Vow, uncensorable PDS written in Go

fix: derive on client

+70 -40
+9 -14
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 + // SigningPublicKey is the base64url-encoded compressed P-256 public key 33 + // derived via the PRF extension. 34 + SigningPublicKey string `json:"signingPublicKey" validate:"required"` 34 35 } 35 36 36 37 type SupplySigningKeyResponse struct { ··· 104 105 return 105 106 } 106 107 107 - prfOutput, err := base64.RawURLEncoding.DecodeString(req.PrfOutput) 108 + signingKeyBytes, err := base64.RawURLEncoding.DecodeString(req.SigningPublicKey) 108 109 if err != nil { 109 - logger.Error("error decoding prf output", "error", err) 110 - helpers.InputError(w, new("invalid prf output encoding")) 110 + logger.Error("error decoding signing public key", "error", err) 111 + helpers.InputError(w, new("invalid signing public key encoding")) 111 112 return 112 113 } 113 114 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() 115 + pubKey, err := atcrypto.ParsePublicBytesP256(signingKeyBytes) 121 116 if err != nil { 122 - logger.Error("failed to get public key from private key", "error", err) 123 - helpers.ServerError(w, nil) 117 + logger.Error("derived signing key rejected by atcrypto", "error", err) 118 + helpers.InputError(w, new("invalid derived P-256 public key")) 124 119 return 125 120 } 126 121
+13 -22
server/handle_signer_connect.go
··· 1 1 package server 2 2 3 3 import ( 4 - "bytes" 5 4 "encoding/base64" 6 5 "encoding/json" 7 6 "fmt" ··· 10 9 "strings" 11 10 "time" 12 11 12 + "github.com/bluesky-social/indigo/atproto/atcrypto" 13 13 "github.com/gorilla/websocket" 14 14 "pkg.rbrt.fr/vow/internal/helpers" 15 15 "pkg.rbrt.fr/vow/models" ··· 55 55 AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url 56 56 ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url 57 57 Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA 58 - PrfOutput string `json:"prfOutput,omitempty"` // base64url 58 + CommitSignature string `json:"commitSignature,omitempty"` // base64url 64-byte raw r||s signature 59 59 } 60 60 61 61 // handleSignerConnect upgrades the connection to a WebSocket and registers it ··· 331 331 return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs 332 332 } 333 333 334 - if in.PrfOutput == "" { 335 - return nil, fmt.Errorf("missing prfOutput in sign_response") 334 + if in.CommitSignature == "" { 335 + return nil, fmt.Errorf("missing commitSignature in sign_response") 336 336 } 337 337 338 338 // The challenge passed to navigator.credentials.get() was the raw bytes ··· 365 365 return nil, err 366 366 } 367 367 368 - // Now derive the signing key from PRF output. 369 - prfOutput, err := base64.RawURLEncoding.DecodeString(in.PrfOutput) 368 + commitSig, err := base64.RawURLEncoding.DecodeString(in.CommitSignature) 370 369 if err != nil { 371 - return nil, fmt.Errorf("invalid prfOutput encoding: %w", err) 370 + return nil, fmt.Errorf("invalid commitSignature encoding: %w", err) 372 371 } 373 372 374 - privKey, err := deriveSigningKey(prfOutput) 375 - if err != nil { 376 - return nil, fmt.Errorf("failed to derive signing key: %w", err) 373 + if len(commitSig) != 64 { 374 + return nil, fmt.Errorf("invalid commitSignature length: got %d, want 64", len(commitSig)) 377 375 } 378 376 379 - pubKey, err := privKey.PublicKey() 377 + // Verify the commit signature against the registered signing key. 378 + pubKey, err := atcrypto.ParsePublicBytesP256(signingPubKey) 380 379 if err != nil { 381 - return nil, fmt.Errorf("failed to get public key from private key: %w", err) 380 + return nil, fmt.Errorf("failed to parse registered signing key: %w", err) 382 381 } 383 382 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) 383 + if err := pubKey.HashAndVerifyLenient(expectedChallenge, commitSig); err != nil { 384 + return nil, fmt.Errorf("commit signature verification failed: %w", err) 394 385 } 395 386 396 387 return commitSig, nil
+48 -4
server/templates/account.html
··· 337 337 throw new Error("Passkey creation was cancelled."); 338 338 } 339 339 340 - // 3. Extract PRF output 340 + // 3. Extract PRF output and derive signing key 341 341 const extResults = credential.getClientExtensionResults(); 342 342 if (!extResults.prf || !extResults.prf.results || !extResults.prf.results.first) { 343 343 throw new Error("Passkey doesn't support PRF extension. Use a modern browser (e.g. Chrome 126+, Safari 18+)."); 344 344 } 345 345 const prfOutput = new Uint8Array(extResults.prf.results.first); 346 346 localStorage.setItem("vow_prf_output", bytesToBase64url(prfOutput)); 347 + 348 + const signingKey = await deriveSigningKey(prfOutput); 349 + const rawPubKey = new Uint8Array(await window.crypto.subtle.exportKey("raw", signingKey.publicKey)); 350 + const compressedPubKey = compressRawPublicKey(rawPubKey); 347 351 348 352 // 4. Send the attestation response to the server. 349 353 btn.textContent = "Registering…"; ··· 361 365 credential.response.attestationObject, 362 366 ), 363 367 ), 364 - prfOutput: bytesToBase64url(prfOutput), 368 + signingPublicKey: bytesToBase64url(compressedPubKey), 365 369 }), 366 370 }); 367 371 ··· 713 717 throw new Error("Passkey assertion was cancelled."); 714 718 } 715 719 720 + // Derive signing key from stored PRF output 721 + const prfOutputB64 = localStorage.getItem("vow_prf_output"); 722 + if (!prfOutputB64) { 723 + throw new Error("Signing key not found in storage. Please re-register your passkey."); 724 + } 725 + const prfOutput = base64urlToBytes(prfOutputB64); 726 + const signingKey = await deriveSigningKey(prfOutput); 727 + 728 + // Sign the commit (which is the challenge bytes) 729 + // Note: WebCrypto ECDSA sign does NOT normalize to low-S automatically, 730 + // ATProto requires low-S. 731 + const rawSig = new Uint8Array(await window.crypto.subtle.sign( 732 + { 733 + name: "ECDSA", 734 + hash: { name: "SHA-256" } 735 + }, 736 + signingKey.privateKey, 737 + challenge 738 + )); 739 + 740 + // Convert P-256 signature to low-S as required by ATProto 741 + const S_BYTES = rawSig.slice(32, 64); 742 + const S_INT = BigInt("0x" + Array.from(S_BYTES).map(b => b.toString(16).padStart(2, "0")).join("")); 743 + const N = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"); 744 + const HALF_N = N / 2n; 745 + 746 + let commitSignature = rawSig; 747 + if (S_INT > HALF_N) { 748 + const lowS = N - S_INT; 749 + const lowSHex = lowS.toString(16).padStart(64, "0"); 750 + const lowSBytes = new Uint8Array(32); 751 + for (let i = 0; i < 32; i++) { 752 + lowSBytes[i] = parseInt(lowSHex.slice(i * 2, i * 2 + 2), 16); 753 + } 754 + commitSignature = new Uint8Array(64); 755 + commitSignature.set(rawSig.slice(0, 32), 0); 756 + commitSignature.set(lowSBytes, 32); 757 + } 758 + 716 759 wsSend( 717 760 buildSignResponse( 718 761 requestId, 719 762 assertion.response, 763 + commitSignature 720 764 ), 721 765 ); 722 766 } catch (err) { ··· 732 776 // Protocol message builders 733 777 // --------------------------------------------------------------------------- 734 778 735 - function buildSignResponse(requestId, assertionResponse) { 779 + function buildSignResponse(requestId, assertionResponse, commitSignature) { 736 780 return JSON.stringify({ 737 781 type: "sign_response", 738 782 requestId: requestId, ··· 747 791 signature: bytesToBase64url( 748 792 new Uint8Array(assertionResponse.signature), 749 793 ), 750 - prfOutput: localStorage.getItem("vow_prf_output"), 794 + commitSignature: bytesToBase64url(commitSignature), 751 795 }); 752 796 } 753 797