Vow Technical Specifications#
This document contains technical specifications for Vow's two-key WebAuthn PRF signing architecture. It describes the protocol, security properties, trade-offs, and maintenance considerations for operators and developers.
Overview#
Vow implements a keyless PDS architecture using WebAuthn PRF (Pseudorandom Function) extension to derive signing keys deterministically from passkeys. The server never stores the private signing key — it is derived on-the-fly for each commit operation.
Key Design Principles#
- Server never holds signing key — The passkey provides PRF output; server derives signing key deterministically
- Deterministic derivation — Same PRF output always produces same signing key
- Two-key model — Separate keys for repo commits (passkey-derived) vs service-auth (PDS server)
- User-controlled DID — After passkey registration, only user can authorize identity operations via passkey
Architecture#
Database Schema#
CREATE TABLE repos (
did TEXT PRIMARY KEY,
created_at DATETIME,
email TEXT UNIQUE,
auth_public_key BLOB, -- Passkey P-256 public key (for WebAuthn assertion verification)
signing_public_key BLOB, -- PRF-derived secp256k1 signing key (for commit signature verification)
credential_id BLOB, -- WebAuthn credential ID (for building allowCredentials list)
rev TEXT,
root BLOB,
preferences BLOB,
deactivated BOOLEAN
);
Key Types#
| Key | Type | Purpose | Stored |
|---|---|---|---|
auth_public_key |
P-256 | WebAuthn assertion verification | ✅ Yes |
signing_public_key |
secp256k1 | Commit signature verification | ✅ Yes |
| PRF-derived private key | secp256k1 | Commit signing | ❌ No (derived on-the-fly) |
| PDS rotation key | secp256k1 | PLC genesis, initial key transfer | ✅ Yes |
| PDS service key | P-256 | Session/OAuth JWT signing | ✅ Yes |
DID Document Structure#
{
"verificationMethods": {
"atproto": "did:key:z6Mkq... (PRF-derived signing key)",
"atproto_service": "did:key:z5Mka... (PDS server key)"
},
"rotationKeys": ["did:key:z6Mkq... (PRF-derived signing key)"]
}
After key registration, PDS rotation key is removed from DID document. Only user's passkey-derived key is the rotation key.
Protocol Flows#
Passkey Registration Flow#
Objective: Register a new passkey and transfer DID control from PDS to user.
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Browser │ │ PDS Server │ │ PLC Directory │
└─────┬───────┘ └──────┬───────────┘ └────────┬─────────┘
│ │ │
│ 1. Request │ │
│ challenge │ │
├──────────────────────>│ │
│ │ 2. Get DID audit │
│ ├───────────────────────>│
│ │ │ 3. Submit
│ │<────────────────────────┘ operation
│ 3. Create │ │ (signing key becomes
│ passkey │ │ rotation key)
│<───────────────────────┤
│ │ │
│ 4. Extract & │ │
│ derive signing │ │
│ key │ │
│ │ │
│ 5. Send signing │ │
│ public key │ │
├──────────────────────>│ │
│ │ 4. Update DB │
│ ├──────────────────────────>│
│ │ │
│ │<──────────────────────────┘
│<───────────────────────┘
Steps:
- Client requests challenge — Server returns WebAuthn creation options with random challenge and PRF extension
- Client creates passkey — Passkey generated with PRF extension using fixed seed
- Client extracts PRF output — Retrieved from passkey response extensions
- Client derives signing key — Deterministic HKDF-SHA256 derivation from PRF output
- Client sends public key — Sends attestation and derived signing public key
- Server validates and stores — Validates passkey attestation, stores keys, updates DID via PLC
- Key transfer — Server signs PLC operation to make user's key the rotation key
Commit Signing Flow#
Objective: Sign a repo commit using passkey-derived signing key.
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Browser │ │ PDS Server │ │ Account Page │
│ (ATProto │ │ │ │ (Signer) │
│ Client) │ │ │ └────────┬─────────┘
└─────┬───────┘ └──────┬───────────┘ │
│ │ │
│ 1. applyWrites │ │
├──────────────────────>│ │
│ │ 2. WebSocket sign request │
│<───────────────────────┼───────────────────────>│
│ │ │ 3. Request passkey
│ │ │ assertion (no PRF)
│ │ │
│ │ 4. Retrieve PRF │
│ │ output from storage │
│ │ │
│ │<──────────────────────────┘
│ │
│ │ 5. Derive signing key
│ │ from stored PRF
│ │
│ │ 6. Sign commit
│ │
│<───────────────────────┤
│ 7. sign_response │
├──────────────────────>│ 8. Verify assertion
│ │ against authPublicKey
│ │
│ │ 9. Verify commit signature
│ │ against signingPublicKey
│ │
│ │ 10. Persist commit
│<───────────────────────┘
Steps:
- Client initiates write — Standard ATProto client sends write request
- Server sends sign request — Holds HTTP request, sends WebSocket sign request
- Client retrieves PRF output — Retrieves from browser storage
- Client derives signing key — Same deterministic derivation as registration
- Client signs commit — Signs with derived key
- Client gets passkey assertion — Passkey assertion for authentication (no PRF needed)
- Client sends sign response — Sends passkey assertion and commit signature
- Server verifies assertion — Verifies passkey authentication
- Server verifies commit signature — Verifies commit against stored signing public key
- Server persists commit — Finalizes and stores commit
Service-Auth Flow#
Objective: Generate JWT for background requests (feeds, notifications) without passkey.
┌─────────────┐ ┌──────────────────┐
│ Any Client│ │ PDS Server │
└─────┬───────┘ └──────┬───────────┘
│ │
│ getServiceAuth │
├──────────────────────>│ 1. Validate session
│ │ 2. Look up did:key
│ │ 3. Sign JWT with PDS key
│ │ 4. Return JWT
│<───────────────────────┘
Steps:
- Client requests service auth — Standard ATProto call
- Server validates session — From JWT or session cookie
- Server loads PDS key — Server's private service key
- Server signs JWT — Signed with PDS key
- Returns signed JWT — Standard ES256 signature, no passkey required
Note: Service-auth JWTs are verified by checking verificationMethods.atproto_service (PDS key), not verificationMethods.atproto (passkey-derived key).
Identity Operation Flow#
Objective: User updates handle, email, or other PLC-managed identity data.
Before Passkey Registration (PDS Controlled)#
┌─────────────┐ ┌──────────────────┐
│ Browser │ │ PDS Server │
│ (Request) │ │ │
└─────┬───────┘ └──────┬───────────┘
│ │
│ requestPlcOperation │
├──────────────────────>│ 1. Sign with rotation.key
│ │ 2. Submit to PLC
│ │ 3. Return signed op
│<───────────────────────┘
PDS has full authority — can modify DID document freely.
After Passkey Registration (User Controlled)#
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Browser │ │ PDS Server │ │ Account Page │
│ (Request) │ │ │ │ (Signer) │
└─────┬───────┘ └──────┬───────────┘ └──────┬──────────┘
│ │ │
│ requestPlcOperation │ │
├──────────────────────>│ │
│ │ 1. Request signature │ 2. Get passkey
│ │ from SignerHub │ assertion
│ │<──────────────────────┼───────────────────>│
│ │ │ 3. Re-derive signing
│ │ │ key from PRF
│ │ │
│ │ 4. Sign PLC operation│
│ │<──────────────────────┘
│ │ 5. Submit to PLC
│ │<──────────────────────────┘
│<───────────────────────┘
Steps:
- Client requests operation — Standard ATProto call
- Server builds PLC operation — Constructs operation CBOR
- Server requests signature — Via SignerHub WebSocket
- SignerHub prompts user — Passkey assertion required
- Client derives signing key — From stored PRF output
- Client signs PLC operation — With derived signing key
- Server submits to PLC — With user's signature
- Only user's key — Is now the rotation key
Security Properties#
Key Isolation#
| Concern | Passkey | PRF-Derived Signing Key | PDS Server Key |
|---|---|---|---|
| Private key stored on server | ❌ No | ❌ No | ✅ Yes |
| Can sign repo commits | ❌ No | ✅ Yes | ❌ No |
| Can sign PLC operations | ❌ No (directly) | ✅ Yes (via PRF derivation) | ✅ Yes (before transfer) |
| Can sign service-auth JWTs | ❌ No | ❌ No | ✅ Yes |
| Requires user interaction | ✅ Yes | ✅ Yes (via passkey) | ❌ No |
Threat Model Mitigation#
| Threat | Standard PDS | Vow |
|---|---|---|
| Server repo key compromise | Server can forge any commit | ❌ Server never sees signing private key |
| Server admin abuse | Server can rotate keys, change DID | ❌ After key transfer, only user can modify DID |
| Database exfiltration | Exposes all private keys | ❌ Only public keys stored |
Deterministic Key Derivation#
Same PRF output produces the same signing key deterministically:
- Derivation method: HKDF-SHA256 with fixed parameters
- PRF seed: Fixed value for reproducibility
- Result: Derivation is reproducible across sessions, browsers, devices
Why this matters:
- Consistent signatures — Same commit always produces same signature
- Key recovery — Lost browser storage can be recovered by re-registering passkey with same seed
- Auditability — PRF output never needs to leave device, but same key material always derivable
Trade-offs#
Advantages#
| Advantage | Description |
|---|---|
| User-controlled DID | After passkey registration, PDS cannot modify user's identity |
| Key isolation | Compromised database never exposes signing private keys |
| Hardware security | Passkey private key lives in TPM/Secure Enclave, never in browser memory |
| No extensions needed | Works in any modern browser (WebAuthn Level 3 with PRF support) |
| Portable identity | User can migrate to new PDS by updating serviceEndpoint via PLC |
| Standard federation | Uses did:plc — full compatibility with ATProto ecosystem |
Disadvantages#
| Disadvantage | Mitigation | |
|---|---|---|
| Requires browser tab | User must keep account page open for signing | Pin tab, use separate device |
| PRF browser support | Not all browsers passkeys support PRF extension | Chrome 126+, Edge 126+, Safari 18+, Firefox 130+ |
| Storage dependency | Lost PRF output on cache clear requires re-registration | Re-register passkey (seed produces same key) |
| Service-auth gap | Standard ATProto clients don't check #atproto_service |
RFC pending |
| Passkey loss | Lost passkey = lost identity (no recovery mechanism) | This is inherent to passkeys, not Vow-specific |
Compatibility Gaps#
Compat Mode#
Problem: Standard ATProto reference implementation and AppViews verify service-auth JWTs by checking verificationMethods.atproto only, ignoring verificationMethods.atproto_service. Additionally, external AppViews (like official Bluesky) only accept ES256K (secp256k1) signed service-auth JWTs.
Solution: Vow provides a "compat mode" that:
- Signs service-auth JWTs with the user's passkey-derived secp256k1 key (not the PDS P-256 key)
- Uses ES256K algorithm with
alg: "ES256K"andcrv: "secp256k1"in JWT header - Requires browser interaction for each service-auth request (passkey assertion)
Current Status:
- ✅ Vow correctly sets
#atproto_servicein DID document - ✅ Vow correctly signs service-auth JWTs with that key (non-compat mode)
- ✅ Compat mode signs with secp256k1 key that external AppViews accept
- ❌ Standard clients without compat mode reject tokens with
BadJwtSignatureuntil RFC is implemented. - ✅ Vow's own account page works (reads
#atproto_service)
Impact:
- ✅ Compat mode: Background feed loading, notifications work with external AppViews
- ⚠️ Compat mode UX: More frequent passkey approvals required
- ❌ Non-compat mode: External AppViews reject service-auth tokens
- ✅ Vow's built-in UI works fully regardless of mode
Maintenance#
Key Management#
Server Keys (Persistent)#
| Key | Location | Rotation Policy | Backup Required |
|---|---|---|---|
rotation.key |
./keys/rotation.key |
Never (used for DID genesis) | ✅ Yes |
jwk.key |
./keys/jwk.key |
Never | ✅ Yes |
Best Practices:
- Store
./keys/backups in secure, off-site location rotation.keycompromise = complete DID takeover (all accounts before key transfer)jwk.keycompromise = session hijacking (cannot forge commits, but can issue JWTs)- Use strong permissions on
./keys/directory
User Keys (Transient)#
| Key | Storage | Derivation | Lifetime |
|---|---|---|---|
| Passkey private key | Hardware (TPM/Secure Enclave) | Permanent (until deregistered) | |
| PRF output | Browser storage | Until browser data cleared | |
| PRF-derived signing key | Derived on-the-fly | Ephemeral (per-session) |
Recovery:
- Lost passkey: No recovery — identity lost (passkey limitation)
- Lost PRF output: Re-register same passkey → same signing key (deterministic)
Database Maintenance#
Schema#
The repos table structure is stable:
auth_public_key— Passkey P-256 public keysigning_public_key— PRF-derived secp256k1 signing keycredential_id— WebAuthn credential ID
Upgrade path:
- Stop services
- Delete old database
- Restart services
- ⚠️ This deletes all user data — backup first!
Index Maintenance#
Automatic indexing:
- Records indexed by
did,nsid,rkey - Blobs indexed by
did,cid(reference counting) - Session indexed by
token,did
No manual index maintenance required.
PLC Operations#
Key Transfer Operation#
When user registers an existing passkey:
- PDS builds PLC operation with user's signing key
- Operation is signed by:
- First registration: PDS rotation key (still has authority)
- Key rotation: User's passkey-derived signing key (new rotation key)
- Submit to PLC directory
Audit trail:
- PLC audit log shows clear transfer
rotationKeyschanges from PDS key to user key- Timestamp proves when user took control
Future Identity Updates#
After key transfer, user can:
- Update handle — Requires passkey signature
- Update email — Requires email confirmation link (no passkey)
- Deactivate account — Requires passkey signature
All identity changes require passkey authentication after transfer.
Disaster Recovery#
Scenario: Complete Server Loss#
-
PDS rotation key compromised:
- Attackers can create new accounts, transfer control before user registers passkey
- Detection: Multiple new registrations with different emails
- Recovery: Generate new
rotation.key(invalidates old key)
-
Database exfiltration:
- Attackers get public keys only
- Cannot sign commits (no private signing key)
- Impact: Read-only access to past commits
- Recovery: Revoke passkeys, rotate PDS keys
-
IPFS data loss:
- Users lose repos and blobs
- Recovery: Users re-sync from relays
Scenario: User Loss Recovery#
-
Lost passkey (after DID transfer):
- ❌ No recovery possible
- Mitigation: Register multiple passkeys on different devices before loss
-
Accidentally deleted browser storage (PRF output):
- ✅ Re-register same passkey
- ✅ Deterministic derivation produces same signing key
- ✅ Identity remains intact
WebSocket Protocol Specification#
Connection#
Endpoint: wss://<hostname>/account/signer
Authentication: Session cookie or Bearer token
Message Types#
sign_request (Server → Client)#
{
"type": "sign_request",
"requestId": "uuid-v4",
"did": "did:plc:abc123...",
"payload": "base64url-encoded unsigned commit CBOR",
"ops": [
{
"type": "create|update|delete",
"collection": "app.bsky.feed.post|app.bsky.graph.follows|...",
"rkey": "record key (for updates)"
}
],
"expiresAt": "ISO-8601 timestamp"
}
Fields:
requestId: Unique identifier for this requestdid: User's DIDpayload: CBOR bytes of unsigned commitops: Array of operations (for UI display)expiresAt: When request becomes invalid
sign_response (Client → Server)#
{
"type": "sign_response",
"requestId": "uuid-v4",
"authenticatorData": "base64url authenticatorData bytes",
"clientDataJSON": "base64url clientDataJSON bytes",
"signature": "base64url DER-encoded ECDSA signature",
"commitSignature": "base64url 64-byte raw r||s signature"
}
Fields:
requestId: Must match original requestauthenticatorData: WebAuthn authenticator dataclientDataJSON: WebAuthn client data JSONsignature: DER-encoded ECDSA signature from passkeycommitSignature: Raw signature from PRF-derived signing key
sign_reject (Client → Server)#
{
"type": "sign_reject",
"requestId": "uuid-v4"
}
Used when:
- User cancelled passkey prompt
- Passkey not found
- Request expired
- Technical error
Connection Lifecycle#
-
Handshake
- HTTP GET → WebSocket upgrade
- Server verifies session/bearer token
- Connection accepted or rejected
-
Keepalive
- Server sends
pingmessages - Client responds with
pong - Missing
pong→ connection terminated
- Server sends
-
Reconnection
- Exponential backoff
- Automatic on page visible, tab focus
- Manual: User clicks "Connect" button
-
Single connection per DID
- New connection immediately closes old connection
- "Replacing existing connection" logged
Error Handling#
Client-Side Errors#
| Error | Cause | User Action |
|---|---|---|
PRF extension output not available |
Browser/passkey doesn't support PRF | Use Chrome 126+, Edge 126+, Safari 18+, Firefox 130+ |
PRF output not found |
Browser storage cleared | Re-register passkey (same key) |
Passkey creation was cancelled |
User dismissed prompt | Try again |
Passkey assertion was cancelled |
User dismissed prompt | Try again |
Server-Side Errors#
| Error | HTTP Status | Cause |
|---|---|---|
no public key registered for account |
400 | No signing key in DB (register passkey) |
assertion verification failed |
401 | Invalid passkey signature |
signature verification failed |
400 | Derived key mismatch (BUG) |
challenge mismatch |
400 | Tampered request |
rpIdHash mismatch |
400 | Wrong origin/domain |
no extension data in authenticatorData |
400 | PRF not supported |
failed to parse attestation object |
400 | Invalid WebAuthn response |