···44> This is highly experimental software. Use with caution, especially during account migration.
5566> [!IMPORTANT]
77-> **Vow implements a two-key model for signing that requires a pending ATProto protocol change to be fully interoperable.**
88->
99-> ATProto uses a single DID document key (`verificationMethods.atproto`) for two distinct purposes: signing repo commits _and_ signing service-auth JWTs. Vow splits these into two separate keys:
1010->
1111-> - **`verificationMethods.atproto`** → the user's passkey. Signs every repo commit. Requires passkey usage, which is acceptable because commits are user-initiated.
1212-> - **`verificationMethods.atproto_service`** → the PDS server key. Signs service-auth JWTs for background requests (feed loading, notifications, proxied reads) without ever prompting the passkey.
1313->
1414-> [did-method-plc#101](https://github.com/did-method-plc/did-method-plc/pull/101) (merged June 2025) relaxed PLC directory constraints so DID documents can carry keys under arbitrary fragment names. Vow already writes both keys into the DID document during `supplySigningKey`.
1515->
1616-> **What remains blocked:** AppViews and relays in the reference implementation ([indigo](https://github.com/bluesky-social/indigo)) still call `identity.PublicKey()` → `GetPublicKey("atproto")` when verifying service-auth JWTs, so they will reject tokens signed by the PDS key with `BadJwtSignature`. A spec change adding an `#atproto_service` fallback is required. The RFC is [here](https://github.com/bluesky-social/atproto/discussions/4739). Until it lands in indigo and Bluesky's infrastructure, feeds, notifications, and proxied reads on standard ATProto clients will not work.
77+> **Vow implements a two-key model for signing using WebAuthn PRF extension.** After registering a passkey, the server never stores a private signing key — the passkey authenticates and provides PRF output, from which a deterministic signing key is derived on-the-fly for each commit. Users fully control their DID.
1781818-Vow is a Go PDS (Personal Data Server) for the AT Protocol.
1919-2020-## Features
2121-2222-- ✅ **IPFS storage** — repo blocks and blobs are stored on a local Kubo node and indexed in SQLite by DID and CID.
2323-- ✅ **Keyless PDS** — the server never stores a private key. Every write is signed by the user's passkey (WebAuthn/FIDO2) registered during onboarding.
2424-- ✅ **Browser signer** — the account page connects over WebSocket and signs repo commits and PLC operations with the user's passkey. No browser extension or extra software is needed; just keep the tab open. Standard ATProto clients do not need to know about it.
2525-- ✅ **User-controlled DID** — when the user registers a key, the PDS transfers the `did:plc` rotation key to the user's passkey-derived public key. After that, only the user can change their identity.
99+Vow is a Go PDS (Personal Data Server) for AT Protocol.
26102711## Quick Start with Docker Compose
2812···33173418### Installation
35193636-1. **Clone the repository**
2020+1. **Clone repository**
37213838- ```bash
3939- git clone https://pkg.rbrt.fr/vow.git
4040- cd vow
4141- ```
2222+```bash
2323+git clone https://pkg.rbrt.fr/vow.git
2424+cd vow
2525+```
422643272. **Create your configuration file**
44284545- ```bash
4646- cp .env.example .env
4747- ```
2929+```bash
3030+cp .env.example .env
3131+```
483249333. **Edit `.env` with your settings**
50345151- ```bash
5252- VOW_DID="did:web:your-domain.com"
5353- VOW_HOSTNAME="your-domain.com"
5454- VOW_CONTACT_EMAIL="you@example.com"
5555- VOW_RELAYS="https://bsky.network"
3535+```bash
3636+VOW_DID="did:web:your-domain.com"
3737+VOW_HOSTNAME="your-domain.com"
3838+VOW_CONTACT_EMAIL="you@example.com"
3939+VOW_RELAYS="https://bsky.network"
56405757- # Generate with: openssl rand -hex 16
5858- VOW_ADMIN_PASSWORD="your-secure-password"
4141+# Generate with: openssl rand -hex 16
4242+VOW_ADMIN_PASSWORD="your-secure-password"
59436060- # Generate with: openssl rand -hex 32
6161- VOW_SESSION_SECRET="your-session-secret"
6262- ```
4444+# Generate with: openssl rand -hex 32
4545+VOW_SESSION_SECRET="your-session-secret"
4646+```
63476464-4. **Start the services**
4848+4. **Start services**
65496666- ```bash
6767- docker compose pull
6868- docker compose up -d
6969- ```
5050+```bash
5151+docker compose pull
5252+docker compose up -d
5353+```
70547171- This starts three services:
7272- - **ipfs** — a Kubo node for repo blocks and blobs
7373- - **vow** — the PDS
7474- - **create-invite** — creates an initial invite code on first run
5555+This starts three services:
5656+5757+- **ipfs** — a Kubo node for repo blocks and blobs
5858+- **vow** — the PDS
5959+- **create-invite** — creates an initial invite code on first run
756076615. **Get your invite code**
77627878- On first run, an invite code is automatically created. View it with:
6363+On first run, an invite code is automatically created:
79648080- ```bash
8181- docker compose logs create-invite
8282- ```
6565+```bash
6666+docker compose logs create-invite
6767+```
83688484- Or check the saved file:
6969+Or check saved file:
7070+7171+```bash
7272+cat keys/initial-invite-code.txt
7373+```
85748686- ```bash
8787- cat keys/initial-invite-code.txt
8888- ```
7575+6. **Monitor services**
89769090-6. **Monitor the services**
9191- ```bash
9292- docker compose logs -f
9393- ```
7777+```bash
7878+docker compose logs -f
7979+```
94809581### What Gets Set Up
96829797-- **init-keys**: Generates the rotation key and JWK on first run
9898-- **ipfs**: A Kubo node for repo blocks and blobs. The RPC API (port 5001) stays internal; the gateway (port 8080) is exposed on `127.0.0.1:8081` for your reverse proxy.
8383+- **init-keys**: Generates rotation key and JWK on first run
8484+- **ipfs**: A Kubo node for repo blocks and blobs. The RPC API (port 5001) stays internal; gateway (port 8080) is exposed on `127.0.0.1:8081` for your reverse proxy.
9985- **vow**: The main PDS service on port 8080
10086- **create-invite**: Creates an initial invite code on first run
10187···123109124110### Database
125111126126-Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens. No extra setup is required.
112112+Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens.
127113128114```bash
129115VOW_DB_NAME="/data/vow/vow.db"
···131117132118### IPFS Node
133119134134-In Docker Compose, the local Kubo node is configured automatically through the internal hostname `ipfs`. For bare-metal deployments, point vow at your local node:
135135-136120```bash
137137-# URL of the Kubo RPC API
121121+# URL of Kubo RPC API
138122VOW_IPFS_NODE_URL="http://127.0.0.1:5001"
139123140140-# Optional: redirect sync.getBlob to a public gateway instead of proxying
141141-# through vow. Set to your own gateway or a public one like https://ipfs.io
124124+# Optional: redirect sync.getBlob to a public gateway
142125VOW_IPFS_GATEWAY_URL="https://ipfs.example.com"
143126```
144144-145145-`VOW_IPFS_NODE_URL` is the only required IPFS setting.
146127147128### SMTP Email
148129···159140160141The PDS holds two keys:
161142162162-- **Rotation key** (`rotation.key`) — a secp256k1 key used for DID genesis operations and for signing the PLC operation that transfers control to the user's passkey during `supplySigningKey`. It is never used to sign user content.
143143+- **Rotation key** (`rotation.key`) — used for DID genesis operations and for signing the PLC operation that transfers control to the user's passkey during passkey registration.
163144- **JWK key** (`jwk.key`) — a P-256 ECDSA key used exclusively to sign ATProto session JWTs (access and refresh tokens) and OAuth tokens. It has no role in repo writes or identity operations.
164145165146Neither key is ever used to sign repo commits or service-auth JWTs.
166147167167-Every repo write from the Bluesky app, Tangled, or any other standard ATProto client is held open until the user's passkey returns a signature through the browser signer on the account page.
168168-169169-#### End-to-end signing flow
170170-171171-Standard ATProto clients do not need to know about this flow. The PDS keeps the HTTP request open while it waits for the signature, then responds normally.
172172-173173-```
174174-ATProto client PDS (vow) Account page (browser) Passkey (WebAuthn)
175175- | | | |
176176- |-- createRecord ----->| | |
177177- | |-- 1. build unsigned commit | |
178178- | |-- 2. push signing request | |
179179- | | over WebSocket ---------->| |
180180- | (HTTP held open, | |-- navigator |
181181- | up to 30 s) | | .credentials.get()->|
182182- | | |<-- assertion ---------|
183183- | |<-- 3. signature over WS -------| |
184184- | |-- 4. verify signature | |
185185- | |-- 5. finalise & persist commit | |
186186- |<-- 200 result -------| | |
187187-```
188188-189189-**What requires a passkey signature:**
190190-191191-Only operations that change the user's repo content, or identity operations after the user has taken ownership of their rotation key, need a passkey signature:
192192-193193-- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
194194-- **Identity operations** — PLC operations and handle updates, **once the user's passkey is the rotation key**. Before that, the PDS rotation key signs them directly.
195195-196196-Read-only operations (browsing feeds, loading profiles, fetching notifications, etc.) do **not** prompt the passkey. Service-auth JWTs for proxied requests (`getServiceAuth`, ATProto proxy) are signed by the PDS server key (`#atproto_service`) and require no passkey: the signer tab does not need to be open for these.
197197-198198-#### WebSocket connection
199199-200200-The account page connects with the session cookie set at sign-in:
201201-202202-```
203203-GET /account/signer
204204-Cookie: <session-cookie>
205205-```
206206-207207-The connection is upgraded to a WebSocket and kept alive with ping/pong. Only one active connection per DID is supported; a new connection replaces the old one. The connection stays open while the tab is open and reconnects automatically with exponential backoff if interrupted.
208208-209209-A legacy Bearer-token endpoint is also available for programmatic clients:
210210-211211-```
212212-GET /xrpc/com.atproto.server.signerConnect
213213-Authorization: Bearer <access-token>
214214-```
215215-216216-**Signing request message (PDS → browser):**
217217-218218-```json
219219-{
220220- "type": "sign_request",
221221- "requestId": "uuid",
222222- "did": "did:plc:...",
223223- "payload": "<base64url-encoded unsigned commit bytes>",
224224- "ops": [
225225- { "type": "create", "collection": "app.bsky.feed.post", "rkey": "3abc..." }
226226- ],
227227- "expiresAt": "2025-01-01T00:00:30Z"
228228-}
229229-```
230230-231231-**Signature response message (browser → PDS):**
232232-233233-```json
234234-{
235235- "type": "sign_response",
236236- "requestId": "uuid",
237237- "authenticatorData": "<base64url authenticatorData bytes>",
238238- "clientDataJSON": "<base64url clientDataJSON bytes>",
239239- "signature": "<base64url DER-encoded ECDSA signature>"
240240-}
241241-```
242242-243243-The server decodes all three fields, reconstructs the signed message as `authenticatorData ‖ SHA-256(clientDataJSON)`, verifies the P-256 signature, and converts the DER-encoded signature to the raw 64-byte (r‖s) format expected by ATProto before delivering it to the waiting write handler.
244244-245245-**Rejection message (browser → PDS):**
246246-247247-```json
248248-{
249249- "type": "sign_reject",
250250- "requestId": "uuid"
251251-}
252252-```
148148+## How It Works
253149254150### Browser-Based Signer
255151256256-The signer runs entirely in the PDS account page. No browser extension or extra software is needed. The user keeps the page open (a pinned tab works well) and signing happens automatically when the passkey is used.
257257-258258-## Identity & DID Sovereignty
259259-260260-Vow uses `did:plc` for full ATProto federation compatibility. AppViews, relays, and other PDSes can resolve DIDs through `plc.directory` without custom logic. The main difference from a standard PDS is **who controls the rotation key**.
261261-262262-### The problem with standard ATProto
263263-264264-In a typical PDS, the server holds the `did:plc` rotation key. That means the operator can change the user's DID document, rotate signing keys, change service endpoints, or effectively hijack the identity. The user has to trust the operator not to do that.
265265-266266-### Vow's approach: trust-then-transfer
267267-268268-Vow uses a two-phase model:
269269-270270-**Phase 1 — Account creation.** `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key.
271271-272272-**Phase 2 — Key registration.** When the user completes onboarding and calls `supplySigningKey`, a new passkey is created via the WebAuthn API (`navigator.credentials.create()`). The PDS receives the public key from the attestation response, then submits a PLC operation that makes the user's passkey-derived key both the signing key and the **only rotation key**, removing the PDS key from the DID document. After that, only the user's passkey can authorise future PLC operations. The PDS rotation key file is not destroyed — it continues to be used for DID genesis when new accounts register — but it no longer has any authority over this user's DID document.
273273-274274-### What the user gets
275275-276276-| Property | Before key registration | After key registration |
277277-| ------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
278278-| Who signs commits | Nobody (no key registered) | User's passkey (`#atproto`) |
279279-| Who signs service-auth JWTs | PDS server key | PDS server key (`#atproto_service`) |
280280-| Who controls the DID | PDS rotation key | User's passkey-derived key |
281281-| PDS can hijack identity | Yes | **No** |
282282-| User can migrate to new PDS | Yes (If rotation key was set) & No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) |
283283-| Commit federation compatibility | Full | Full (unchanged) |
284284-| Service-auth federation compat. | Full | Partial — requires [`#atproto_service` RFC](https://github.com/bluesky-social/atproto/discussions/4739) to land in AppViews |
285285-286286-### Verifiability
152152+The account page (`/account`) connects over WebSocket and runs entirely in the browser. No browser extension or extra software is needed — the user just keeps the tab open and signs commits automatically when prompted.
287153288288-The transfer is publicly verifiable. The PLC audit log at `plc.directory` shows the full history of rotation key changes. After `supplySigningKey`, the log shows the PDS rotation key being replaced by the user's passkey-derived `did:key`.
154154+### Key Flow
289155290290-### Why passkeys instead of Ethereum wallets?
156156+1. **Before passkey registration** — PDS controls DID with its rotation key
157157+2. **After passkey registration** — User's passkey becomes the rotation key, derived via WebAuthn PRF extension. Only the user can modify their DID.
158158+3. **Signing commits** — Passkey authenticates user and provides PRF output. A deterministic signing key is derived from PRF output and used to sign commits.
291159292292-The original Vow design used Ethereum wallets (MetaMask, Rabby, etc.) and `personal_sign` / EIP-191 for commit signing. In practice, verifying Ethereum-style message hashes against ATProto's expected signature format proved unreliable — the EIP-191 prefix and keccak256 hashing are incompatible with how ATProto verifiers check secp256k1 signatures.
160160+### Two-Key Model
293161294294-Passkeys (WebAuthn/FIDO2) solve this:
162162+Vow implements a two-key model:
295163296296-- **No browser extension required** — passkeys are built into every modern browser and OS.
297297-- **Hardware-backed security** — the private key lives in a secure enclave (TPM, Secure Enclave, or a roaming authenticator like a YubiKey). It never leaves the device.
298298-- **Familiar UX** — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup.
299299-- **Correct signature format** — the passkey signs repo commits with P-256 ECDSA in the raw (r‖s) format ATProto expects. Service-auth JWTs are signed by the PDS server key (`#atproto_service`) so they are standard ES256 and require no passkey. See the limitation notice at the top of this document for the remaining interoperability gap.
164164+| Property | PDS Server Key | Passkey-Derived Key |
165165+| ---------------------- | ------------------ | --------------------------- |
166166+| **DID slot** | `#atproto_service` | `#atproto` |
167167+| **Purpose** | Service-auth JWTs | Repo commits |
168168+| **Passkey required** | No | Yes (for repo writes) |
169169+| **Private key stored** | Yes (in `jwk.key`) | **No** (derived on-the-fly) |
300170301171## Management Commands
302172···376246- [x] `com.atproto.sync.getRepoStatus`
377247- [x] `com.atproto.sync.getRepo`
378248- [x] `com.atproto.sync.listBlobs`
379379-- [x] `com.atproto.sync.listRepos`
380249- [x] `com.atproto.sync.requestCrawl`
381250- [x] `com.atproto.sync.subscribeRepos`
382251···389258390259## License
391260392392-[MIT](license). `server/static/pico.css` is also MIT licensed, available at [https://github.com/picocss/pico/](https://github.com/picocss/pico/).
261261+[MIT](license). `server/static/pico.css` is also MIT licensed, available at [https://github.com/picocss/pico](https://github.com/picocss/pico).
393262394263## Thanks
395264···404273| SQLite blockstore | ❌ removed | ✅ |
405274| PostgreSQL support | ❌ removed | ✅ |
406275| S3 blob storage | ❌ removed | ✅ |
407407-| S3 database backups | ❌ removed | ✅ |
276276+| IPFS repo block storage | ✅ (Kubo) | ❌ |
408277| IPFS blob storage | ✅ (Kubo) | ❌ |
409409-| IPFS repo block storage | ✅ (Kubo) | ❌ |
410278| Email 2FA | ❌ removed | ✅ |
411279| BYOK (keyless PDS) | ✅ | ❌ |
412280| Passkey signer | ✅ | ❌ |
413413-| User-sovereign DID | ✅ | ❌ |
281281+282282+## Technical Details
283283+284284+For in-depth specifications, flows, trade-offs, and maintenance considerations, see [specs.md](specs.md).
+569
specs.md
···11+# Vow Technical Specifications
22+33+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.
44+55+## Overview
66+77+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.
88+99+### Key Design Principles
1010+1111+1. **Server never holds signing key** — The passkey provides PRF output; server derives signing key deterministically
1212+2. **Deterministic derivation** — Same PRF output always produces same signing key
1313+3. **Two-key model** — Separate keys for repo commits (passkey-derived) vs service-auth (PDS server)
1414+4. **User-controlled DID** — After passkey registration, only user can authorize identity operations via passkey
1515+1616+## Architecture
1717+1818+### Database Schema
1919+2020+```sql
2121+CREATE TABLE repos (
2222+ did TEXT PRIMARY KEY,
2323+ created_at DATETIME,
2424+ email TEXT UNIQUE,
2525+ auth_public_key BLOB, -- Passkey P-256 public key (for WebAuthn assertion verification)
2626+ signing_public_key BLOB, -- PRF-derived P-256 signing key (for commit signature verification)
2727+ credential_id BLOB, -- WebAuthn credential ID (for building allowCredentials list)
2828+ rev TEXT,
2929+ root BLOB,
3030+ preferences BLOB,
3131+ deactivated BOOLEAN
3232+);
3333+```
3434+3535+### Key Types
3636+3737+| Key | Type | Curve | Purpose | Stored |
3838+| ----------------------- | --------- | --------------------------------- | -------------------------- | ------ |
3939+| `auth_public_key` | P-256 | WebAuthn assertion verification | ✅ Yes |
4040+| `signing_public_key` | P-256 | Commit signature verification | ✅ Yes |
4141+| PRF-derived private key | P-256 | Commit signing | ❌ No (derived on-the-fly) |
4242+| PDS rotation key | secp256k1 | PLC genesis, initial key transfer | ✅ Yes |
4343+| PDS service key | P-256 | Session/OAuth JWT signing | ✅ Yes |
4444+4545+### DID Document Structure
4646+4747+```json
4848+{
4949+ "verificationMethods": {
5050+ "atproto": "did:key:z6Mkq... (PRF-derived signing key)",
5151+ "atproto_service": "did:key:z5Mka... (PDS server key)"
5252+ },
5353+ "rotationKeys": ["did:key:z6Mkq... (PRF-derived signing key)"]
5454+}
5555+```
5656+5757+After key registration, PDS rotation key is **removed** from DID document. Only user's passkey-derived key is the rotation key.
5858+5959+## Protocol Flows
6060+6161+### Passkey Registration Flow
6262+6363+**Objective:** Register a new passkey and transfer DID control from PDS to user.
6464+6565+```
6666+┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
6767+│ Browser │ │ PDS Server │ │ PLC Directory │
6868+└─────┬───────┘ └──────┬───────────┘ └────────┬─────────┘
6969+ │ │ │
7070+ │ 1. Request │ │
7171+ │ challenge │ │
7272+ ├──────────────────────>│ │
7373+ │ │ 2. Get DID audit │
7474+ │ ├───────────────────────>│
7575+ │ │ │ 3. Submit
7676+ │ │<────────────────────────┘ operation
7777+ │ 3. Create │ │ (signing key becomes
7878+ │ passkey │ │ rotation key)
7979+ │<───────────────────────┤
8080+ │ │ │
8181+ │ 4. Extract & │ │
8282+ │ derive signing │ │
8383+ │ key │ │
8484+ │ │ │
8585+ │ 5. Send signing │ │
8686+ │ public key │ │
8787+ ├──────────────────────>│ │
8888+ │ │ 4. Update DB │
8989+ │ ├──────────────────────────>│
9090+ │ │ │
9191+ │ │<──────────────────────────┘
9292+ │<───────────────────────┘
9393+```
9494+9595+**Steps:**
9696+9797+1. **Client requests challenge** — Server returns WebAuthn creation options with random challenge and PRF extension
9898+2. **Client creates passkey** — Passkey generated with PRF extension using fixed seed
9999+3. **Client extracts PRF output** — Retrieved from passkey response extensions
100100+4. **Client derives signing key** — Deterministic HKDF-SHA256 derivation from PRF output
101101+5. **Client sends public key** — Sends attestation and derived signing public key
102102+6. **Server validates and stores** — Validates passkey attestation, stores keys, updates DID via PLC
103103+7. **Key transfer** — Server signs PLC operation to make user's key the rotation key
104104+105105+### Commit Signing Flow
106106+107107+**Objective:** Sign a repo commit using passkey-derived signing key.
108108+109109+```
110110+┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
111111+│ Browser │ │ PDS Server │ │ Account Page │
112112+│ (ATProto │ │ │ │ (Signer) │
113113+│ Client) │ │ │ └────────┬─────────┘
114114+└─────┬───────┘ └──────┬───────────┘ │
115115+ │ │ │
116116+ │ 1. applyWrites │ │
117117+ ├──────────────────────>│ │
118118+ │ │ 2. WebSocket sign request │
119119+ │<───────────────────────┼───────────────────────>│
120120+ │ │ │ 3. Request passkey
121121+ │ │ │ assertion (no PRF)
122122+ │ │ │
123123+ │ │ 4. Retrieve PRF │
124124+ │ │ output from storage │
125125+ │ │ │
126126+ │ │<──────────────────────────┘
127127+ │ │
128128+ │ │ 5. Derive signing key
129129+ │ │ from stored PRF
130130+ │ │
131131+ │ │ 6. Sign commit
132132+ │ │
133133+ │<───────────────────────┤
134134+ │ 7. sign_response │
135135+ ├──────────────────────>│ 8. Verify assertion
136136+ │ │ against authPublicKey
137137+ │ │
138138+ │ │ 9. Verify commit signature
139139+ │ │ against signingPublicKey
140140+ │ │
141141+ │ │ 10. Persist commit
142142+ │<───────────────────────┘
143143+```
144144+145145+**Steps:**
146146+147147+1. **Client initiates write** — Standard ATProto client sends write request
148148+2. **Server sends sign request** — Holds HTTP request, sends WebSocket sign request
149149+3. **Client retrieves PRF output** — Retrieves from browser storage
150150+4. **Client derives signing key** — Same deterministic derivation as registration
151151+5. **Client signs commit** — Signs with derived key
152152+6. **Client gets passkey assertion** — Passkey assertion for authentication (no PRF needed)
153153+7. **Client sends sign response** — Sends passkey assertion and commit signature
154154+8. **Server verifies assertion** — Verifies passkey authentication
155155+9. **Server verifies commit signature** — Verifies commit against stored signing public key
156156+10. **Server persists commit** — Finalizes and stores commit
157157+158158+### Service-Auth Flow
159159+160160+**Objective:** Generate JWT for background requests (feeds, notifications) without passkey.
161161+162162+```
163163+┌─────────────┐ ┌──────────────────┐
164164+│ Any Client│ │ PDS Server │
165165+└─────┬───────┘ └──────┬───────────┘
166166+ │ │
167167+ │ getServiceAuth │
168168+ ├──────────────────────>│ 1. Validate session
169169+ │ │ 2. Look up did:key
170170+ │ │ 3. Sign JWT with PDS key
171171+ │ │ 4. Return JWT
172172+ │<───────────────────────┘
173173+```
174174+175175+**Steps:**
176176+177177+1. **Client requests service auth** — Standard ATProto call
178178+2. **Server validates session** — From JWT or session cookie
179179+3. **Server loads PDS key** — Server's private service key
180180+4. **Server signs JWT** — Signed with PDS key
181181+5. **Returns signed JWT** — Standard ES256 signature, no passkey required
182182+183183+**Note:** Service-auth JWTs are verified by checking `verificationMethods.atproto_service` (PDS key), not `verificationMethods.atproto` (passkey-derived key).
184184+185185+### Identity Operation Flow
186186+187187+**Objective:** User updates handle, email, or other PLC-managed identity data.
188188+189189+#### Before Passkey Registration (PDS Controlled)
190190+191191+```
192192+┌─────────────┐ ┌──────────────────┐
193193+│ Browser │ │ PDS Server │
194194+│ (Request) │ │ │
195195+└─────┬───────┘ └──────┬───────────┘
196196+ │ │
197197+ │ requestPlcOperation │
198198+ ├──────────────────────>│ 1. Sign with rotation.key
199199+ │ │ 2. Submit to PLC
200200+ │ │ 3. Return signed op
201201+ │<───────────────────────┘
202202+```
203203+204204+PDS has full authority — can modify DID document freely.
205205+206206+#### After Passkey Registration (User Controlled)
207207+208208+```
209209+┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
210210+│ Browser │ │ PDS Server │ │ Account Page │
211211+│ (Request) │ │ │ │ (Signer) │
212212+└─────┬───────┘ └──────┬───────────┘ └──────┬──────────┘
213213+ │ │ │
214214+ │ requestPlcOperation │ │
215215+ ├──────────────────────>│ │
216216+ │ │ 1. Request signature │ 2. Get passkey
217217+ │ │ from SignerHub │ assertion
218218+ │ │<──────────────────────┼───────────────────>│
219219+ │ │ │ 3. Re-derive signing
220220+ │ │ │ key from PRF
221221+ │ │ │
222222+ │ │ 4. Sign PLC operation│
223223+ │ │<──────────────────────┘
224224+ │ │ 5. Submit to PLC
225225+ │ │<──────────────────────────┘
226226+ │<───────────────────────┘
227227+```
228228+229229+**Steps:**
230230+231231+1. **Client requests operation** — Standard ATProto call
232232+2. **Server builds PLC operation** — Constructs operation CBOR
233233+3. **Server requests signature** — Via SignerHub WebSocket
234234+4. **SignerHub prompts user** — Passkey assertion required
235235+5. **Client derives signing key** — From stored PRF output
236236+6. **Client signs PLC operation** — With derived signing key
237237+7. **Server submits to PLC** — With user's signature
238238+8. **Only user's key** — Is now the rotation key
239239+240240+## Security Properties
241241+242242+### Key Isolation
243243+244244+| Concern | Passkey | PRF-Derived Signing Key | PDS Server Key |
245245+| -------------------------------- | ---------------- | --------------------------- | ------------------------ |
246246+| **Private key stored on server** | ❌ No | ❌ No | ✅ Yes |
247247+| **Can sign repo commits** | ❌ No | ✅ Yes | ❌ No |
248248+| **Can sign PLC operations** | ❌ No (directly) | ✅ Yes (via PRF derivation) | ✅ Yes (before transfer) |
249249+| **Can sign service-auth JWTs** | ❌ No | ❌ No | ✅ Yes |
250250+| **Requires user interaction** | ✅ Yes | ✅ Yes (via passkey) | ❌ No |
251251+252252+### Threat Model Mitigation
253253+254254+| Threat | Standard PDS | Vow |
255255+| ------------------------------ | ---------------------------------- | ----------------------------------------------- |
256256+| **Server repo key compromise** | Server can forge any commit | ❌ Server never sees signing private key |
257257+| **Server admin abuse** | Server can rotate keys, change DID | ❌ After key transfer, only user can modify DID |
258258+| **Database exfiltration** | Exposes all private keys | ❌ Only public keys stored |
259259+260260+### Deterministic Key Derivation
261261+262262+Same PRF output produces the same signing key deterministically:
263263+264264+- **Derivation method:** HKDF-SHA256 with fixed parameters
265265+- **PRF seed:** Fixed value for reproducibility
266266+- **Result:** Derivation is reproducible across sessions, browsers, devices
267267+268268+**Why this matters:**
269269+270270+1. **Consistent signatures** — Same commit always produces same signature
271271+2. **Key recovery** — Lost browser storage can be recovered by re-registering passkey with same seed
272272+3. **Auditability** — PRF output never needs to leave device, but same key material always derivable
273273+274274+## Trade-offs
275275+276276+### Advantages
277277+278278+| Advantage | Description |
279279+| ------------------------ | ------------------------------------------------------------------------ |
280280+| **User-controlled DID** | After passkey registration, PDS cannot modify user's identity |
281281+| **Key isolation** | Compromised database never exposes signing private keys |
282282+| **Hardware security** | Passkey private key lives in TPM/Secure Enclave, never in browser memory |
283283+| **No extensions needed** | Works in any modern browser (WebAuthn Level 3 with PRF support) |
284284+| **Portable identity** | User can migrate to new PDS by updating serviceEndpoint via PLC |
285285+| **Standard federation** | Uses `did:plc` — full compatibility with ATProto ecosystem |
286286+287287+### Disadvantages
288288+289289+| Disadvantage | Mitigation |
290290+| ------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------- |
291291+| **Requires browser tab** | User must keep account page open for signing | Pin tab, use separate device |
292292+| **PRF browser support** | Not all browsers passkeys support PRF extension | Chrome 126+, Edge 126+, Safari 18+, Firefox 130+ |
293293+| **Storage dependency** | Lost PRF output on cache clear requires re-registration | Re-register passkey (seed produces same key) |
294294+| **Service-auth gap** | Standard ATProto clients don't check `#atproto_service` | [RFC pending](https://github.com/bluesky-social/atproto/discussions/4739) |
295295+| **Passkey loss** | Lost passkey = lost identity (no recovery mechanism) | This is inherent to passkeys, not Vow-specific |
296296+297297+### Compatibility Gaps
298298+299299+#### Service-Auth Federation Gap
300300+301301+**Problem:** Standard ATProto reference implementation and AppViews verify service-auth JWTs by checking `verificationMethods.atproto` only, ignoring `verificationMethods.atproto_service`.
302302+303303+**Current Status:**
304304+305305+- ✅ Vow correctly sets `#atproto_service` in DID document
306306+- ✅ Vow correctly signs service-auth JWTs with that key
307307+- ❌ Standard clients reject these tokens with `BadJwtSignature`
308308+- ✅ Vow's own account page works (reads `#atproto_service`)
309309+310310+**Impact:**
311311+312312+- ❌ Background feed loading via standard clients fails
313313+- ❌ Notification streaming via standard clients fails
314314+- ❌ Proxied blob reads via standard clients fails
315315+- ✅ Direct API access works if clients check `#atproto_service`
316316+- ✅ Vow's built-in UI works fully
317317+318318+**Tracking:** RFC Draft for atproto_service verification
319319+320320+#### Mitigation for Users
321321+322322+Until the RFC lands, users should:
323323+324324+1. Use Vow's account page for full experience
325325+2. Build/custom clients that check `#atproto_service` as fallback
326326+3. Use direct API calls for automation
327327+328328+## Maintenance
329329+330330+### Key Management
331331+332332+#### Server Keys (Persistent)
333333+334334+| Key | Location | Rotation Policy | Backup Required |
335335+| -------------- | --------------------- | ---------------------------- | --------------- |
336336+| `rotation.key` | `./keys/rotation.key` | Never (used for DID genesis) | ✅ Yes |
337337+| `jwk.key` | `./keys/jwk.key` | Never | ✅ Yes |
338338+339339+**Best Practices:**
340340+341341+- Store `./keys/` backups in secure, off-site location
342342+- `rotation.key` compromise = complete DID takeover (all accounts before key transfer)
343343+- `jwk.key` compromise = session hijacking (cannot forge commits, but can issue JWTs)
344344+- Use strong permissions on `./keys/` directory
345345+346346+#### User Keys (Transient)
347347+348348+| Key | Storage | Derivation | Lifetime |
349349+| ----------------------- | ----------------------------- | ------------------------------ | -------- |
350350+| Passkey private key | Hardware (TPM/Secure Enclave) | Permanent (until deregistered) |
351351+| PRF output | Browser storage | Until browser data cleared |
352352+| PRF-derived signing key | Derived on-the-fly | Ephemeral (per-session) |
353353+354354+**Recovery:**
355355+356356+- **Lost passkey:** No recovery — identity lost (passkey limitation)
357357+- **Lost PRF output:** Re-register same passkey → same signing key (deterministic)
358358+359359+### Database Maintenance
360360+361361+#### Schema
362362+363363+The `repos` table structure is stable:
364364+365365+- `auth_public_key` — Passkey P-256 public key
366366+- `signing_public_key` — PRF-derived P-256 signing key
367367+- `credential_id` — WebAuthn credential ID
368368+369369+**Upgrade path:**
370370+371371+- Stop services
372372+- Delete old database
373373+- Restart services
374374+- ⚠️ This deletes all user data — backup first!
375375+376376+#### Index Maintenance
377377+378378+Automatic indexing:
379379+380380+- Records indexed by `did`, `nsid`, `rkey`
381381+- Blobs indexed by `did`, `cid` (reference counting)
382382+- Session indexed by `token`, `did`
383383+384384+No manual index maintenance required.
385385+386386+### PLC Operations
387387+388388+#### Key Transfer Operation
389389+390390+When user registers an existing passkey:
391391+392392+1. PDS builds PLC operation with user's signing key
393393+2. Operation is signed by:
394394+ - **First registration:** PDS rotation key (still has authority)
395395+ - **Key rotation:** User's passkey-derived signing key (new rotation key)
396396+3. Submit to PLC directory
397397+398398+**Audit trail:**
399399+400400+- PLC audit log shows clear transfer
401401+- `rotationKeys` changes from PDS key to user key
402402+- Timestamp proves when user took control
403403+404404+#### Future Identity Updates
405405+406406+After key transfer, user can:
407407+408408+1. **Update handle** — Requires passkey signature
409409+2. **Update email** — Requires email confirmation link (no passkey)
410410+3. **Deactivate account** — Requires passkey signature
411411+412412+All identity changes require passkey authentication after transfer.
413413+414414+### Disaster Recovery
415415+416416+#### Scenario: Complete Server Loss
417417+418418+1. **PDS rotation key compromised:**
419419+ - Attackers can create new accounts, transfer control before user registers passkey
420420+ - Detection: Multiple new registrations with different emails
421421+ - Recovery: Generate new `rotation.key` (invalidates old key)
422422+423423+2. **Database exfiltration:**
424424+ - Attackers get public keys only
425425+ - **Cannot sign commits** (no private signing key)
426426+ - Impact: Read-only access to past commits
427427+ - Recovery: Revoke passkeys, rotate PDS keys
428428+429429+3. **IPFS data loss:**
430430+ - Users lose repos and blobs
431431+ - Recovery: Users re-sync from relays
432432+433433+#### Scenario: User Loss Recovery
434434+435435+1. **Lost passkey (after DID transfer):**
436436+ - ❌ No recovery possible
437437+ - Mitigation: Register multiple passkeys on different devices before loss
438438+439439+2. **Accidentally deleted browser storage (PRF output):**
440440+ - ✅ Re-register same passkey
441441+ - ✅ Deterministic derivation produces same signing key
442442+ - ✅ Identity remains intact
443443+444444+## WebSocket Protocol Specification
445445+446446+### Connection
447447+448448+**Endpoint:** `wss://<hostname>/account/signer`
449449+450450+**Authentication:** Session cookie or Bearer token
451451+452452+### Message Types
453453+454454+#### sign_request (Server → Client)
455455+456456+```json
457457+{
458458+ "type": "sign_request",
459459+ "requestId": "uuid-v4",
460460+ "did": "did:plc:abc123...",
461461+ "payload": "base64url-encoded unsigned commit CBOR",
462462+ "ops": [
463463+ {
464464+ "type": "create|update|delete",
465465+ "collection": "app.bsky.feed.post|app.bsky.graph.follows|...",
466466+ "rkey": "record key (for updates)"
467467+ }
468468+ ],
469469+ "expiresAt": "ISO-8601 timestamp"
470470+}
471471+```
472472+473473+**Fields:**
474474+475475+- `requestId`: Unique identifier for this request
476476+- `did`: User's DID
477477+- `payload`: CBOR bytes of unsigned commit
478478+- `ops`: Array of operations (for UI display)
479479+- `expiresAt`: When request becomes invalid
480480+481481+#### sign_response (Client → Server)
482482+483483+```json
484484+{
485485+ "type": "sign_response",
486486+ "requestId": "uuid-v4",
487487+ "authenticatorData": "base64url authenticatorData bytes",
488488+ "clientDataJSON": "base64url clientDataJSON bytes",
489489+ "signature": "base64url DER-encoded ECDSA signature",
490490+ "commitSignature": "base64url 64-byte raw r||s signature"
491491+}
492492+```
493493+494494+**Fields:**
495495+496496+- `requestId`: Must match original request
497497+- `authenticatorData`: WebAuthn authenticator data
498498+- `clientDataJSON`: WebAuthn client data JSON
499499+- `signature`: DER-encoded ECDSA signature from passkey
500500+- `commitSignature`: Raw signature from PRF-derived signing key
501501+502502+#### sign_reject (Client → Server)
503503+504504+```json
505505+{
506506+ "type": "sign_reject",
507507+ "requestId": "uuid-v4"
508508+}
509509+```
510510+511511+**Used when:**
512512+513513+- User cancelled passkey prompt
514514+- Passkey not found
515515+- Request expired
516516+- Technical error
517517+518518+### Connection Lifecycle
519519+520520+1. **Handshake**
521521+ - HTTP GET → WebSocket upgrade
522522+ - Server verifies session/bearer token
523523+ - Connection accepted or rejected
524524+525525+2. **Keepalive**
526526+ - Server sends `ping` messages
527527+ - Client responds with `pong`
528528+ - Missing `pong` → connection terminated
529529+530530+3. **Reconnection**
531531+ - Exponential backoff
532532+ - Automatic on page visible, tab focus
533533+ - Manual: User clicks "Connect" button
534534+535535+4. **Single connection per DID**
536536+ - New connection immediately closes old connection
537537+ - "Replacing existing connection" logged
538538+539539+## Error Handling
540540+541541+### Client-Side Errors
542542+543543+| Error | Cause | User Action |
544544+| ------------------------------------ | ----------------------------------- | ---------------------------------------------------- |
545545+| `PRF extension output not available` | Browser/passkey doesn't support PRF | Use Chrome 126+, Edge 126+, Safari 18+, Firefox 130+ |
546546+| `PRF output not found` | Browser storage cleared | Re-register passkey (same key) |
547547+| `Passkey creation was cancelled` | User dismissed prompt | Try again |
548548+| `Passkey assertion was cancelled` | User dismissed prompt | Try again |
549549+550550+### Server-Side Errors
551551+552552+| Error | HTTP Status | Cause |
553553+| ---------------------------------------- | ----------- | --------------------------------------- |
554554+| `no public key registered for account` | 400 | No signing key in DB (register passkey) |
555555+| `assertion verification failed` | 401 | Invalid passkey signature |
556556+| `signature verification failed` | 400 | Derived key mismatch (BUG) |
557557+| `challenge mismatch` | 400 | Tampered request |
558558+| `rpIdHash mismatch` | 400 | Wrong origin/domain |
559559+| `no extension data in authenticatorData` | 400 | PRF not supported |
560560+| `failed to parse attestation object` | 400 | Invalid WebAuthn response |
561561+562562+## Appendix
563563+564564+### References
565565+566566+- [WebAuthn Level 3 Specification](https://www.w3.org/TR/webauthn-3/)
567567+- [WebAuthn PRF Extension](https://w3cg.github.io/webauthn/sctn-prf-extension/)
568568+- [ATProto DID Spec](https://github.com/bluesky-social/atproto/blob/main/identifiers/did-plc.md)
569569+- [Service-Auth RFC Discussion](https://github.com/bluesky-social/atproto/discussions/4739)