···20 AccountDeleteCode *string
21 AccountDeleteCodeExpiresAt *time.Time
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
026 // CredentialID is the WebAuthn credential ID returned by the authenticator
27 // during registration. It is stored so the server can build the
28 // allowCredentials list when requesting an assertion from the passkey.
···20 AccountDeleteCode *string
21 AccountDeleteCodeExpiresAt *time.Time
22 Password string
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
27 // CredentialID is the WebAuthn credential ID returned by the authenticator
28 // during registration. It is stored so the server can build the
29 // allowCredentials list when requesting an assertion from the passkey.
···1617 repo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo)
1819- if len(repo.PublicKey) == 0 {
20 helpers.InputError(w, new("no signing key registered for this account"))
21 return
22 }
2324- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
25 if err != nil {
26 logger.Error("error parsing stored public key", "error", err)
27 helpers.ServerError(w, nil)
···1617 repo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo)
1819+ if len(repo.SigningPublicKey) == 0 {
20 helpers.InputError(w, new("no signing key registered for this account"))
21 return
22 }
2324+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey)
25 if err != nil {
26 logger.Error("error parsing stored public key", "error", err)
27 helpers.ServerError(w, nil)
+2-2
server/handle_identity_submit_plc_operation.go
···57 // 4. The operation was signed by one of the current rotation keys (enforced
58 // by plc.directory on submission, not re-checked here).
5960- if len(repo.PublicKey) == 0 {
61 helpers.InputError(w, new("no signing key registered for this account"))
62 return
63 }
6465- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
66 if err != nil {
67 logger.Error("error parsing stored public key", "error", err)
68 helpers.ServerError(w, nil)
···57 // 4. The operation was signed by one of the current rotation keys (enforced
58 // by plc.directory on submission, not re-checked here).
5960+ if len(repo.SigningPublicKey) == 0 {
61 helpers.InputError(w, new("no signing key registered for this account"))
62 return
63 }
6465+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey)
66 if err != nil {
67 logger.Error("error parsing stored public key", "error", err)
68 helpers.ServerError(w, nil)
+1-1
server/handle_identity_update_handle.go
···7475 // If no passkey is registered yet, PDS signs. Otherwise, the user's
76 // passkey signs (it is the rotation key in Vow's model).
77- if len(repo.PublicKey) == 0 {
78 // PDS still holds authority — sign directly.
79 if err := s.plcClient.SignOp(&op); err != nil {
80 logger.Error("error signing PLC operation with rotation key", "error", err)
···7475 // If no passkey is registered yet, PDS signs. Otherwise, the user's
76 // passkey signs (it is the rotation key in Vow's model).
77+ if len(repo.SigningPublicKey) == 0 {
78 // PDS still holds authority — sign directly.
79 if err := s.plcClient.SignOp(&op); err != nil {
80 logger.Error("error signing PLC operation with rotation key", "error", err)
+1-1
server/handle_passkey_assertion_challenge.go
···39 return
40 }
4142- if len(repo.PublicKey) == 0 {
43 s.writeJSON(w, http.StatusBadRequest, map[string]string{
44 "error": "NoSigningKey",
45 "message": "No passkey is registered for this account.",
···39 return
40 }
4142+ if len(repo.AuthPublicKey) == 0 {
43 s.writeJSON(w, http.StatusBadRequest, map[string]string{
44 "error": "NoSigningKey",
45 "message": "No passkey is registered for this account.",
+2-2
server/handle_server_delete_account.go
···176177 // The account must have a registered passkey; without it there is nothing
178 // to verify against.
179- if len(repo.PublicKey) == 0 {
180 s.writeJSON(w, http.StatusBadRequest, map[string]any{
181 "error": "NoSigningKey",
182 "message": "No passkey is registered for this account. Please register a passkey first.",
···226 // verifyAssertion checks the challenge, rpIdHash, UP flag, and P-256
227 // signature. It also returns the raw (r‖s) bytes, which we discard here.
228 if _, err := verifyAssertion(
229- repo.PublicKey,
230 sum[:],
231 clientDataJSONBytes,
232 authenticatorDataBytes,
···176177 // The account must have a registered passkey; without it there is nothing
178 // to verify against.
179+ if len(repo.AuthPublicKey) == 0 {
180 s.writeJSON(w, http.StatusBadRequest, map[string]any{
181 "error": "NoSigningKey",
182 "message": "No passkey is registered for this account. Please register a passkey first.",
···226 // verifyAssertion checks the challenge, rpIdHash, UP flag, and P-256
227 // signature. It also returns the raw (r‖s) bytes, which we discard here.
228 if _, err := verifyAssertion(
229+ repo.AuthPublicKey,
230 sum[:],
231 clientDataJSONBytes,
232 authenticatorDataBytes,
+2-2
server/handle_server_get_signing_key.go
···39 return
40 }
4142- if len(repo.PublicKey) == 0 {
43 helpers.InputError(w, new("no signing key registered for this account"))
44 return
45 }
4647- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
48 if err != nil {
49 logger.Error("error parsing stored public key", "error", err)
50 helpers.ServerError(w, nil)
···39 return
40 }
4142+ if len(repo.SigningPublicKey) == 0 {
43 helpers.InputError(w, new("no signing key registered for this account"))
44 return
45 }
4647+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey)
48 if err != nil {
49 logger.Error("error parsing stored public key", "error", err)
50 helpers.ServerError(w, nil)
+26-4
server/handle_server_supply_signing_key.go
···29 // AttestationObject is the base64url-encoded attestationObject CBOR from
30 // the AuthenticatorAttestationResponse.
31 AttestationObject string `json:"attestationObject" validate:"required"`
0032}
3334type SupplySigningKeyResponse struct {
···95 }
9697 // Validate the compressed key is a well-formed P-256 point.
98- pubKey, err := atcrypto.ParsePublicBytesP256(keyBytes)
99 if err != nil {
100 logger.Error("compressed P-256 key rejected by atcrypto", "error", err)
101 helpers.InputError(w, new("invalid P-256 public key in attestation"))
102 return
103 }
10400000000000000000000105 pubDIDKey := pubKey.DIDKey()
106107 // Derive the PDS server did:key for the atproto_service slot.
···151152 // If no passkey is registered yet, PDS signs (initial registration).
153 // Otherwise, the existing passkey signs (passkey rotation).
154- if len(repo.PublicKey) == 0 {
155 // PDS still holds authority — sign directly. This is the last
156 // PLC operation the PDS will ever be able to sign on behalf of the
157 // user. It is voluntarily handing over control to the passkey.
···210211 // Persist the compressed P-256 public key and credential ID.
212 if err := s.db.Exec(ctx,
213- "UPDATE repos SET public_key = ?, credential_id = ? WHERE did = ?",
214- nil, keyBytes, credentialID, repo.Repo.Did,
215 ).Error; err != nil {
216 logger.Error("error updating public key and credential ID in db", "error", err)
217 helpers.ServerError(w, nil)
···29 // AttestationObject is the base64url-encoded attestationObject CBOR from
30 // the AuthenticatorAttestationResponse.
31 AttestationObject string `json:"attestationObject" validate:"required"`
32+ // PrfOutput is the base64url-encoded PRF output.
33+ PrfOutput string `json:"prfOutput" validate:"required"`
34}
3536type SupplySigningKeyResponse struct {
···97 }
9899 // Validate the compressed key is a well-formed P-256 point.
100+ _, err = atcrypto.ParsePublicBytesP256(keyBytes)
101 if err != nil {
102 logger.Error("compressed P-256 key rejected by atcrypto", "error", err)
103 helpers.InputError(w, new("invalid P-256 public key in attestation"))
104 return
105 }
106107+ 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+127 pubDIDKey := pubKey.DIDKey()
128129 // Derive the PDS server did:key for the atproto_service slot.
···173174 // If no passkey is registered yet, PDS signs (initial registration).
175 // Otherwise, the existing passkey signs (passkey rotation).
176+ if len(repo.SigningPublicKey) == 0 {
177 // PDS still holds authority — sign directly. This is the last
178 // PLC operation the PDS will ever be able to sign on behalf of the
179 // user. It is voluntarily handing over control to the passkey.
···232233 // Persist the compressed P-256 public key and credential ID.
234 if err := s.db.Exec(ctx,
235+ "UPDATE repos SET auth_public_key = ?, signing_public_key = ?, credential_id = ? WHERE did = ?",
236+ nil, keyBytes, pubKey.Bytes(), credentialID, repo.Repo.Did,
237 ).Error; err != nil {
238 logger.Error("error updating public key and credential ID in db", "error", err)
239 helpers.ServerError(w, nil)
+41-5
server/handle_signer_connect.go
···1package server
23import (
04 "encoding/base64"
5 "encoding/json"
06 "log/slog"
7 "net/http"
8 "strings"
···53 AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url
54 ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url
55 Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA
056}
5758// handleSignerConnect upgrades the connection to a WebSocket and registers it
···8889 // Ensure the account actually has a public key registered before accepting
90 // a signer connection; without it no signature can ever be verified.
91- if len(repo.PublicKey) == 0 {
92 helpers.InputError(w, new("no signing key registered for this account"))
93 return
94 }
···222 }
223 delete(pendingPayloads, in.RequestID)
224225- rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger)
226 if err != nil {
227 logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err)
228 continue
···317// sign_request (the raw CBOR bytes of the unsigned commit, or the SHA-256 of
318// the JWT signing input for service-auth tokens).
319func verifyWebAuthnSignResponse(
320- pubKey []byte,
0321 payloadB64 string,
322 in wsIncoming,
323 rpID string,
···327 return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs
328 }
3290000330 // The challenge passed to navigator.credentials.get() was the raw bytes
331 // decoded from payloadB64. The browser re-encodes them as base64url in
332 // clientDataJSON.challenge — so the expected challenge is exactly those
···351 return nil, err
352 }
353354- rawSig, err := verifyAssertion(pubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID)
355 if err != nil {
356 logger.With("rpID", rpID).Debug("verifyAssertion detail", "error", err)
357 return nil, err
358 }
359360- return rawSig, nil
0000000000000000000000000000361}
362363// isTokenExpired returns true if the JWT's exp claim is in the past.
···1package server
23import (
4+ "bytes"
5 "encoding/base64"
6 "encoding/json"
7+ "fmt"
8 "log/slog"
9 "net/http"
10 "strings"
···55 AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url
56 ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url
57 Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA
58+ PrfOutput string `json:"prfOutput,omitempty"` // base64url
59}
6061// handleSignerConnect upgrades the connection to a WebSocket and registers it
···9192 // Ensure the account actually has a public key registered before accepting
93 // a signer connection; without it no signature can ever be verified.
94+ if len(repo.AuthPublicKey) == 0 {
95 helpers.InputError(w, new("no signing key registered for this account"))
96 return
97 }
···225 }
226 delete(pendingPayloads, in.RequestID)
227228+ rawSig, err := verifyWebAuthnSignResponse(repo.AuthPublicKey, repo.SigningPublicKey, payload, in, s.config.Hostname, logger)
229 if err != nil {
230 logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err)
231 continue
···320// sign_request (the raw CBOR bytes of the unsigned commit, or the SHA-256 of
321// the JWT signing input for service-auth tokens).
322func verifyWebAuthnSignResponse(
323+ authPubKey []byte,
324+ signingPubKey []byte,
325 payloadB64 string,
326 in wsIncoming,
327 rpID string,
···331 return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs
332 }
333334+ if in.PrfOutput == "" {
335+ return nil, fmt.Errorf("missing prfOutput in sign_response")
336+ }
337+338 // The challenge passed to navigator.credentials.get() was the raw bytes
339 // decoded from payloadB64. The browser re-encodes them as base64url in
340 // clientDataJSON.challenge — so the expected challenge is exactly those
···359 return nil, err
360 }
361362+ _, err = verifyAssertion(authPubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID)
363 if err != nil {
364 logger.With("rpID", rpID).Debug("verifyAssertion detail", "error", err)
365 return nil, err
366 }
367368+ // 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
397}
398399// isTokenExpired returns true if the JWT's exp claim is in the past.
+2-2
server/repo.go
···595 }
596597 // ── Phase 4: verify the signature ─────────────────────────────────────
598- if len(urepo.PublicKey) == 0 {
599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did)
600 }
601602- pubKey, err := atcrypto.ParsePublicBytesP256(urepo.PublicKey)
603 if err != nil {
604 return nil, fmt.Errorf("parsing stored public key: %w", err)
605 }
···595 }
596597 // ── Phase 4: verify the signature ─────────────────────────────────────
598+ if len(urepo.SigningPublicKey) == 0 {
599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did)
600 }
601602+ pubKey, err := atcrypto.ParsePublicBytesP256(urepo.SigningPublicKey)
603 if err != nil {
604 return nil, fmt.Errorf("parsing stored public key: %w", err)
605 }
+25-1
server/templates/account.html
···313 options.challenge = base64urlToBytes(options.challenge);
314 options.user.id = base64urlToBytes(options.user.id);
31500000000000000316 // 2. Create the passkey.
317 btn.textContent = "Confirm with your passkey…";
318 const credential = await navigator.credentials.create({
···323 throw new Error("Passkey creation was cancelled.");
324 }
325326- // 3. Send the attestation response to the server.
00000000327 btn.textContent = "Registering…";
328 const res = await fetch("/account/supply-signing-key", {
329 method: "POST",
···339 credential.response.attestationObject,
340 ),
341 ),
0342 }),
343 });
344···724 signature: bytesToBase64url(
725 new Uint8Array(assertionResponse.signature),
726 ),
0727 });
728 }
729
···313 options.challenge = base64urlToBytes(options.challenge);
314 options.user.id = base64urlToBytes(options.user.id);
315316+ // 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+330 // 2. Create the passkey.
331 btn.textContent = "Confirm with your passkey…";
332 const credential = await navigator.credentials.create({
···337 throw new Error("Passkey creation was cancelled.");
338 }
339340+ // 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.
349 btn.textContent = "Registering…";
350 const res = await fetch("/account/supply-signing-key", {
351 method: "POST",
···361 credential.response.attestationObject,
362 ),
363 ),
364+ prfOutput: bytesToBase64url(prfOutput),
365 }),
366 });
367···747 signature: bytesToBase64url(
748 new Uint8Array(assertionResponse.signature),
749 ),
750+ prfOutput: localStorage.getItem("vow_prf_output"),
751 });
752 }
753