CapAuth Zero-Knowledge Profile

Version: 1.0.0 | Date: 2026-02-28 | Status: Implemented

> Core invariant: The CapAuth Verification Service stores ONLY your PGP

> fingerprint and public key. Every other claim โ€” name, email, avatar, groups โ€”

> lives on YOUR device, is signed by YOUR key, and is verified-then-discarded

> by the server. Nothing is persisted. There is no user database. There is no

> profile management. There is nothing to breach.


Table of Contents

1. Problem Statement

2. Design Principles

3. Protocol Specification

4. Signed Claims Bundle

5. Server-Side Storage Model

6. Data Flow

7. Authentik Stage Integration

8. GDPR Compliance

9. Threat Model

10. Implementation Reference


Problem Statement

Every conventional authentication system requires the server to store user

profiles. The server becomes a honeypot: breach the server, steal everyone's

identity, email, group memberships.

CapAuth inverts this. The server is a verifier and claims relay, not a

store. It confirms that a signature is valid, then passes the client's claims

through to the application token. The claims are never written to any database.


Design Principles

1. Fingerprint-Only Identity Anchor

The server stores one 40-character string per user:


9B3AB00F411B064646879B92D10E637B4F8367DA

This is the user's PGP fingerprint โ€” their sovereign, self-sovereign identity.

Nothing else. No name. No email. No UUID assigned by the server.

2. Client-Asserted Claims

All profile information is asserted by the client at login time:


{
  "name": "Chef Jonathan",
  "email": "[email protected]",
  "groups": ["admins", "sovereign-stack"]
}

The client signs these claims cryptographically. The server verifies the

signature but does not store the claims.

3. Claims Are Ephemeral

Claims live only in:

When the token expires, the claims are gone from the server's perspective.

The next login re-asserts them from the client's profile.

4. Server Changes Nothing

The server does not:

5. Privacy by Architecture

User changes their display name locally โ†’ every service sees the new name next

login. No profile update API. No sync delay. No data consistency problem. The

source of truth is the client's device.


Protocol Specification

Phase 1 โ€” Challenge

The client requests authentication from a service.

Request:


POST /capauth/v1/challenge
{
  "fingerprint": "<40-char-uppercase-hex>",
  "client_nonce": "<base64-16-random-bytes>"
}

Response:


{
  "nonce": "<uuid-v4>",
  "client_nonce": "<echo-of-client-nonce>",
  "timestamp": "<ISO-8601-UTC>",
  "expires": "<ISO-8601-UTC-plus-60s>",
  "service": "<service-id>",
  "server_signature": "<ASCII-armored-detach-sig>"
}

The server_signature signs the canonical nonce payload (see below). Clients

SHOULD verify this signature if they have the server's public key, to ensure

they are talking to the expected service.

Canonical nonce payload (what the server signs):


CAPAUTH_NONCE_V1
nonce=<uuid>
client_nonce=<base64>
timestamp=<ISO-8601>
service=<service-id>
expires=<ISO-8601>

Phase 2 โ€” Verify (with ZK Claims Bundle)

The client signs both the nonce and a claims bundle, then submits both.

Request:


POST /capauth/v1/verify
{
  "fingerprint": "<40-char-uppercase-hex>",
  "nonce": "<uuid from challenge>",
  "public_key_armor": "<ASCII-armored PGP public key>",
  "nonce_signature": "<ASCII-armored detach-sig over nonce payload>",
  "claims": {
    "name": "Chef Jonathan",
    "email": "[email protected]",
    "groups": ["admins"]
  },
  "claims_signature": "<ASCII-armored detach-sig over claims payload>"
}

Claims signature payload (what the client signs):


CAPAUTH_CLAIMS_V1
fingerprint=<40-char-fp>
nonce=<uuid>
claims={"email":"[email protected]","groups":["admins"],"name":"Chef Jonathan"}

Claims are JSON-encoded with sorted keys and no whitespace to ensure identical

serialization regardless of platform. The nonce binds the claims to this

specific authentication event โ€” a replayed claims bundle is useless.

Response (success):


{
  "status": "ok",
  "fingerprint": "<fingerprint>",
  "enrolled": true,
  "claims": {
    "sub": "<fingerprint>",
    "capauth_fingerprint": "<fingerprint>",
    "amr": ["pgp"],
    "name": "Chef Jonathan",
    "preferred_username": "Chef Jonathan",
    "email": "[email protected]",
    "email_verified": false,
    "groups": ["admins"]
  }
}

The claims in the response are the OIDC-mapped claims โ€” ready to embed in a

JWT. They are computed on-the-fly and not written to any database.


Signed Claims Bundle

The claims bundle is the mechanism that makes ZK profiles work. It:

1. Carries the client's self-asserted profile data

2. Is bound to a specific nonce (single-use, 60-second TTL)

3. Is signed with the client's PGP private key

4. Is verified server-side against the enrolled public key

5. Is never stored โ€” consumed to produce the OIDC token, then discarded

Bundle Structure (client-side, before signing)


