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
6. Data Flow
7. Authentik Stage Integration
9. Threat Model
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:
- The user's local
~/.capauth/profile.yml - The OIDC token (duration-limited, typically 1 hour)
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:
- Store names, emails, or group memberships
- Write to any user profile database
- Require "profile management" flows
- Have a "user account" concept beyond the fingerprint row
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
| Property | Mechanism |
| Authenticity | Signature verified against enrolled public key |
| Freshness | Nonce is single-use with 60s TTL |
| Binding | Fingerprint in payload ties claims to specific identity |
| Replay protection | Nonce is consumed (deleted) on use |
| Tampering detection | Any byte change invalidates the signature |
| Forward secrecy | Claims 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
| Data | Where It Lives |
| Name | `~/.capauth/profile.yml` on client device |
| `~/.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:
username:capauth-{fingerprint[:8]}(fingerprint-derived, stable)name: claimed display name (overwritten each login, not the identity anchor)- No email in the Authentik user model โ only in the OIDC token claims
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:
- Fingerprint: required to identify the key for verification
- Public key: required to verify signatures
- Enrollment timestamp: required for audit trail
- Last auth timestamp: required for rate limiting and security monitoring
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
- Names (not stored)
- Email addresses (not stored)
- Group memberships (not stored)
- Any data that would enable identity-based phishing
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
| File | Purpose |
| `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*