Vow, uncensorable PDS written in Go

docs: change vow direction

+30 -29
+30 -29
readme.md
··· 8 8 ## Features 9 9 10 10 - ✅ **IPFS storage** — repo blocks and blobs are stored on a local Kubo node and indexed in SQLite by DID and CID. 11 - - ✅ **Keyless PDS** — the server never stores a private key. Every write is signed by the user's secp256k1 key in their Ethereum wallet (Rabby, MetaMask, etc.). 12 - - ✅ **Browser signer** — the account page connects over WebSocket and signs repo commits and PLC operations with the user's Ethereum wallet. No browser extension is needed; just keep the tab open. Standard ATProto clients do not need to know about it. 13 - - ✅ **User-controlled DID** — when the user registers a key, the PDS transfers the `did:plc` rotation key to the user's wallet. After that, only the user can change their identity. 14 - - 🔜 **x402 payments for IPFS storage** — blob uploads will be gated by on-chain payments via the [x402 protocol](https://x402.org), using the same Ethereum wallet. 11 + - ✅ **Keyless PDS** — the server never stores a private key. Every write is signed by the user's passkey (WebAuthn/FIDO2) registered during onboarding. 12 + - ✅ **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. 13 + - ✅ **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. 15 14 16 15 ## Quick Start with Docker Compose 17 16 ··· 99 98 100 99 ### Reverse Proxy 101 100 102 - You need a reverse proxy (nginx, Caddy, etc.) in front of both services: 101 + You need a reverse proxy (nginx, Caddy, etc.) in front of the PDS: 103 102 104 103 | Service | Internal address | Purpose | 105 104 | ------- | ---------------- | ----------------------------- | ··· 131 130 VOW_IPFS_GATEWAY_URL="https://ipfs.example.com" 132 131 ``` 133 132 134 - `VOW_IPFS_NODE_URL` is the only required IPFS setting. The local Kubo node is the only storage backend for now; remote pinning will come later through x402 payments. 133 + `VOW_IPFS_NODE_URL` is the only required IPFS setting. 135 134 136 135 ### SMTP Email 137 136 ··· 148 147 149 148 The PDS holds two keys: 150 149 151 - - **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 wallet during `supplySigningKey`. It is never used to sign user content. 150 + - **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. 152 151 - **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. 153 152 154 153 Neither key is ever used to sign repo commits or service-auth JWTs. 155 154 156 - Every repo write from the Bluesky app, Tangled, or any other standard ATProto client is held open until the user's Ethereum wallet returns a signature through the browser signer on the account page. 155 + 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. 157 156 158 157 #### End-to-end signing flow 159 158 160 159 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. 161 160 162 161 ``` 163 - ATProto client PDS (vow) Account page (browser) Ethereum wallet 162 + ATProto client PDS (vow) Account page (browser) Passkey (WebAuthn) 164 163 | | | | 165 164 |-- createRecord ----->| | | 166 165 | |-- 1. build unsigned commit | | 167 166 | |-- 2. push signing request | | 168 167 | | over WebSocket ---------->| | 169 - | (HTTP held open, | |-- personal_sign() --->| 170 - | up to 30 s) | |<-- signature ---------| 168 + | (HTTP held open, | |-- navigator | 169 + | up to 30 s) | | .credentials.get()->| 170 + | | |<-- assertion ---------| 171 171 | |<-- 3. signature over WS -------| | 172 172 | |-- 4. verify signature | | 173 173 | |-- 5. finalise & persist commit | | 174 174 |<-- 200 result -------| | | 175 175 ``` 176 176 177 - **What requires a wallet signature:** 177 + **What requires a passkey signature:** 178 178 179 - Only operations that change the user's repo content, or identity operations after the user has taken ownership of their rotation key, need a wallet signature: 179 + 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: 180 180 181 181 - **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites` 182 - - **Identity operations** — PLC operations and handle updates, **once the user's wallet key is the rotation key**. Before that, the PDS rotation key signs them directly. 183 - - **x402 payments** — EIP-712 payment authorisations for gated pinning 182 + - **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. 184 183 185 - Read-only operations (browsing feeds, loading profiles, fetching notifications, etc.) do **not** prompt the wallet. Service-auth JWTs for proxied requests (`getServiceAuth`, ATProto proxy) are also signed by the user's wallet via the signer WebSocket — the wallet tab must be open for these to succeed. 184 + 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 also signed by the user's passkey via the signer WebSocket — the signer tab must be open for these to succeed. 186 185 187 186 #### WebSocket connection 188 187 ··· 223 222 { 224 223 "type": "sign_response", 225 224 "requestId": "uuid", 226 - "signature": "<base64url-encoded EIP-191 signature bytes>" 225 + "signature": "<base64url-encoded signature bytes>" 227 226 } 228 227 ``` 229 228 ··· 238 237 239 238 ### Browser-Based Signer 240 239 241 - 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. 240 + 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 via the platform's passkey prompt (biometric, PIN, or security key). 242 241 243 242 ## Identity & DID Sovereignty 244 243 ··· 254 253 255 254 **Phase 1 — Account creation.** `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key. 256 255 257 - **Phase 2 — Key registration.** When the user completes onboarding and calls `supplySigningKey`, the PDS submits one PLC operation that makes the user's wallet key both the signing key and the **only rotation key**, removing the PDS key from the DID document. After that, only the user's Ethereum wallet 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. 256 + **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. 258 257 259 258 ### What the user gets 260 259 261 260 | Property | Before key registration | After key registration | 262 261 | --------------------------- | -------------------------- | ------------------------------------------------- | 263 - | Who signs commits | Nobody (no key registered) | User's wallet | 264 - | Who controls the DID | PDS rotation key | User's wallet key | 262 + | Who signs commits | Nobody (no key registered) | User's passkey | 263 + | Who controls the DID | PDS rotation key | User's passkey-derived key | 265 264 | PDS can hijack identity | Yes | **No** | 266 265 | User can migrate to new PDS | No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) | 267 266 | Federation compatibility | Full | Full (unchanged) | 268 267 269 268 ### Verifiability 270 269 271 - 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 wallet `did:key`. 270 + 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`. 272 271 273 - ### Why not `did:key` or on-chain? 272 + ### Why passkeys instead of Ethereum wallets? 273 + 274 + 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. 274 275 275 - - **`did:key`** — elegant, but current ATProto AppViews and relays do not resolve it. 276 - - **On-chain registry** — fully trustless, but every PDS would need blockchain support. 277 - - **`did:web`** — if hosted on the PDS domain, the PDS controls it. If hosted on the user's domain, most users do not have one. 276 + Passkeys (WebAuthn/FIDO2) solve this: 278 277 279 - `did:plc` with rotation key transfer is the pragmatic choice: it works with existing ATProto implementations today and gives users real control over their identity. 278 + - **No browser extension required** — passkeys are built into every modern browser and OS. 279 + - **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. 280 + - **Familiar UX** — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup. 281 + - **Correct signature format** — the passkey signs raw bytes with standard ECDSA, producing signatures that ATProto verifiers accept without transformation. 280 282 281 283 ## Management Commands 282 284 ··· 389 391 | IPFS repo block storage | ✅ (Kubo) | ❌ | 390 392 | Email 2FA | ❌ removed | ✅ | 391 393 | BYOK (keyless PDS) | ✅ | ❌ | 392 - | Ethereum wallet signer | ✅ | ❌ | 394 + | Passkey signer | ✅ | ❌ | 393 395 | User-sovereign DID | ✅ | ❌ | 394 - | x402 payments for IPFS | ✅ | ❌ |