···88## Features
991010- ✅ **IPFS storage** — repo blocks and blobs are stored on a local Kubo node and indexed in SQLite by DID and CID.
1111-- ✅ **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.).
1212-- ✅ **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.
1313-- ✅ **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.
1414-- 🔜 **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.
1111+- ✅ **Keyless PDS** — the server never stores a private key. Every write is signed by the user's passkey (WebAuthn/FIDO2) registered during onboarding.
1212+- ✅ **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.
1313+- ✅ **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.
15141615## Quick Start with Docker Compose
1716···999810099### Reverse Proxy
101100102102-You need a reverse proxy (nginx, Caddy, etc.) in front of both services:
101101+You need a reverse proxy (nginx, Caddy, etc.) in front of the PDS:
103102104103| Service | Internal address | Purpose |
105104| ------- | ---------------- | ----------------------------- |
···131130VOW_IPFS_GATEWAY_URL="https://ipfs.example.com"
132131```
133132134134-`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.
133133+`VOW_IPFS_NODE_URL` is the only required IPFS setting.
135134136135### SMTP Email
137136···148147149148The PDS holds two keys:
150149151151-- **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.
150150+- **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.
152151- **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.
153152154153Neither key is ever used to sign repo commits or service-auth JWTs.
155154156156-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.
155155+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.
157156158157#### End-to-end signing flow
159158160159Standard 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.
161160162161```
163163-ATProto client PDS (vow) Account page (browser) Ethereum wallet
162162+ATProto client PDS (vow) Account page (browser) Passkey (WebAuthn)
164163 | | | |
165164 |-- createRecord ----->| | |
166165 | |-- 1. build unsigned commit | |
167166 | |-- 2. push signing request | |
168167 | | over WebSocket ---------->| |
169169- | (HTTP held open, | |-- personal_sign() --->|
170170- | up to 30 s) | |<-- signature ---------|
168168+ | (HTTP held open, | |-- navigator |
169169+ | up to 30 s) | | .credentials.get()->|
170170+ | | |<-- assertion ---------|
171171 | |<-- 3. signature over WS -------| |
172172 | |-- 4. verify signature | |
173173 | |-- 5. finalise & persist commit | |
174174 |<-- 200 result -------| | |
175175```
176176177177-**What requires a wallet signature:**
177177+**What requires a passkey signature:**
178178179179-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:
179179+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:
180180181181- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
182182-- **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.
183183-- **x402 payments** — EIP-712 payment authorisations for gated pinning
182182+- **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.
184183185185-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.
184184+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.
186185187186#### WebSocket connection
188187···223222{
224223 "type": "sign_response",
225224 "requestId": "uuid",
226226- "signature": "<base64url-encoded EIP-191 signature bytes>"
225225+ "signature": "<base64url-encoded signature bytes>"
227226}
228227```
229228···238237239238### Browser-Based Signer
240239241241-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.
240240+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).
242241243242## Identity & DID Sovereignty
244243···254253255254**Phase 1 — Account creation.** `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key.
256255257257-**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.
256256+**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.
258257259258### What the user gets
260259261260| Property | Before key registration | After key registration |
262261| --------------------------- | -------------------------- | ------------------------------------------------- |
263263-| Who signs commits | Nobody (no key registered) | User's wallet |
264264-| Who controls the DID | PDS rotation key | User's wallet key |
262262+| Who signs commits | Nobody (no key registered) | User's passkey |
263263+| Who controls the DID | PDS rotation key | User's passkey-derived key |
265264| PDS can hijack identity | Yes | **No** |
266265| User can migrate to new PDS | No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) |
267266| Federation compatibility | Full | Full (unchanged) |
268267269268### Verifiability
270269271271-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`.
270270+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`.
272271273273-### Why not `did:key` or on-chain?
272272+### Why passkeys instead of Ethereum wallets?
273273+274274+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.
274275275275-- **`did:key`** — elegant, but current ATProto AppViews and relays do not resolve it.
276276-- **On-chain registry** — fully trustless, but every PDS would need blockchain support.
277277-- **`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.
276276+Passkeys (WebAuthn/FIDO2) solve this:
278277279279-`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.
278278+- **No browser extension required** — passkeys are built into every modern browser and OS.
279279+- **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.
280280+- **Familiar UX** — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup.
281281+- **Correct signature format** — the passkey signs raw bytes with standard ECDSA, producing signatures that ATProto verifiers accept without transformation.
280282281283## Management Commands
282284···389391| IPFS repo block storage | ✅ (Kubo) | ❌ |
390392| Email 2FA | ❌ removed | ✅ |
391393| BYOK (keyless PDS) | ✅ | ❌ |
392392-| Ethereum wallet signer | ✅ | ❌ |
394394+| Passkey signer | ✅ | ❌ |
393395| User-sovereign DID | ✅ | ❌ |
394394-| x402 payments for IPFS | ✅ | ❌ |