# What the client signs (canonical_claims_payload in verifier.py):
claims_compact = json.dumps(claims, sort_keys=True, separators=(",", ":"))
payload = "\n".join([
    "CAPAUTH_CLAIMS_V1",
    f"fingerprint={fingerprint}",
    f"nonce={nonce}",
    f"claims={claims_compact}",
]).encode("utf-8")

Security Properties

PropertyMechanism
AuthenticitySignature verified against enrolled public key
FreshnessNonce is single-use with 60s TTL
BindingFingerprint in payload ties claims to specific identity
Replay protectionNonce is consumed (deleted) on use
Tampering detectionAny byte change invalidates the signature
Forward secrecyClaims expire with the OIDC token; no server-side record

Server-Side Storage Model

What Is Stored (KeyStore, SQLite)


CREATE TABLE enrolled_keys (
    fingerprint       TEXT PRIMARY KEY,   -- 40-char PGP fingerprint
    public_key_armor  TEXT NOT NULL,       -- ASCII-armored public key
    enrolled_at       TEXT NOT NULL,       -- ISO 8601 enrollment timestamp
    last_auth         TEXT,                -- Last successful auth timestamp
    approved          INTEGER DEFAULT 1,   -- Admin approval flag
    linked_to         TEXT                 -- Primary FP for multi-device
);

Total PII stored per user: 0 bytes

The public key and fingerprint are not considered PII under GDPR (they are

cryptographic identifiers, not personal data per se, though opinions vary by

jurisdiction). Name, email, group memberships, and avatar URLs are not stored.

What Is NOT Stored

DataWhere It Lives
Name`~/.capauth/profile.yml` on client device
Email`~/.capauth/profile.yml` on client device
Groups`~/.capauth/profile.yml` on client device
Avatar URL`~/.capauth/profile.yml` on client device
Soul blueprint`~/.capauth/profile.yml` on client device
Agent type`~/.capauth/profile.yml` on client device

Data Flow


โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  CLIENT DEVICE                                                    โ”‚
โ”‚                                                                   โ”‚
โ”‚  ~/.capauth/profile.yml                                           โ”‚
โ”‚  โ”œโ”€โ”€ name: "Chef Jonathan"                                        โ”‚
โ”‚  โ”œโ”€โ”€ email: "[email protected]"                                     โ”‚
โ”‚  โ”œโ”€โ”€ groups: [admins]                                             โ”‚
โ”‚  โ””โ”€โ”€ service_profiles: {...}                                      โ”‚
โ”‚                                                                   โ”‚
โ”‚  PGP Private Key (NEVER leaves device)                            โ”‚
โ”‚  โ””โ”€โ”€ Signs: nonce payload + claims bundle                         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                             โ”‚
                             โ”‚  POST /capauth/v1/verify
                             โ”‚  {fingerprint, nonce, nonce_sig,
                             โ”‚   claims: {name, email, groups},
                             โ”‚   claims_sig}
                             โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  CAPAUTH VERIFICATION SERVICE                                     โ”‚
โ”‚                                                                   โ”‚
โ”‚  1. Load public_key from enrolled_keys[fingerprint]               โ”‚
โ”‚  2. Verify nonce_sig over canonical nonce payload    โœ“            โ”‚
โ”‚  3. Verify claims_sig over canonical claims payload  โœ“            โ”‚
โ”‚  4. Consume nonce (delete from nonce store)                       โ”‚
โ”‚  5. Map claims โ†’ OIDC claims (map_claims())                       โ”‚
โ”‚  6. Return OIDC claims                                            โ”‚
โ”‚                                                                   โ”‚
โ”‚  NOTHING WRITTEN: claims are processed in-memory only            โ”‚
โ”‚  update_last_auth(fingerprint)  โ† only DB write                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                             โ”‚
                             โ”‚  {status: ok, claims: {sub, name,
                             โ”‚   email, groups, amr: [pgp]}}
                             โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  APPLICATION / AUTHENTIK                                          โ”‚
