BYOK Personal Data Server (PDS) written in Go
ipfs vow atproto pds go
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: improve readme and add specs

+638 -198
+69 -198
readme.md
··· 4 4 > This is highly experimental software. Use with caution, especially during account migration. 5 5 6 6 > [!IMPORTANT] 7 - > **Vow implements a two-key model for signing that requires a pending ATProto protocol change to be fully interoperable.** 8 - > 9 - > 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: 10 - > 11 - > - **`verificationMethods.atproto`** → the user's passkey. Signs every repo commit. Requires passkey usage, which is acceptable because commits are user-initiated. 12 - > - **`verificationMethods.atproto_service`** → the PDS server key. Signs service-auth JWTs for background requests (feed loading, notifications, proxied reads) without ever prompting the passkey. 13 - > 14 - > [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`. 15 - > 16 - > **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. 7 + > **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. 17 8 18 - Vow is a Go PDS (Personal Data Server) for the AT Protocol. 19 - 20 - ## Features 21 - 22 - - ✅ **IPFS storage** — repo blocks and blobs are stored on a local Kubo node and indexed in SQLite by DID and CID. 23 - - ✅ **Keyless PDS** — the server never stores a private key. Every write is signed by the user's passkey (WebAuthn/FIDO2) registered during onboarding. 24 - - ✅ **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. 25 - - ✅ **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. 9 + Vow is a Go PDS (Personal Data Server) for AT Protocol. 26 10 27 11 ## Quick Start with Docker Compose 28 12 ··· 33 17 34 18 ### Installation 35 19 36 - 1. **Clone the repository** 20 + 1. **Clone repository** 37 21 38 - ```bash 39 - git clone https://pkg.rbrt.fr/vow.git 40 - cd vow 41 - ``` 22 + ```bash 23 + git clone https://pkg.rbrt.fr/vow.git 24 + cd vow 25 + ``` 42 26 43 27 2. **Create your configuration file** 44 28 45 - ```bash 46 - cp .env.example .env 47 - ``` 29 + ```bash 30 + cp .env.example .env 31 + ``` 48 32 49 33 3. **Edit `.env` with your settings** 50 34 51 - ```bash 52 - VOW_DID="did:web:your-domain.com" 53 - VOW_HOSTNAME="your-domain.com" 54 - VOW_CONTACT_EMAIL="you@example.com" 55 - VOW_RELAYS="https://bsky.network" 35 + ```bash 36 + VOW_DID="did:web:your-domain.com" 37 + VOW_HOSTNAME="your-domain.com" 38 + VOW_CONTACT_EMAIL="you@example.com" 39 + VOW_RELAYS="https://bsky.network" 56 40 57 - # Generate with: openssl rand -hex 16 58 - VOW_ADMIN_PASSWORD="your-secure-password" 41 + # Generate with: openssl rand -hex 16 42 + VOW_ADMIN_PASSWORD="your-secure-password" 59 43 60 - # Generate with: openssl rand -hex 32 61 - VOW_SESSION_SECRET="your-session-secret" 62 - ``` 44 + # Generate with: openssl rand -hex 32 45 + VOW_SESSION_SECRET="your-session-secret" 46 + ``` 63 47 64 - 4. **Start the services** 48 + 4. **Start services** 65 49 66 - ```bash 67 - docker compose pull 68 - docker compose up -d 69 - ``` 50 + ```bash 51 + docker compose pull 52 + docker compose up -d 53 + ``` 70 54 71 - This starts three services: 72 - - **ipfs** — a Kubo node for repo blocks and blobs 73 - - **vow** — the PDS 74 - - **create-invite** — creates an initial invite code on first run 55 + This starts three services: 56 + 57 + - **ipfs** — a Kubo node for repo blocks and blobs 58 + - **vow** — the PDS 59 + - **create-invite** — creates an initial invite code on first run 75 60 76 61 5. **Get your invite code** 77 62 78 - On first run, an invite code is automatically created. View it with: 63 + On first run, an invite code is automatically created: 79 64 80 - ```bash 81 - docker compose logs create-invite 82 - ``` 65 + ```bash 66 + docker compose logs create-invite 67 + ``` 83 68 84 - Or check the saved file: 69 + Or check saved file: 70 + 71 + ```bash 72 + cat keys/initial-invite-code.txt 73 + ``` 85 74 86 - ```bash 87 - cat keys/initial-invite-code.txt 88 - ``` 75 + 6. **Monitor services** 89 76 90 - 6. **Monitor the services** 91 - ```bash 92 - docker compose logs -f 93 - ``` 77 + ```bash 78 + docker compose logs -f 79 + ``` 94 80 95 81 ### What Gets Set Up 96 82 97 - - **init-keys**: Generates the rotation key and JWK on first run 98 - - **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. 83 + - **init-keys**: Generates rotation key and JWK on first run 84 + - **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. 99 85 - **vow**: The main PDS service on port 8080 100 86 - **create-invite**: Creates an initial invite code on first run 101 87 ··· 123 109 124 110 ### Database 125 111 126 - Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens. No extra setup is required. 112 + Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens. 127 113 128 114 ```bash 129 115 VOW_DB_NAME="/data/vow/vow.db" ··· 131 117 132 118 ### IPFS Node 133 119 134 - 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: 135 - 136 120 ```bash 137 - # URL of the Kubo RPC API 121 + # URL of Kubo RPC API 138 122 VOW_IPFS_NODE_URL="http://127.0.0.1:5001" 139 123 140 - # Optional: redirect sync.getBlob to a public gateway instead of proxying 141 - # through vow. Set to your own gateway or a public one like https://ipfs.io 124 + # Optional: redirect sync.getBlob to a public gateway 142 125 VOW_IPFS_GATEWAY_URL="https://ipfs.example.com" 143 126 ``` 144 - 145 - `VOW_IPFS_NODE_URL` is the only required IPFS setting. 146 127 147 128 ### SMTP Email 148 129 ··· 159 140 160 141 The PDS holds two keys: 161 142 162 - - **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. 143 + - **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. 163 144 - **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. 164 145 165 146 Neither key is ever used to sign repo commits or service-auth JWTs. 166 147 167 - 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. 168 - 169 - #### End-to-end signing flow 170 - 171 - 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. 172 - 173 - ``` 174 - ATProto client PDS (vow) Account page (browser) Passkey (WebAuthn) 175 - | | | | 176 - |-- createRecord ----->| | | 177 - | |-- 1. build unsigned commit | | 178 - | |-- 2. push signing request | | 179 - | | over WebSocket ---------->| | 180 - | (HTTP held open, | |-- navigator | 181 - | up to 30 s) | | .credentials.get()->| 182 - | | |<-- assertion ---------| 183 - | |<-- 3. signature over WS -------| | 184 - | |-- 4. verify signature | | 185 - | |-- 5. finalise & persist commit | | 186 - |<-- 200 result -------| | | 187 - ``` 188 - 189 - **What requires a passkey signature:** 190 - 191 - 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: 192 - 193 - - **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites` 194 - - **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. 195 - 196 - 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. 197 - 198 - #### WebSocket connection 199 - 200 - The account page connects with the session cookie set at sign-in: 201 - 202 - ``` 203 - GET /account/signer 204 - Cookie: <session-cookie> 205 - ``` 206 - 207 - 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. 208 - 209 - A legacy Bearer-token endpoint is also available for programmatic clients: 210 - 211 - ``` 212 - GET /xrpc/com.atproto.server.signerConnect 213 - Authorization: Bearer <access-token> 214 - ``` 215 - 216 - **Signing request message (PDS → browser):** 217 - 218 - ```json 219 - { 220 - "type": "sign_request", 221 - "requestId": "uuid", 222 - "did": "did:plc:...", 223 - "payload": "<base64url-encoded unsigned commit bytes>", 224 - "ops": [ 225 - { "type": "create", "collection": "app.bsky.feed.post", "rkey": "3abc..." } 226 - ], 227 - "expiresAt": "2025-01-01T00:00:30Z" 228 - } 229 - ``` 230 - 231 - **Signature response message (browser → PDS):** 232 - 233 - ```json 234 - { 235 - "type": "sign_response", 236 - "requestId": "uuid", 237 - "authenticatorData": "<base64url authenticatorData bytes>", 238 - "clientDataJSON": "<base64url clientDataJSON bytes>", 239 - "signature": "<base64url DER-encoded ECDSA signature>" 240 - } 241 - ``` 242 - 243 - 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. 244 - 245 - **Rejection message (browser → PDS):** 246 - 247 - ```json 248 - { 249 - "type": "sign_reject", 250 - "requestId": "uuid" 251 - } 252 - ``` 148 + ## How It Works 253 149 254 150 ### Browser-Based Signer 255 151 256 - 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. 257 - 258 - ## Identity & DID Sovereignty 259 - 260 - 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**. 261 - 262 - ### The problem with standard ATProto 263 - 264 - 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. 265 - 266 - ### Vow's approach: trust-then-transfer 267 - 268 - Vow uses a two-phase model: 269 - 270 - **Phase 1 — Account creation.** `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key. 271 - 272 - **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. 273 - 274 - ### What the user gets 275 - 276 - | Property | Before key registration | After key registration | 277 - | ------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | 278 - | Who signs commits | Nobody (no key registered) | User's passkey (`#atproto`) | 279 - | Who signs service-auth JWTs | PDS server key | PDS server key (`#atproto_service`) | 280 - | Who controls the DID | PDS rotation key | User's passkey-derived key | 281 - | PDS can hijack identity | Yes | **No** | 282 - | User can migrate to new PDS | Yes (If rotation key was set) & No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) | 283 - | Commit federation compatibility | Full | Full (unchanged) | 284 - | Service-auth federation compat. | Full | Partial — requires [`#atproto_service` RFC](https://github.com/bluesky-social/atproto/discussions/4739) to land in AppViews | 285 - 286 - ### Verifiability 152 + 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. 287 153 288 - 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`. 154 + ### Key Flow 289 155 290 - ### Why passkeys instead of Ethereum wallets? 156 + 1. **Before passkey registration** — PDS controls DID with its rotation key 157 + 2. **After passkey registration** — User's passkey becomes the rotation key, derived via WebAuthn PRF extension. Only the user can modify their DID. 158 + 3. **Signing commits** — Passkey authenticates user and provides PRF output. A deterministic signing key is derived from PRF output and used to sign commits. 291 159 292 - 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. 160 + ### Two-Key Model 293 161 294 - Passkeys (WebAuthn/FIDO2) solve this: 162 + Vow implements a two-key model: 295 163 296 - - **No browser extension required** — passkeys are built into every modern browser and OS. 297 - - **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. 298 - - **Familiar UX** — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup. 299 - - **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. 164 + | Property | PDS Server Key | Passkey-Derived Key | 165 + | ---------------------- | ------------------ | --------------------------- | 166 + | **DID slot** | `#atproto_service` | `#atproto` | 167 + | **Purpose** | Service-auth JWTs | Repo commits | 168 + | **Passkey required** | No | Yes (for repo writes) | 169 + | **Private key stored** | Yes (in `jwk.key`) | **No** (derived on-the-fly) | 300 170 301 171 ## Management Commands 302 172 ··· 376 246 - [x] `com.atproto.sync.getRepoStatus` 377 247 - [x] `com.atproto.sync.getRepo` 378 248 - [x] `com.atproto.sync.listBlobs` 379 - - [x] `com.atproto.sync.listRepos` 380 249 - [x] `com.atproto.sync.requestCrawl` 381 250 - [x] `com.atproto.sync.subscribeRepos` 382 251 ··· 389 258 390 259 ## License 391 260 392 - [MIT](license). `server/static/pico.css` is also MIT licensed, available at [https://github.com/picocss/pico/](https://github.com/picocss/pico/). 261 + [MIT](license). `server/static/pico.css` is also MIT licensed, available at [https://github.com/picocss/pico](https://github.com/picocss/pico). 393 262 394 263 ## Thanks 395 264 ··· 404 273 | SQLite blockstore | ❌ removed | ✅ | 405 274 | PostgreSQL support | ❌ removed | ✅ | 406 275 | S3 blob storage | ❌ removed | ✅ | 407 - | S3 database backups | ❌ removed | ✅ | 276 + | IPFS repo block storage | ✅ (Kubo) | ❌ | 408 277 | IPFS blob storage | ✅ (Kubo) | ❌ | 409 - | IPFS repo block storage | ✅ (Kubo) | ❌ | 410 278 | Email 2FA | ❌ removed | ✅ | 411 279 | BYOK (keyless PDS) | ✅ | ❌ | 412 280 | Passkey signer | ✅ | ❌ | 413 - | User-sovereign DID | ✅ | ❌ | 281 + 282 + ## Technical Details 283 + 284 + For in-depth specifications, flows, trade-offs, and maintenance considerations, see [specs.md](specs.md).
+569
specs.md
··· 1 + # Vow Technical Specifications 2 + 3 + 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. 4 + 5 + ## Overview 6 + 7 + 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. 8 + 9 + ### Key Design Principles 10 + 11 + 1. **Server never holds signing key** — The passkey provides PRF output; server derives signing key deterministically 12 + 2. **Deterministic derivation** — Same PRF output always produces same signing key 13 + 3. **Two-key model** — Separate keys for repo commits (passkey-derived) vs service-auth (PDS server) 14 + 4. **User-controlled DID** — After passkey registration, only user can authorize identity operations via passkey 15 + 16 + ## Architecture 17 + 18 + ### Database Schema 19 + 20 + ```sql 21 + CREATE TABLE repos ( 22 + did TEXT PRIMARY KEY, 23 + created_at DATETIME, 24 + email TEXT UNIQUE, 25 + auth_public_key BLOB, -- Passkey P-256 public key (for WebAuthn assertion verification) 26 + signing_public_key BLOB, -- PRF-derived P-256 signing key (for commit signature verification) 27 + credential_id BLOB, -- WebAuthn credential ID (for building allowCredentials list) 28 + rev TEXT, 29 + root BLOB, 30 + preferences BLOB, 31 + deactivated BOOLEAN 32 + ); 33 + ``` 34 + 35 + ### Key Types 36 + 37 + | Key | Type | Curve | Purpose | Stored | 38 + | ----------------------- | --------- | --------------------------------- | -------------------------- | ------ | 39 + | `auth_public_key` | P-256 | WebAuthn assertion verification | ✅ Yes | 40 + | `signing_public_key` | P-256 | Commit signature verification | ✅ Yes | 41 + | PRF-derived private key | P-256 | Commit signing | ❌ No (derived on-the-fly) | 42 + | PDS rotation key | secp256k1 | PLC genesis, initial key transfer | ✅ Yes | 43 + | PDS service key | P-256 | Session/OAuth JWT signing | ✅ Yes | 44 + 45 + ### DID Document Structure 46 + 47 + ```json 48 + { 49 + "verificationMethods": { 50 + "atproto": "did:key:z6Mkq... (PRF-derived signing key)", 51 + "atproto_service": "did:key:z5Mka... (PDS server key)" 52 + }, 53 + "rotationKeys": ["did:key:z6Mkq... (PRF-derived signing key)"] 54 + } 55 + ``` 56 + 57 + After key registration, PDS rotation key is **removed** from DID document. Only user's passkey-derived key is the rotation key. 58 + 59 + ## Protocol Flows 60 + 61 + ### Passkey Registration Flow 62 + 63 + **Objective:** Register a new passkey and transfer DID control from PDS to user. 64 + 65 + ``` 66 + ┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐ 67 + │ Browser │ │ PDS Server │ │ PLC Directory │ 68 + └─────┬───────┘ └──────┬───────────┘ └────────┬─────────┘ 69 + │ │ │ 70 + │ 1. Request │ │ 71 + │ challenge │ │ 72 + ├──────────────────────>│ │ 73 + │ │ 2. Get DID audit │ 74 + │ ├───────────────────────>│ 75 + │ │ │ 3. Submit 76 + │ │<────────────────────────┘ operation 77 + │ 3. Create │ │ (signing key becomes 78 + │ passkey │ │ rotation key) 79 + │<───────────────────────┤ 80 + │ │ │ 81 + │ 4. Extract & │ │ 82 + │ derive signing │ │ 83 + │ key │ │ 84 + │ │ │ 85 + │ 5. Send signing │ │ 86 + │ public key │ │ 87 + ├──────────────────────>│ │ 88 + │ │ 4. Update DB │ 89 + │ ├──────────────────────────>│ 90 + │ │ │ 91 + │ │<──────────────────────────┘ 92 + │<───────────────────────┘ 93 + ``` 94 + 95 + **Steps:** 96 + 97 + 1. **Client requests challenge** — Server returns WebAuthn creation options with random challenge and PRF extension 98 + 2. **Client creates passkey** — Passkey generated with PRF extension using fixed seed 99 + 3. **Client extracts PRF output** — Retrieved from passkey response extensions 100 + 4. **Client derives signing key** — Deterministic HKDF-SHA256 derivation from PRF output 101 + 5. **Client sends public key** — Sends attestation and derived signing public key 102 + 6. **Server validates and stores** — Validates passkey attestation, stores keys, updates DID via PLC 103 + 7. **Key transfer** — Server signs PLC operation to make user's key the rotation key 104 + 105 + ### Commit Signing Flow 106 + 107 + **Objective:** Sign a repo commit using passkey-derived signing key. 108 + 109 + ``` 110 + ┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐ 111 + │ Browser │ │ PDS Server │ │ Account Page │ 112 + │ (ATProto │ │ │ │ (Signer) │ 113 + │ Client) │ │ │ └────────┬─────────┘ 114 + └─────┬───────┘ └──────┬───────────┘ │ 115 + │ │ │ 116 + │ 1. applyWrites │ │ 117 + ├──────────────────────>│ │ 118 + │ │ 2. WebSocket sign request │ 119 + │<───────────────────────┼───────────────────────>│ 120 + │ │ │ 3. Request passkey 121 + │ │ │ assertion (no PRF) 122 + │ │ │ 123 + │ │ 4. Retrieve PRF │ 124 + │ │ output from storage │ 125 + │ │ │ 126 + │ │<──────────────────────────┘ 127 + │ │ 128 + │ │ 5. Derive signing key 129 + │ │ from stored PRF 130 + │ │ 131 + │ │ 6. Sign commit 132 + │ │ 133 + │<───────────────────────┤ 134 + │ 7. sign_response │ 135 + ├──────────────────────>│ 8. Verify assertion 136 + │ │ against authPublicKey 137 + │ │ 138 + │ │ 9. Verify commit signature 139 + │ │ against signingPublicKey 140 + │ │ 141 + │ │ 10. Persist commit 142 + │<───────────────────────┘ 143 + ``` 144 + 145 + **Steps:** 146 + 147 + 1. **Client initiates write** — Standard ATProto client sends write request 148 + 2. **Server sends sign request** — Holds HTTP request, sends WebSocket sign request 149 + 3. **Client retrieves PRF output** — Retrieves from browser storage 150 + 4. **Client derives signing key** — Same deterministic derivation as registration 151 + 5. **Client signs commit** — Signs with derived key 152 + 6. **Client gets passkey assertion** — Passkey assertion for authentication (no PRF needed) 153 + 7. **Client sends sign response** — Sends passkey assertion and commit signature 154 + 8. **Server verifies assertion** — Verifies passkey authentication 155 + 9. **Server verifies commit signature** — Verifies commit against stored signing public key 156 + 10. **Server persists commit** — Finalizes and stores commit 157 + 158 + ### Service-Auth Flow 159 + 160 + **Objective:** Generate JWT for background requests (feeds, notifications) without passkey. 161 + 162 + ``` 163 + ┌─────────────┐ ┌──────────────────┐ 164 + │ Any Client│ │ PDS Server │ 165 + └─────┬───────┘ └──────┬───────────┘ 166 + │ │ 167 + │ getServiceAuth │ 168 + ├──────────────────────>│ 1. Validate session 169 + │ │ 2. Look up did:key 170 + │ │ 3. Sign JWT with PDS key 171 + │ │ 4. Return JWT 172 + │<───────────────────────┘ 173 + ``` 174 + 175 + **Steps:** 176 + 177 + 1. **Client requests service auth** — Standard ATProto call 178 + 2. **Server validates session** — From JWT or session cookie 179 + 3. **Server loads PDS key** — Server's private service key 180 + 4. **Server signs JWT** — Signed with PDS key 181 + 5. **Returns signed JWT** — Standard ES256 signature, no passkey required 182 + 183 + **Note:** Service-auth JWTs are verified by checking `verificationMethods.atproto_service` (PDS key), not `verificationMethods.atproto` (passkey-derived key). 184 + 185 + ### Identity Operation Flow 186 + 187 + **Objective:** User updates handle, email, or other PLC-managed identity data. 188 + 189 + #### Before Passkey Registration (PDS Controlled) 190 + 191 + ``` 192 + ┌─────────────┐ ┌──────────────────┐ 193 + │ Browser │ │ PDS Server │ 194 + │ (Request) │ │ │ 195 + └─────┬───────┘ └──────┬───────────┘ 196 + │ │ 197 + │ requestPlcOperation │ 198 + ├──────────────────────>│ 1. Sign with rotation.key 199 + │ │ 2. Submit to PLC 200 + │ │ 3. Return signed op 201 + │<───────────────────────┘ 202 + ``` 203 + 204 + PDS has full authority — can modify DID document freely. 205 + 206 + #### After Passkey Registration (User Controlled) 207 + 208 + ``` 209 + ┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐ 210 + │ Browser │ │ PDS Server │ │ Account Page │ 211 + │ (Request) │ │ │ │ (Signer) │ 212 + └─────┬───────┘ └──────┬───────────┘ └──────┬──────────┘ 213 + │ │ │ 214 + │ requestPlcOperation │ │ 215 + ├──────────────────────>│ │ 216 + │ │ 1. Request signature │ 2. Get passkey 217 + │ │ from SignerHub │ assertion 218 + │ │<──────────────────────┼───────────────────>│ 219 + │ │ │ 3. Re-derive signing 220 + │ │ │ key from PRF 221 + │ │ │ 222 + │ │ 4. Sign PLC operation│ 223 + │ │<──────────────────────┘ 224 + │ │ 5. Submit to PLC 225 + │ │<──────────────────────────┘ 226 + │<───────────────────────┘ 227 + ``` 228 + 229 + **Steps:** 230 + 231 + 1. **Client requests operation** — Standard ATProto call 232 + 2. **Server builds PLC operation** — Constructs operation CBOR 233 + 3. **Server requests signature** — Via SignerHub WebSocket 234 + 4. **SignerHub prompts user** — Passkey assertion required 235 + 5. **Client derives signing key** — From stored PRF output 236 + 6. **Client signs PLC operation** — With derived signing key 237 + 7. **Server submits to PLC** — With user's signature 238 + 8. **Only user's key** — Is now the rotation key 239 + 240 + ## Security Properties 241 + 242 + ### Key Isolation 243 + 244 + | Concern | Passkey | PRF-Derived Signing Key | PDS Server Key | 245 + | -------------------------------- | ---------------- | --------------------------- | ------------------------ | 246 + | **Private key stored on server** | ❌ No | ❌ No | ✅ Yes | 247 + | **Can sign repo commits** | ❌ No | ✅ Yes | ❌ No | 248 + | **Can sign PLC operations** | ❌ No (directly) | ✅ Yes (via PRF derivation) | ✅ Yes (before transfer) | 249 + | **Can sign service-auth JWTs** | ❌ No | ❌ No | ✅ Yes | 250 + | **Requires user interaction** | ✅ Yes | ✅ Yes (via passkey) | ❌ No | 251 + 252 + ### Threat Model Mitigation 253 + 254 + | Threat | Standard PDS | Vow | 255 + | ------------------------------ | ---------------------------------- | ----------------------------------------------- | 256 + | **Server repo key compromise** | Server can forge any commit | ❌ Server never sees signing private key | 257 + | **Server admin abuse** | Server can rotate keys, change DID | ❌ After key transfer, only user can modify DID | 258 + | **Database exfiltration** | Exposes all private keys | ❌ Only public keys stored | 259 + 260 + ### Deterministic Key Derivation 261 + 262 + Same PRF output produces the same signing key deterministically: 263 + 264 + - **Derivation method:** HKDF-SHA256 with fixed parameters 265 + - **PRF seed:** Fixed value for reproducibility 266 + - **Result:** Derivation is reproducible across sessions, browsers, devices 267 + 268 + **Why this matters:** 269 + 270 + 1. **Consistent signatures** — Same commit always produces same signature 271 + 2. **Key recovery** — Lost browser storage can be recovered by re-registering passkey with same seed 272 + 3. **Auditability** — PRF output never needs to leave device, but same key material always derivable 273 + 274 + ## Trade-offs 275 + 276 + ### Advantages 277 + 278 + | Advantage | Description | 279 + | ------------------------ | ------------------------------------------------------------------------ | 280 + | **User-controlled DID** | After passkey registration, PDS cannot modify user's identity | 281 + | **Key isolation** | Compromised database never exposes signing private keys | 282 + | **Hardware security** | Passkey private key lives in TPM/Secure Enclave, never in browser memory | 283 + | **No extensions needed** | Works in any modern browser (WebAuthn Level 3 with PRF support) | 284 + | **Portable identity** | User can migrate to new PDS by updating serviceEndpoint via PLC | 285 + | **Standard federation** | Uses `did:plc` — full compatibility with ATProto ecosystem | 286 + 287 + ### Disadvantages 288 + 289 + | Disadvantage | Mitigation | 290 + | ------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------- | 291 + | **Requires browser tab** | User must keep account page open for signing | Pin tab, use separate device | 292 + | **PRF browser support** | Not all browsers passkeys support PRF extension | Chrome 126+, Edge 126+, Safari 18+, Firefox 130+ | 293 + | **Storage dependency** | Lost PRF output on cache clear requires re-registration | Re-register passkey (seed produces same key) | 294 + | **Service-auth gap** | Standard ATProto clients don't check `#atproto_service` | [RFC pending](https://github.com/bluesky-social/atproto/discussions/4739) | 295 + | **Passkey loss** | Lost passkey = lost identity (no recovery mechanism) | This is inherent to passkeys, not Vow-specific | 296 + 297 + ### Compatibility Gaps 298 + 299 + #### Service-Auth Federation Gap 300 + 301 + **Problem:** Standard ATProto reference implementation and AppViews verify service-auth JWTs by checking `verificationMethods.atproto` only, ignoring `verificationMethods.atproto_service`. 302 + 303 + **Current Status:** 304 + 305 + - ✅ Vow correctly sets `#atproto_service` in DID document 306 + - ✅ Vow correctly signs service-auth JWTs with that key 307 + - ❌ Standard clients reject these tokens with `BadJwtSignature` 308 + - ✅ Vow's own account page works (reads `#atproto_service`) 309 + 310 + **Impact:** 311 + 312 + - ❌ Background feed loading via standard clients fails 313 + - ❌ Notification streaming via standard clients fails 314 + - ❌ Proxied blob reads via standard clients fails 315 + - ✅ Direct API access works if clients check `#atproto_service` 316 + - ✅ Vow's built-in UI works fully 317 + 318 + **Tracking:** RFC Draft for atproto_service verification 319 + 320 + #### Mitigation for Users 321 + 322 + Until the RFC lands, users should: 323 + 324 + 1. Use Vow's account page for full experience 325 + 2. Build/custom clients that check `#atproto_service` as fallback 326 + 3. Use direct API calls for automation 327 + 328 + ## Maintenance 329 + 330 + ### Key Management 331 + 332 + #### Server Keys (Persistent) 333 + 334 + | Key | Location | Rotation Policy | Backup Required | 335 + | -------------- | --------------------- | ---------------------------- | --------------- | 336 + | `rotation.key` | `./keys/rotation.key` | Never (used for DID genesis) | ✅ Yes | 337 + | `jwk.key` | `./keys/jwk.key` | Never | ✅ Yes | 338 + 339 + **Best Practices:** 340 + 341 + - Store `./keys/` backups in secure, off-site location 342 + - `rotation.key` compromise = complete DID takeover (all accounts before key transfer) 343 + - `jwk.key` compromise = session hijacking (cannot forge commits, but can issue JWTs) 344 + - Use strong permissions on `./keys/` directory 345 + 346 + #### User Keys (Transient) 347 + 348 + | Key | Storage | Derivation | Lifetime | 349 + | ----------------------- | ----------------------------- | ------------------------------ | -------- | 350 + | Passkey private key | Hardware (TPM/Secure Enclave) | Permanent (until deregistered) | 351 + | PRF output | Browser storage | Until browser data cleared | 352 + | PRF-derived signing key | Derived on-the-fly | Ephemeral (per-session) | 353 + 354 + **Recovery:** 355 + 356 + - **Lost passkey:** No recovery — identity lost (passkey limitation) 357 + - **Lost PRF output:** Re-register same passkey → same signing key (deterministic) 358 + 359 + ### Database Maintenance 360 + 361 + #### Schema 362 + 363 + The `repos` table structure is stable: 364 + 365 + - `auth_public_key` — Passkey P-256 public key 366 + - `signing_public_key` — PRF-derived P-256 signing key 367 + - `credential_id` — WebAuthn credential ID 368 + 369 + **Upgrade path:** 370 + 371 + - Stop services 372 + - Delete old database 373 + - Restart services 374 + - ⚠️ This deletes all user data — backup first! 375 + 376 + #### Index Maintenance 377 + 378 + Automatic indexing: 379 + 380 + - Records indexed by `did`, `nsid`, `rkey` 381 + - Blobs indexed by `did`, `cid` (reference counting) 382 + - Session indexed by `token`, `did` 383 + 384 + No manual index maintenance required. 385 + 386 + ### PLC Operations 387 + 388 + #### Key Transfer Operation 389 + 390 + When user registers an existing passkey: 391 + 392 + 1. PDS builds PLC operation with user's signing key 393 + 2. Operation is signed by: 394 + - **First registration:** PDS rotation key (still has authority) 395 + - **Key rotation:** User's passkey-derived signing key (new rotation key) 396 + 3. Submit to PLC directory 397 + 398 + **Audit trail:** 399 + 400 + - PLC audit log shows clear transfer 401 + - `rotationKeys` changes from PDS key to user key 402 + - Timestamp proves when user took control 403 + 404 + #### Future Identity Updates 405 + 406 + After key transfer, user can: 407 + 408 + 1. **Update handle** — Requires passkey signature 409 + 2. **Update email** — Requires email confirmation link (no passkey) 410 + 3. **Deactivate account** — Requires passkey signature 411 + 412 + All identity changes require passkey authentication after transfer. 413 + 414 + ### Disaster Recovery 415 + 416 + #### Scenario: Complete Server Loss 417 + 418 + 1. **PDS rotation key compromised:** 419 + - Attackers can create new accounts, transfer control before user registers passkey 420 + - Detection: Multiple new registrations with different emails 421 + - Recovery: Generate new `rotation.key` (invalidates old key) 422 + 423 + 2. **Database exfiltration:** 424 + - Attackers get public keys only 425 + - **Cannot sign commits** (no private signing key) 426 + - Impact: Read-only access to past commits 427 + - Recovery: Revoke passkeys, rotate PDS keys 428 + 429 + 3. **IPFS data loss:** 430 + - Users lose repos and blobs 431 + - Recovery: Users re-sync from relays 432 + 433 + #### Scenario: User Loss Recovery 434 + 435 + 1. **Lost passkey (after DID transfer):** 436 + - ❌ No recovery possible 437 + - Mitigation: Register multiple passkeys on different devices before loss 438 + 439 + 2. **Accidentally deleted browser storage (PRF output):** 440 + - ✅ Re-register same passkey 441 + - ✅ Deterministic derivation produces same signing key 442 + - ✅ Identity remains intact 443 + 444 + ## WebSocket Protocol Specification 445 + 446 + ### Connection 447 + 448 + **Endpoint:** `wss://<hostname>/account/signer` 449 + 450 + **Authentication:** Session cookie or Bearer token 451 + 452 + ### Message Types 453 + 454 + #### sign_request (Server → Client) 455 + 456 + ```json 457 + { 458 + "type": "sign_request", 459 + "requestId": "uuid-v4", 460 + "did": "did:plc:abc123...", 461 + "payload": "base64url-encoded unsigned commit CBOR", 462 + "ops": [ 463 + { 464 + "type": "create|update|delete", 465 + "collection": "app.bsky.feed.post|app.bsky.graph.follows|...", 466 + "rkey": "record key (for updates)" 467 + } 468 + ], 469 + "expiresAt": "ISO-8601 timestamp" 470 + } 471 + ``` 472 + 473 + **Fields:** 474 + 475 + - `requestId`: Unique identifier for this request 476 + - `did`: User's DID 477 + - `payload`: CBOR bytes of unsigned commit 478 + - `ops`: Array of operations (for UI display) 479 + - `expiresAt`: When request becomes invalid 480 + 481 + #### sign_response (Client → Server) 482 + 483 + ```json 484 + { 485 + "type": "sign_response", 486 + "requestId": "uuid-v4", 487 + "authenticatorData": "base64url authenticatorData bytes", 488 + "clientDataJSON": "base64url clientDataJSON bytes", 489 + "signature": "base64url DER-encoded ECDSA signature", 490 + "commitSignature": "base64url 64-byte raw r||s signature" 491 + } 492 + ``` 493 + 494 + **Fields:** 495 + 496 + - `requestId`: Must match original request 497 + - `authenticatorData`: WebAuthn authenticator data 498 + - `clientDataJSON`: WebAuthn client data JSON 499 + - `signature`: DER-encoded ECDSA signature from passkey 500 + - `commitSignature`: Raw signature from PRF-derived signing key 501 + 502 + #### sign_reject (Client → Server) 503 + 504 + ```json 505 + { 506 + "type": "sign_reject", 507 + "requestId": "uuid-v4" 508 + } 509 + ``` 510 + 511 + **Used when:** 512 + 513 + - User cancelled passkey prompt 514 + - Passkey not found 515 + - Request expired 516 + - Technical error 517 + 518 + ### Connection Lifecycle 519 + 520 + 1. **Handshake** 521 + - HTTP GET → WebSocket upgrade 522 + - Server verifies session/bearer token 523 + - Connection accepted or rejected 524 + 525 + 2. **Keepalive** 526 + - Server sends `ping` messages 527 + - Client responds with `pong` 528 + - Missing `pong` → connection terminated 529 + 530 + 3. **Reconnection** 531 + - Exponential backoff 532 + - Automatic on page visible, tab focus 533 + - Manual: User clicks "Connect" button 534 + 535 + 4. **Single connection per DID** 536 + - New connection immediately closes old connection 537 + - "Replacing existing connection" logged 538 + 539 + ## Error Handling 540 + 541 + ### Client-Side Errors 542 + 543 + | Error | Cause | User Action | 544 + | ------------------------------------ | ----------------------------------- | ---------------------------------------------------- | 545 + | `PRF extension output not available` | Browser/passkey doesn't support PRF | Use Chrome 126+, Edge 126+, Safari 18+, Firefox 130+ | 546 + | `PRF output not found` | Browser storage cleared | Re-register passkey (same key) | 547 + | `Passkey creation was cancelled` | User dismissed prompt | Try again | 548 + | `Passkey assertion was cancelled` | User dismissed prompt | Try again | 549 + 550 + ### Server-Side Errors 551 + 552 + | Error | HTTP Status | Cause | 553 + | ---------------------------------------- | ----------- | --------------------------------------- | 554 + | `no public key registered for account` | 400 | No signing key in DB (register passkey) | 555 + | `assertion verification failed` | 401 | Invalid passkey signature | 556 + | `signature verification failed` | 400 | Derived key mismatch (BUG) | 557 + | `challenge mismatch` | 400 | Tampered request | 558 + | `rpIdHash mismatch` | 400 | Wrong origin/domain | 559 + | `no extension data in authenticatorData` | 400 | PRF not supported | 560 + | `failed to parse attestation object` | 400 | Invalid WebAuthn response | 561 + 562 + ## Appendix 563 + 564 + ### References 565 + 566 + - [WebAuthn Level 3 Specification](https://www.w3.org/TR/webauthn-3/) 567 + - [WebAuthn PRF Extension](https://w3cg.github.io/webauthn/sctn-prf-extension/) 568 + - [ATProto DID Spec](https://github.com/bluesky-social/atproto/blob/main/identifiers/did-plc.md) 569 + - [Service-Auth RFC Discussion](https://github.com/bluesky-social/atproto/discussions/4739)