Vow, uncensorable PDS written in Go

feat: use webauthn prf

+125 -28
+4 -3
models/models.go
··· 20 20 AccountDeleteCode *string 21 21 AccountDeleteCodeExpiresAt *time.Time 22 22 Password string 23 - // PublicKey holds the compressed P-256 (secp256r1) public key bytes for 24 - // the account. This is the only key material the PDS retains. 25 - PublicKey []byte 23 + // AuthPublicKey holds the compressed P-256 public key bytes for WebAuthn assertion verification. 24 + AuthPublicKey []byte 25 + // SigningPublicKey holds the compressed P-256 public key bytes for commit signature verification. 26 + SigningPublicKey []byte 26 27 // CredentialID is the WebAuthn credential ID returned by the authenticator 27 28 // during registration. It is stored so the server can build the 28 29 // allowCredentials list when requesting an assertion from the passkey.
+1 -1
server/handle_account.go
··· 76 76 if err := s.renderTemplate(w, "account.html", map[string]any{ 77 77 "Handle": repo.Handle, 78 78 "Did": repo.Repo.Did, 79 - "HasSigningKey": len(repo.PublicKey) > 0, 79 + "HasSigningKey": len(repo.SigningPublicKey) > 0, 80 80 "CredentialID": credentialID, 81 81 "Tokens": tokenInfo, 82 82 "flashes": s.getFlashesFromSession(w, r, sess),
+2 -2
server/handle_account_signer.go
··· 22 22 did := repo.Repo.Did 23 23 24 24 // Require a public key. 25 - if len(repo.PublicKey) == 0 { 25 + if len(repo.AuthPublicKey) == 0 { 26 26 http.Error(w, "no signing key registered for this account", http.StatusBadRequest) 27 27 return 28 28 } ··· 120 120 } 121 121 delete(pendingPayloads, in.RequestID) 122 122 123 - rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger) 123 + rawSig, err := verifyWebAuthnSignResponse(repo.AuthPublicKey, repo.SigningPublicKey, payload, in, s.config.Hostname, logger) 124 124 if err != nil { 125 125 logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err) 126 126 continue
+2 -2
server/handle_identity_submit_plc_operation.go
··· 57 57 // 4. The operation was signed by one of the current rotation keys (enforced 58 58 // by plc.directory on submission, not re-checked here). 59 59 60 - if len(repo.PublicKey) == 0 { 60 + if len(repo.SigningPublicKey) == 0 { 61 61 helpers.InputError(w, new("no signing key registered for this account")) 62 62 return 63 63 } 64 64 65 - pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 65 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey) 66 66 if err != nil { 67 67 logger.Error("error parsing stored public key", "error", err) 68 68 helpers.ServerError(w, nil)
+1 -1
server/handle_identity_update_handle.go
··· 74 74 75 75 // If no passkey is registered yet, PDS signs. Otherwise, the user's 76 76 // passkey signs (it is the rotation key in Vow's model). 77 - if len(repo.PublicKey) == 0 { 77 + if len(repo.SigningPublicKey) == 0 { 78 78 // PDS still holds authority — sign directly. 79 79 if err := s.plcClient.SignOp(&op); err != nil { 80 80 logger.Error("error signing PLC operation with rotation key", "error", err)
+1 -1
server/handle_passkey_assertion_challenge.go
··· 39 39 return 40 40 } 41 41 42 - if len(repo.PublicKey) == 0 { 42 + if len(repo.AuthPublicKey) == 0 { 43 43 s.writeJSON(w, http.StatusBadRequest, map[string]string{ 44 44 "error": "NoSigningKey", 45 45 "message": "No passkey is registered for this account.",
+2 -2
server/handle_server_delete_account.go
··· 176 176 177 177 // The account must have a registered passkey; without it there is nothing 178 178 // to verify against. 179 - if len(repo.PublicKey) == 0 { 179 + if len(repo.AuthPublicKey) == 0 { 180 180 s.writeJSON(w, http.StatusBadRequest, map[string]any{ 181 181 "error": "NoSigningKey", 182 182 "message": "No passkey is registered for this account. Please register a passkey first.", ··· 226 226 // verifyAssertion checks the challenge, rpIdHash, UP flag, and P-256 227 227 // signature. It also returns the raw (r‖s) bytes, which we discard here. 228 228 if _, err := verifyAssertion( 229 - repo.PublicKey, 229 + repo.AuthPublicKey, 230 230 sum[:], 231 231 clientDataJSONBytes, 232 232 authenticatorDataBytes,
+2 -2
server/handle_server_get_signing_key.go
··· 39 39 return 40 40 } 41 41 42 - if len(repo.PublicKey) == 0 { 42 + if len(repo.SigningPublicKey) == 0 { 43 43 helpers.InputError(w, new("no signing key registered for this account")) 44 44 return 45 45 } 46 46 47 - pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 47 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey) 48 48 if err != nil { 49 49 logger.Error("error parsing stored public key", "error", err) 50 50 helpers.ServerError(w, nil)
+26 -4
server/handle_server_supply_signing_key.go
··· 29 29 // AttestationObject is the base64url-encoded attestationObject CBOR from 30 30 // the AuthenticatorAttestationResponse. 31 31 AttestationObject string `json:"attestationObject" validate:"required"` 32 + // PrfOutput is the base64url-encoded PRF output. 33 + PrfOutput string `json:"prfOutput" validate:"required"` 32 34 } 33 35 34 36 type SupplySigningKeyResponse struct { ··· 95 97 } 96 98 97 99 // Validate the compressed key is a well-formed P-256 point. 98 - pubKey, err := atcrypto.ParsePublicBytesP256(keyBytes) 100 + _, err = atcrypto.ParsePublicBytesP256(keyBytes) 99 101 if err != nil { 100 102 logger.Error("compressed P-256 key rejected by atcrypto", "error", err) 101 103 helpers.InputError(w, new("invalid P-256 public key in attestation")) 102 104 return 103 105 } 104 106 107 + prfOutput, err := base64.RawURLEncoding.DecodeString(req.PrfOutput) 108 + if err != nil { 109 + logger.Error("error decoding prf output", "error", err) 110 + helpers.InputError(w, new("invalid prf output encoding")) 111 + return 112 + } 113 + 114 + privKey, err := deriveSigningKey(prfOutput) 115 + if err != nil { 116 + logger.Error("failed to derive signing key", "error", err) 117 + helpers.ServerError(w, nil) 118 + return 119 + } 120 + pubKey, err := privKey.PublicKey() 121 + if err != nil { 122 + logger.Error("failed to get public key from private key", "error", err) 123 + helpers.ServerError(w, nil) 124 + return 125 + } 126 + 105 127 pubDIDKey := pubKey.DIDKey() 106 128 107 129 // Derive the PDS server did:key for the atproto_service slot. ··· 151 173 152 174 // If no passkey is registered yet, PDS signs (initial registration). 153 175 // Otherwise, the existing passkey signs (passkey rotation). 154 - if len(repo.PublicKey) == 0 { 176 + if len(repo.SigningPublicKey) == 0 { 155 177 // PDS still holds authority — sign directly. This is the last 156 178 // PLC operation the PDS will ever be able to sign on behalf of the 157 179 // user. It is voluntarily handing over control to the passkey. ··· 210 232 211 233 // Persist the compressed P-256 public key and credential ID. 212 234 if err := s.db.Exec(ctx, 213 - "UPDATE repos SET public_key = ?, credential_id = ? WHERE did = ?", 214 - nil, keyBytes, credentialID, repo.Repo.Did, 235 + "UPDATE repos SET auth_public_key = ?, signing_public_key = ?, credential_id = ? WHERE did = ?", 236 + nil, keyBytes, pubKey.Bytes(), credentialID, repo.Repo.Did, 215 237 ).Error; err != nil { 216 238 logger.Error("error updating public key and credential ID in db", "error", err) 217 239 helpers.ServerError(w, nil)
+41 -5
server/handle_signer_connect.go
··· 1 1 package server 2 2 3 3 import ( 4 + "bytes" 4 5 "encoding/base64" 5 6 "encoding/json" 7 + "fmt" 6 8 "log/slog" 7 9 "net/http" 8 10 "strings" ··· 53 55 AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url 54 56 ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url 55 57 Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA 58 + PrfOutput string `json:"prfOutput,omitempty"` // base64url 56 59 } 57 60 58 61 // handleSignerConnect upgrades the connection to a WebSocket and registers it ··· 88 91 89 92 // Ensure the account actually has a public key registered before accepting 90 93 // a signer connection; without it no signature can ever be verified. 91 - if len(repo.PublicKey) == 0 { 94 + if len(repo.AuthPublicKey) == 0 { 92 95 helpers.InputError(w, new("no signing key registered for this account")) 93 96 return 94 97 } ··· 222 225 } 223 226 delete(pendingPayloads, in.RequestID) 224 227 225 - rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger) 228 + rawSig, err := verifyWebAuthnSignResponse(repo.AuthPublicKey, repo.SigningPublicKey, payload, in, s.config.Hostname, logger) 226 229 if err != nil { 227 230 logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err) 228 231 continue ··· 317 320 // sign_request (the raw CBOR bytes of the unsigned commit, or the SHA-256 of 318 321 // the JWT signing input for service-auth tokens). 319 322 func verifyWebAuthnSignResponse( 320 - pubKey []byte, 323 + authPubKey []byte, 324 + signingPubKey []byte, 321 325 payloadB64 string, 322 326 in wsIncoming, 323 327 rpID string, ··· 327 331 return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs 328 332 } 329 333 334 + if in.PrfOutput == "" { 335 + return nil, fmt.Errorf("missing prfOutput in sign_response") 336 + } 337 + 330 338 // The challenge passed to navigator.credentials.get() was the raw bytes 331 339 // decoded from payloadB64. The browser re-encodes them as base64url in 332 340 // clientDataJSON.challenge — so the expected challenge is exactly those ··· 351 359 return nil, err 352 360 } 353 361 354 - rawSig, err := verifyAssertion(pubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID) 362 + _, err = verifyAssertion(authPubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID) 355 363 if err != nil { 356 364 logger.With("rpID", rpID).Debug("verifyAssertion detail", "error", err) 357 365 return nil, err 358 366 } 359 367 360 - return rawSig, nil 368 + // Now derive the signing key from PRF output. 369 + prfOutput, err := base64.RawURLEncoding.DecodeString(in.PrfOutput) 370 + if err != nil { 371 + return nil, fmt.Errorf("invalid prfOutput encoding: %w", err) 372 + } 373 + 374 + privKey, err := deriveSigningKey(prfOutput) 375 + if err != nil { 376 + return nil, fmt.Errorf("failed to derive signing key: %w", err) 377 + } 378 + 379 + pubKey, err := privKey.PublicKey() 380 + if err != nil { 381 + return nil, fmt.Errorf("failed to get public key from private key: %w", err) 382 + } 383 + 384 + // Verify that the derived signing key matches the registered one. 385 + if !bytes.Equal(pubKey.Bytes(), signingPubKey) { 386 + return nil, fmt.Errorf("derived signing key does not match registered signing key") 387 + } 388 + 389 + // Sign the payload with the derived key. 390 + // HashAndSign hashes the data using SHA-256 and produces a low-S signature. 391 + commitSig, err := privKey.HashAndSign(expectedChallenge) 392 + if err != nil { 393 + return nil, fmt.Errorf("failed to sign payload: %w", err) 394 + } 395 + 396 + return commitSig, nil 361 397 } 362 398 363 399 // isTokenExpired returns true if the JWT's exp claim is in the past.
+2 -2
server/repo.go
··· 595 595 } 596 596 597 597 // ── Phase 4: verify the signature ───────────────────────────────────── 598 - if len(urepo.PublicKey) == 0 { 598 + if len(urepo.SigningPublicKey) == 0 { 599 599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did) 600 600 } 601 601 602 - pubKey, err := atcrypto.ParsePublicBytesP256(urepo.PublicKey) 602 + pubKey, err := atcrypto.ParsePublicBytesP256(urepo.SigningPublicKey) 603 603 if err != nil { 604 604 return nil, fmt.Errorf("parsing stored public key: %w", err) 605 605 }
+25 -1
server/templates/account.html
··· 313 313 options.challenge = base64urlToBytes(options.challenge); 314 314 options.user.id = base64urlToBytes(options.user.id); 315 315 316 + // Add PRF extension to create the signing key 317 + // We use a fixed salt so the derived key is always the same. 318 + const prfSalt = new Uint8Array( 319 + await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode("Vow PDS Commit Signing Key Derivation")) 320 + ); 321 + 322 + options.extensions = { 323 + prf: { 324 + eval: { 325 + first: prfSalt 326 + } 327 + } 328 + }; 329 + 316 330 // 2. Create the passkey. 317 331 btn.textContent = "Confirm with your passkey…"; 318 332 const credential = await navigator.credentials.create({ ··· 323 337 throw new Error("Passkey creation was cancelled."); 324 338 } 325 339 326 - // 3. Send the attestation response to the server. 340 + // 3. Extract PRF output 341 + const extResults = credential.getClientExtensionResults(); 342 + if (!extResults.prf || !extResults.prf.results || !extResults.prf.results.first) { 343 + throw new Error("Passkey doesn't support PRF extension. Use a modern browser (e.g. Chrome 126+, Safari 18+)."); 344 + } 345 + const prfOutput = new Uint8Array(extResults.prf.results.first); 346 + localStorage.setItem("vow_prf_output", bytesToBase64url(prfOutput)); 347 + 348 + // 4. Send the attestation response to the server. 327 349 btn.textContent = "Registering…"; 328 350 const res = await fetch("/account/supply-signing-key", { 329 351 method: "POST", ··· 339 361 credential.response.attestationObject, 340 362 ), 341 363 ), 364 + prfOutput: bytesToBase64url(prfOutput), 342 365 }), 343 366 }); 344 367 ··· 724 747 signature: bytesToBase64url( 725 748 new Uint8Array(assertionResponse.signature), 726 749 ), 750 + prfOutput: localStorage.getItem("vow_prf_output"), 727 751 }); 728 752 } 729 753
+14
server/webauthn.go
··· 10 10 "fmt" 11 11 "math/big" 12 12 13 + "github.com/bluesky-social/indigo/atproto/atcrypto" 14 + "golang.org/x/crypto/hkdf" 13 15 "github.com/fxamacker/cbor/v2" 14 16 ) 15 17 ··· 334 336 335 337 return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil 336 338 } 339 + 340 + // deriveSigningKey deterministically derives a P-256 private key from the 341 + // WebAuthn PRF output using HKDF-SHA256. 342 + func deriveSigningKey(prfOutput []byte) (*atcrypto.PrivateKeyP256, error) { 343 + kdf := hkdf.New(sha256.New, prfOutput, nil, []byte("Vow PDS Commit Signing Key Derivation")) 344 + keyBytes := make([]byte, 32) 345 + if _, err := kdf.Read(keyBytes); err != nil { 346 + return nil, fmt.Errorf("hkdf read: %w", err) 347 + } 348 + 349 + return atcrypto.ParsePrivateBytesP256(keyBytes) 350 + }