โ”‚                                                                   โ”‚
โ”‚  Embeds claims in OIDC token (JWT, 1-hour expiry)                โ”‚
โ”‚  Creates local session from token claims                          โ”‚
โ”‚  MAY store claims in its own user table (application's choice)   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Authentik Stage Integration

The CapAuthStage in authentik/stage.py implements the ZK principle for

Authentik's authentication flow:

User Object Invariant


# From stage.py โ€” what gets written to the Authentik user model:
user.username = preferred_username  # from claims (ephemeral display, not stored as PII)
user.name = display_name            # from claims (display only, overwritten each login)
# user.email is NOT set on the Authentik user โ€” email is claims-only

The Authentik User object is keyed on username, which is derived from the

fingerprint as capauth-{FP[:8].upper()} when no name is claimed. This ensures

the user record is stable and fingerprint-derived, not name-derived.

The user record that does get created in Authentik contains:

Claims Flow Through the Stage


# stage.py โ€” build_challenge() / verify_auth_response()
claims = response.data.get("claims", {})          # from client
oidc_claims = map_claims(fingerprint, claims)      # translate to OIDC
context[PLAN_CONTEXT_PENDING_USER] = user          # user keyed on fingerprint
context["capauth_claims"] = oidc_claims            # ephemeral, in-flow only

The capauth_claims context key lives only for the duration of the Authentik

flow. It is consumed by the claims mapper stage and embedded in the OIDC token.

It is not written to any Authentik database field.


GDPR Compliance

Right to Erasure (Article 17)

To fully erase a user from a CapAuth-integrated system:


# Revoke from CapAuth service
curl -X POST http://capauth:8420/capauth/v1/keys/revoke \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"fingerprint": "9B3AB00F..."}'

# That's it. One row deleted.
# No user profile table to clean up.
# No email address to hunt down.
# No avatar URLs to purge.

What this erases: The fingerprint row + public key (~700 bytes).

What this does NOT erase: Claims in existing unexpired OIDC tokens. This is

an inherent property of stateless tokens and is standard GDPR practice (tokens

have bounded lifetime).

Right to Access (Article 15)

A user can see everything the server stores about them:


curl http://capauth:8420/capauth/v1/keys/me \
  -H "Authorization: Bearer $CAPAUTH_TOKEN"
# Returns: {fingerprint, enrolled_at, last_auth}
# That is literally all we have.

Data Minimization (Article 5(1)(c))

We collect the minimum data technically necessary:

All other data (name, email, groups) is explicitly excluded from storage.


Threat Model

What an Attacker Gets from Breaching the KeyStore


fingerprints: [
  "9B3AB00F411B064646879B92D10E637B4F8367DA",
  "AABBCCDDEEFF00112233445566778899AABBCCDD",
  ...
]
public_keys: [
  "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
  ...
]

Impact: None for authentication (public keys are public). Zero PII. An

attacker learns that certain PGP fingerprints have accounts, which is

equivalent to a list of usernames โ€” already public information.

What an Attacker CANNOT Get

Nonce Replay Attack

Threat: Attacker intercepts a verify request and replays it.

Defense: Nonces are single-use. Each nonce is deleted from the nonce store

when consumed. A replayed verify request will fail with nonce not found.

TTL is 60 seconds โ€” even a fresh nonce expires before it can be misused in

most attack scenarios.

Claims Tampering Attack

Threat: Attacker intercepts a verify request and replaces "groups": ["users"]

with "groups": ["admins"].

Defense: The claims are signed. Any modification invalidates the signature.

The server verifies the signature before processing claims.

Key Compromise

Threat: Attacker steals the client's PGP private key.

Defense: Standard key revocation. The compromised key can be revoked by

the admin via the /capauth/v1/keys/revoke endpoint. New authentication

requires the private key, so a revoked key cannot generate new sessions.


Implementation Reference

Core Files

FilePurpose
`capauth/src/capauth/authentik/verifier.py`Canonical payload builders + signature verification
`capauth/src/capauth/authentik/claims_mapper.py`Maps client claims โ†’ OIDC claims
`capauth/src/capauth/authentik/stage.py`Authentik stage โ€” ZK flow orchestrator
`capauth/src/capauth/service/app.py`FastAPI service โ€” standalone verifier
`capauth/src/capauth/service/keystore.py`SQLite key store โ€” fingerprint-only schema
`capauth/src/capauth/profile.py`Client profile management

Canonical Payload Functions


# verifier.py
def canonical_nonce_payload(nonce, client_nonce_echo, timestamp, service, expires) -> bytes
def canonical_claims_payload(fingerprint, nonce, claims) -> bytes

Adding a New Claim Type

1. Add the claim to _KNOWN_CLAIMS in claims_mapper.py

2. Map it in map_claims() under the appropriate scope

3. Update the scope table in SCOPE_CLAIMS

4. Document it in CLAIMS.md

5. Update profile.yml examples

Invariant to preserve: New claims MUST come from the client. Never derive

a claim from server-side lookup. The server is a relay, not an oracle.


Quick Reference

Server stores per user


fingerprint  โ†’  9B3AB00F411B064646879B92D10E637B4F8367DA   (40 chars)
public_key   โ†’  -----BEGIN PGP PUBLIC KEY BLOCK-----...    (~3 KB)
enrolled_at  โ†’  2026-02-24T13:00:00Z                        (20 chars)
last_auth    โ†’  2026-02-28T09:15:32Z                        (20 chars)

Client stores per user


~/.capauth/identity/private.asc  โ†’  PGP private key (STAYS HERE)
~/.capauth/identity/public.asc   โ†’  PGP public key
~/.capauth/profile.yml           โ†’  name, email, groups, service profiles

GDPR deletion


# Delete ONE row โ†’ user completely erased from CapAuth
skcapstone coord claim <revoke-task> --agent mcp-builder
capauth-service revoke --fingerprint 9B3AB00F...

*CapAuth: your identity is a key, not a database row.*

*#staycuriousANDkeepsmilin*