···2020 AccountDeleteCode *string
2121 AccountDeleteCodeExpiresAt *time.Time
2222 Password string
2323- // PublicKey holds the compressed P-256 (secp256r1) public key bytes for
2424- // the account. This is the only key material the PDS retains.
2525- PublicKey []byte
2323+ // AuthPublicKey holds the compressed P-256 public key bytes for WebAuthn assertion verification.
2424+ AuthPublicKey []byte
2525+ // SigningPublicKey holds the compressed P-256 public key bytes for commit signature verification.
2626+ SigningPublicKey []byte
2627 // CredentialID is the WebAuthn credential ID returned by the authenticator
2728 // during registration. It is stored so the server can build the
2829 // allowCredentials list when requesting an assertion from the passkey.
···16161717 repo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo)
18181919- if len(repo.PublicKey) == 0 {
1919+ if len(repo.SigningPublicKey) == 0 {
2020 helpers.InputError(w, new("no signing key registered for this account"))
2121 return
2222 }
23232424- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
2424+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey)
2525 if err != nil {
2626 logger.Error("error parsing stored public key", "error", err)
2727 helpers.ServerError(w, nil)
+2-2
server/handle_identity_submit_plc_operation.go
···5757 // 4. The operation was signed by one of the current rotation keys (enforced
5858 // by plc.directory on submission, not re-checked here).
59596060- if len(repo.PublicKey) == 0 {
6060+ if len(repo.SigningPublicKey) == 0 {
6161 helpers.InputError(w, new("no signing key registered for this account"))
6262 return
6363 }
64646565- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
6565+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey)
6666 if err != nil {
6767 logger.Error("error parsing stored public key", "error", err)
6868 helpers.ServerError(w, nil)
+1-1
server/handle_identity_update_handle.go
···74747575 // If no passkey is registered yet, PDS signs. Otherwise, the user's
7676 // passkey signs (it is the rotation key in Vow's model).
7777- if len(repo.PublicKey) == 0 {
7777+ if len(repo.SigningPublicKey) == 0 {
7878 // PDS still holds authority — sign directly.
7979 if err := s.plcClient.SignOp(&op); err != nil {
8080 logger.Error("error signing PLC operation with rotation key", "error", err)
+1-1
server/handle_passkey_assertion_challenge.go
···3939 return
4040 }
41414242- if len(repo.PublicKey) == 0 {
4242+ if len(repo.AuthPublicKey) == 0 {
4343 s.writeJSON(w, http.StatusBadRequest, map[string]string{
4444 "error": "NoSigningKey",
4545 "message": "No passkey is registered for this account.",
+2-2
server/handle_server_delete_account.go
···176176177177 // The account must have a registered passkey; without it there is nothing
178178 // to verify against.
179179- if len(repo.PublicKey) == 0 {
179179+ if len(repo.AuthPublicKey) == 0 {
180180 s.writeJSON(w, http.StatusBadRequest, map[string]any{
181181 "error": "NoSigningKey",
182182 "message": "No passkey is registered for this account. Please register a passkey first.",
···226226 // verifyAssertion checks the challenge, rpIdHash, UP flag, and P-256
227227 // signature. It also returns the raw (r‖s) bytes, which we discard here.
228228 if _, err := verifyAssertion(
229229- repo.PublicKey,
229229+ repo.AuthPublicKey,
230230 sum[:],
231231 clientDataJSONBytes,
232232 authenticatorDataBytes,
+2-2
server/handle_server_get_signing_key.go
···3939 return
4040 }
41414242- if len(repo.PublicKey) == 0 {
4242+ if len(repo.SigningPublicKey) == 0 {
4343 helpers.InputError(w, new("no signing key registered for this account"))
4444 return
4545 }
46464747- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
4747+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey)
4848 if err != nil {
4949 logger.Error("error parsing stored public key", "error", err)
5050 helpers.ServerError(w, nil)
+26-4
server/handle_server_supply_signing_key.go
···2929 // AttestationObject is the base64url-encoded attestationObject CBOR from
3030 // the AuthenticatorAttestationResponse.
3131 AttestationObject string `json:"attestationObject" validate:"required"`
3232+ // PrfOutput is the base64url-encoded PRF output.
3333+ PrfOutput string `json:"prfOutput" validate:"required"`
3234}
33353436type SupplySigningKeyResponse struct {
···9597 }
96989799 // Validate the compressed key is a well-formed P-256 point.
9898- pubKey, err := atcrypto.ParsePublicBytesP256(keyBytes)
100100+ _, err = atcrypto.ParsePublicBytesP256(keyBytes)
99101 if err != nil {
100102 logger.Error("compressed P-256 key rejected by atcrypto", "error", err)
101103 helpers.InputError(w, new("invalid P-256 public key in attestation"))
102104 return
103105 }
104106107107+ prfOutput, err := base64.RawURLEncoding.DecodeString(req.PrfOutput)
108108+ if err != nil {
109109+ logger.Error("error decoding prf output", "error", err)
110110+ helpers.InputError(w, new("invalid prf output encoding"))
111111+ return
112112+ }
113113+114114+ privKey, err := deriveSigningKey(prfOutput)
115115+ if err != nil {
116116+ logger.Error("failed to derive signing key", "error", err)
117117+ helpers.ServerError(w, nil)
118118+ return
119119+ }
120120+ pubKey, err := privKey.PublicKey()
121121+ if err != nil {
122122+ logger.Error("failed to get public key from private key", "error", err)
123123+ helpers.ServerError(w, nil)
124124+ return
125125+ }
126126+105127 pubDIDKey := pubKey.DIDKey()
106128107129 // Derive the PDS server did:key for the atproto_service slot.
···151173152174 // If no passkey is registered yet, PDS signs (initial registration).
153175 // Otherwise, the existing passkey signs (passkey rotation).
154154- if len(repo.PublicKey) == 0 {
176176+ if len(repo.SigningPublicKey) == 0 {
155177 // PDS still holds authority — sign directly. This is the last
156178 // PLC operation the PDS will ever be able to sign on behalf of the
157179 // user. It is voluntarily handing over control to the passkey.
···210232211233 // Persist the compressed P-256 public key and credential ID.
212234 if err := s.db.Exec(ctx,
213213- "UPDATE repos SET public_key = ?, credential_id = ? WHERE did = ?",
214214- nil, keyBytes, credentialID, repo.Repo.Did,
235235+ "UPDATE repos SET auth_public_key = ?, signing_public_key = ?, credential_id = ? WHERE did = ?",
236236+ nil, keyBytes, pubKey.Bytes(), credentialID, repo.Repo.Did,
215237 ).Error; err != nil {
216238 logger.Error("error updating public key and credential ID in db", "error", err)
217239 helpers.ServerError(w, nil)
+41-5
server/handle_signer_connect.go
···11package server
2233import (
44+ "bytes"
45 "encoding/base64"
56 "encoding/json"
77+ "fmt"
68 "log/slog"
79 "net/http"
810 "strings"
···5355 AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url
5456 ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url
5557 Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA
5858+ PrfOutput string `json:"prfOutput,omitempty"` // base64url
5659}
57605861// handleSignerConnect upgrades the connection to a WebSocket and registers it
···88918992 // Ensure the account actually has a public key registered before accepting
9093 // a signer connection; without it no signature can ever be verified.
9191- if len(repo.PublicKey) == 0 {
9494+ if len(repo.AuthPublicKey) == 0 {
9295 helpers.InputError(w, new("no signing key registered for this account"))
9396 return
9497 }
···222225 }
223226 delete(pendingPayloads, in.RequestID)
224227225225- rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger)
228228+ rawSig, err := verifyWebAuthnSignResponse(repo.AuthPublicKey, repo.SigningPublicKey, payload, in, s.config.Hostname, logger)
226229 if err != nil {
227230 logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err)
228231 continue
···317320// sign_request (the raw CBOR bytes of the unsigned commit, or the SHA-256 of
318321// the JWT signing input for service-auth tokens).
319322func verifyWebAuthnSignResponse(
320320- pubKey []byte,
323323+ authPubKey []byte,
324324+ signingPubKey []byte,
321325 payloadB64 string,
322326 in wsIncoming,
323327 rpID string,
···327331 return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs
328332 }
329333334334+ if in.PrfOutput == "" {
335335+ return nil, fmt.Errorf("missing prfOutput in sign_response")
336336+ }
337337+330338 // The challenge passed to navigator.credentials.get() was the raw bytes
331339 // decoded from payloadB64. The browser re-encodes them as base64url in
332340 // clientDataJSON.challenge — so the expected challenge is exactly those
···351359 return nil, err
352360 }
353361354354- rawSig, err := verifyAssertion(pubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID)
362362+ _, err = verifyAssertion(authPubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID)
355363 if err != nil {
356364 logger.With("rpID", rpID).Debug("verifyAssertion detail", "error", err)
357365 return nil, err
358366 }
359367360360- return rawSig, nil
368368+ // Now derive the signing key from PRF output.
369369+ prfOutput, err := base64.RawURLEncoding.DecodeString(in.PrfOutput)
370370+ if err != nil {
371371+ return nil, fmt.Errorf("invalid prfOutput encoding: %w", err)
372372+ }
373373+374374+ privKey, err := deriveSigningKey(prfOutput)
375375+ if err != nil {
376376+ return nil, fmt.Errorf("failed to derive signing key: %w", err)
377377+ }
378378+379379+ pubKey, err := privKey.PublicKey()
380380+ if err != nil {
381381+ return nil, fmt.Errorf("failed to get public key from private key: %w", err)
382382+ }
383383+384384+ // Verify that the derived signing key matches the registered one.
385385+ if !bytes.Equal(pubKey.Bytes(), signingPubKey) {
386386+ return nil, fmt.Errorf("derived signing key does not match registered signing key")
387387+ }
388388+389389+ // Sign the payload with the derived key.
390390+ // HashAndSign hashes the data using SHA-256 and produces a low-S signature.
391391+ commitSig, err := privKey.HashAndSign(expectedChallenge)
392392+ if err != nil {
393393+ return nil, fmt.Errorf("failed to sign payload: %w", err)
394394+ }
395395+396396+ return commitSig, nil
361397}
362398363399// isTokenExpired returns true if the JWT's exp claim is in the past.
+2-2
server/repo.go
···595595 }
596596597597 // ── Phase 4: verify the signature ─────────────────────────────────────
598598- if len(urepo.PublicKey) == 0 {
598598+ if len(urepo.SigningPublicKey) == 0 {
599599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did)
600600 }
601601602602- pubKey, err := atcrypto.ParsePublicBytesP256(urepo.PublicKey)
602602+ pubKey, err := atcrypto.ParsePublicBytesP256(urepo.SigningPublicKey)
603603 if err != nil {
604604 return nil, fmt.Errorf("parsing stored public key: %w", err)
605605 }
+25-1
server/templates/account.html
···313313 options.challenge = base64urlToBytes(options.challenge);
314314 options.user.id = base64urlToBytes(options.user.id);
315315316316+ // Add PRF extension to create the signing key
317317+ // We use a fixed salt so the derived key is always the same.
318318+ const prfSalt = new Uint8Array(
319319+ await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode("Vow PDS Commit Signing Key Derivation"))
320320+ );
321321+322322+ options.extensions = {
323323+ prf: {
324324+ eval: {
325325+ first: prfSalt
326326+ }
327327+ }
328328+ };
329329+316330 // 2. Create the passkey.
317331 btn.textContent = "Confirm with your passkey…";
318332 const credential = await navigator.credentials.create({
···323337 throw new Error("Passkey creation was cancelled.");
324338 }
325339326326- // 3. Send the attestation response to the server.
340340+ // 3. Extract PRF output
341341+ const extResults = credential.getClientExtensionResults();
342342+ if (!extResults.prf || !extResults.prf.results || !extResults.prf.results.first) {
343343+ throw new Error("Passkey doesn't support PRF extension. Use a modern browser (e.g. Chrome 126+, Safari 18+).");
344344+ }
345345+ const prfOutput = new Uint8Array(extResults.prf.results.first);
346346+ localStorage.setItem("vow_prf_output", bytesToBase64url(prfOutput));
347347+348348+ // 4. Send the attestation response to the server.
327349 btn.textContent = "Registering…";
328350 const res = await fetch("/account/supply-signing-key", {
329351 method: "POST",
···339361 credential.response.attestationObject,
340362 ),
341363 ),
364364+ prfOutput: bytesToBase64url(prfOutput),
342365 }),
343366 });
344367···724747 signature: bytesToBase64url(
725748 new Uint8Array(assertionResponse.signature),
726749 ),
750750+ prfOutput: localStorage.getItem("vow_prf_output"),
727751 });
728752 }
729753