Vow#
WARNING
This is highly experimental software. Use with caution, especially during account migration.
IMPORTANT
Vow implements a two-key model for signing that requires a pending ATProto protocol change to be fully interoperable.
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:
verificationMethods.atproto→ the user's passkey. Signs every repo commit. Requires passkey usage, which is acceptable because commits are user-initiated.verificationMethods.atproto_service→ the PDS server key. Signs service-auth JWTs for background requests (feed loading, notifications, proxied reads) without ever prompting the passkey.
did-method-plc#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.
What remains blocked: AppViews and relays in the reference implementation (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. Until it lands in indigo and Bluesky's infrastructure, feeds, notifications, and proxied reads on standard ATProto clients will not work.
Vow is a Go PDS (Personal Data Server) for the AT Protocol.
Features#
- ✅ IPFS storage — repo blocks and blobs are stored on a local Kubo node and indexed in SQLite by DID and CID.
- ✅ Keyless PDS — the server never stores a private key. Every write is signed by the user's passkey (WebAuthn/FIDO2) registered during onboarding.
- ✅ 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.
- ✅ User-controlled DID — when the user registers a key, the PDS transfers the
did:plcrotation key to the user's passkey-derived public key. After that, only the user can change their identity.
Quick Start with Docker Compose#
Prerequisites#
- Docker and Docker Compose installed
- A domain name pointing to your server
Installation#
-
Clone the repository
git clone https://pkg.rbrt.fr/vow.git cd vow -
Create your configuration file
cp .env.example .env -
Edit
.envwith your settingsVOW_DID="did:web:your-domain.com" VOW_HOSTNAME="your-domain.com" VOW_CONTACT_EMAIL="you@example.com" VOW_RELAYS="https://bsky.network" # Generate with: openssl rand -hex 16 VOW_ADMIN_PASSWORD="your-secure-password" # Generate with: openssl rand -hex 32 VOW_SESSION_SECRET="your-session-secret" -
Start the services
docker compose pull docker compose up -dThis starts three services:
- ipfs — a Kubo node for repo blocks and blobs
- vow — the PDS
- create-invite — creates an initial invite code on first run
-
Get your invite code
On first run, an invite code is automatically created. View it with:
docker compose logs create-inviteOr check the saved file:
cat keys/initial-invite-code.txt -
Monitor the services
docker compose logs -f
What Gets Set Up#
- init-keys: Generates the rotation key and JWK on first run
- 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:8081for your reverse proxy. - vow: The main PDS service on port 8080
- create-invite: Creates an initial invite code on first run
Data Persistence#
./keys/— generated keysrotation.key— PDS rotation keyjwk.key— JWK private keyinitial-invite-code.txt— first invite code (first run only)
./data/— SQLite metadata databaseipfs_dataDocker volume — IPFS blocks and blobs
Reverse Proxy#
You need a reverse proxy (nginx, Caddy, etc.) in front of the PDS:
| Service | Internal address | Purpose |
|---|---|---|
| vow | 127.0.0.1:8080 |
AT Protocol PDS |
| ipfs | 127.0.0.1:8081 |
IPFS gateway for blob serving |
Set VOW_IPFS_GATEWAY_URL to your public gateway URL so sync.getBlob redirects clients there instead of proxying through vow.
Configuration#
Database#
Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens. No extra setup is required.
VOW_DB_NAME="/data/vow/vow.db"
IPFS Node#
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:
# URL of the Kubo RPC API
VOW_IPFS_NODE_URL="http://127.0.0.1:5001"
# Optional: redirect sync.getBlob to a public gateway instead of proxying
# through vow. Set to your own gateway or a public one like https://ipfs.io
VOW_IPFS_GATEWAY_URL="https://ipfs.example.com"
VOW_IPFS_NODE_URL is the only required IPFS setting.
SMTP Email#
VOW_SMTP_USER="your-smtp-username"
VOW_SMTP_PASS="your-smtp-password"
VOW_SMTP_HOST="smtp.example.com"
VOW_SMTP_PORT="587"
VOW_SMTP_EMAIL="noreply@example.com"
VOW_SMTP_NAME="Vow PDS"
BYOK (Bring Your Own Key)#
The PDS holds two keys:
- 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 duringsupplySigningKey. It is never used to sign user content. - 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.
Neither key is ever used to sign repo commits or service-auth JWTs.
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.
End-to-end signing flow#
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.
ATProto client PDS (vow) Account page (browser) Passkey (WebAuthn)
| | | |
|-- createRecord ----->| | |
| |-- 1. build unsigned commit | |
| |-- 2. push signing request | |
| | over WebSocket ---------->| |
| (HTTP held open, | |-- navigator |
| up to 30 s) | | .credentials.get()->|
| | |<-- assertion ---------|
| |<-- 3. signature over WS -------| |
| |-- 4. verify signature | |
| |-- 5. finalise & persist commit | |
|<-- 200 result -------| | |
What requires a passkey signature:
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:
- Repo writes —
createRecord,putRecord,deleteRecord,applyWrites - 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.
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.
WebSocket connection#
The account page connects with the session cookie set at sign-in:
GET /account/signer
Cookie: <session-cookie>
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.
A legacy Bearer-token endpoint is also available for programmatic clients:
GET /xrpc/com.atproto.server.signerConnect
Authorization: Bearer <access-token>
Signing request message (PDS → browser):
{
"type": "sign_request",
"requestId": "uuid",
"did": "did:plc:...",
"payload": "<base64url-encoded unsigned commit bytes>",
"ops": [
{ "type": "create", "collection": "app.bsky.feed.post", "rkey": "3abc..." }
],
"expiresAt": "2025-01-01T00:00:30Z"
}
Signature response message (browser → PDS):
{
"type": "sign_response",
"requestId": "uuid",
"authenticatorData": "<base64url authenticatorData bytes>",
"clientDataJSON": "<base64url clientDataJSON bytes>",
"signature": "<base64url DER-encoded ECDSA signature>"
}
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.
Rejection message (browser → PDS):
{
"type": "sign_reject",
"requestId": "uuid"
}
Browser-Based Signer#
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.
Identity & DID Sovereignty#
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.
The problem with standard ATProto#
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.
Vow's approach: trust-then-transfer#
Vow uses a two-phase model:
Phase 1 — Account creation. createAccount works like standard ATProto: the PDS creates the did:plc with its own rotation key.
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.
What the user gets#
| Property | Before key registration | After key registration |
|---|---|---|
| Who signs commits | Nobody (no key registered) | User's passkey (#atproto) |
| Who signs service-auth JWTs | PDS server key | PDS server key (#atproto_service) |
| Who controls the DID | PDS rotation key | User's passkey-derived key |
| PDS can hijack identity | Yes | No |
| User can migrate to new PDS | Yes (If rotation key was set) & No (PDS must cooperate) | Yes (sign a PLC op to update serviceEndpoint) |
| Commit federation compatibility | Full | Full (unchanged) |
| Service-auth federation compat. | Full | Partial — requires #atproto_service RFC to land in AppViews |
Verifiability#
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.
Why passkeys instead of Ethereum wallets?#
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.
Passkeys (WebAuthn/FIDO2) solve this:
- No browser extension required — passkeys are built into every modern browser and OS.
- 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.
- Familiar UX — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup.
- 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.
Management Commands#
Create an invite code:
docker exec vow-pds /vow create-invite-code --uses 1
Reset a user's password:
docker exec vow-pds /vow reset-password --did "did:plc:xxx"
Updating#
docker compose build
docker compose up -d
Implemented Endpoints#
NOTE
Just because something is implemented doesn't mean it is finished. Many endpoints still have rough edges around validation and error handling.
Identity#
-
com.atproto.identity.getRecommendedDidCredentials -
com.atproto.identity.requestPlcOperationSignature -
com.atproto.identity.resolveHandle -
com.atproto.identity.signPlcOperation -
com.atproto.identity.submitPlcOperation -
com.atproto.identity.updateHandle
Repo#
-
com.atproto.repo.applyWrites -
com.atproto.repo.createRecord -
com.atproto.repo.putRecord -
com.atproto.repo.deleteRecord -
com.atproto.repo.describeRepo -
com.atproto.repo.getRecord -
com.atproto.repo.importRepo(Works "okay". Use with extreme caution.) -
com.atproto.repo.listRecords -
com.atproto.repo.listMissingBlobs
Server#
-
com.atproto.server.activateAccount -
com.atproto.server.checkAccountStatus -
com.atproto.server.confirmEmail -
com.atproto.server.createAccount -
com.atproto.server.createInviteCode -
com.atproto.server.createInviteCodes -
com.atproto.server.deactivateAccount -
com.atproto.server.deleteAccount -
com.atproto.server.deleteSession -
com.atproto.server.describeServer -
com.atproto.server.getAccountInviteCodes -
com.atproto.server.getServiceAuth -
com.atproto.server.refreshSession -
com.atproto.server.requestAccountDelete -
com.atproto.server.requestEmailConfirmation -
com.atproto.server.requestEmailUpdate -
com.atproto.server.requestPasswordReset -
com.atproto.server.resetPassword -
com.atproto.server.updateEmail
Sync#
-
com.atproto.sync.getBlob -
com.atproto.sync.getBlocks -
com.atproto.sync.getLatestCommit -
com.atproto.sync.getRecord -
com.atproto.sync.getRepoStatus -
com.atproto.sync.getRepo -
com.atproto.sync.listBlobs -
com.atproto.sync.listRepos -
com.atproto.sync.requestCrawl -
com.atproto.sync.subscribeRepos
Other#
-
com.atproto.label.queryLabels -
com.atproto.moderation.createReport -
app.bsky.actor.getPreferences -
app.bsky.actor.putPreferences
License#
MIT. server/static/pico.css is also MIT licensed, available at https://github.com/picocss/pico/.
Thanks#
Vow is based on Cocoon. Many thanks for the solid foundation.
Vow vs Cocoon#
| Feature | Vow | Cocoon |
|---|---|---|
| Language | Go | Go |
| SQLite (metadata) | ✅ | ✅ |
| SQLite blockstore | ❌ removed | ✅ |
| PostgreSQL support | ❌ removed | ✅ |
| S3 blob storage | ❌ removed | ✅ |
| S3 database backups | ❌ removed | ✅ |
| IPFS blob storage | ✅ (Kubo) | ❌ |
| IPFS repo block storage | ✅ (Kubo) | ❌ |
| Email 2FA | ❌ removed | ✅ |
| BYOK (keyless PDS) | ✅ | ❌ |
| Passkey signer | ✅ | ❌ |
| User-sovereign DID | ✅ | ❌ |