chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
secp256k1 Account Signing Keys
For Claude: This is a design/plan doc, not yet implemented. Read the whole thing before starting; the OAuth entanglement in "Constraints" is the crux.
Goal: Give newly-created accounts a secp256k1 atproto signing key instead of P-256, so that migrating an existing account (almost always secp256k1) onto a fresh pds.js instance no longer changes the signing key's curve mid-migration. Existing P-256 accounts must keep working unchanged (dual-curve).
Status: Deferred. Written 2026-08-04 after a real migration surfaced the bug below. Not urgent — the symptom self-heals in ≤1 hour — but worth doing right.
Motivation: the bug this fixes
When chadtmiller.com migrated from the reference PDS (secp256k1 signing key) onto
pds.js (P-256 signing key), feeds and DMs failed with could not verify jwt signature for up to an hour after the move, even though pds.js was minting
correctly-signed service tokens.
Root cause, traced through ~/code/atproto:
- pds.js signs service-auth JWTs with
alg: ES256(P-256), because the account's atproto signing key is P-256. - A consuming service (AppView, chat) verifies by resolving the account's signing
key from its identity cache. Immediately post-migration that cache still holds
the old secp256k1 (
ES256K) key. xrpc-server/src/auth.ts#verifyJwthas a force-refresh retry for exactly this ("key rotated") case — but only when signature verification returnsfalse.crypto/verify.ts#verifySignaturethrows (not returns false) when the token's alg doesn't match the cached key's alg:throw new Error('Expected key alg ES256, got ES256K').- So the alg mismatch throws →
verifyJwtcatches it and raisesBadJwtSignaturebefore reaching the force-refresh retry. The token is rejected until the service's identity cache goes stale on its own (@atproto/identityMemoryCachedefaults:staleTTL = 1 HOUR,maxTTL = 24 HOUR).
If the new signing key were secp256k1 like the old one, the cached (old) key and
the new token would share an alg, verification would return false (not throw), the
force-refresh would fire, and the service would re-resolve the new key immediately.
No outage. This is the entire reason to switch.
(The #identity firehose event we now emit on activate — commit e18ee28 — fixes
the AppView fast because its data-plane cache consumes that event. Services that
don't consume it, like chat, still wait out the TTL. secp256k1 fixes those too.)
Constraints (read before designing)
-
packages/corecannot import@noble/curves. P-256 signing/verifying usescrypto.subtlenatively. secp256k1 verification is injected from the platform packages viacreateVerifier({ secp256k1 })(seecore/src/verify.js,cloudflare/src/index.js:686,node/src/index.js:294,deno/src/index.js:143). secp256k1 signing + keygen must be injected the same way — core can't do it. -
crypto.subtlehas no secp256k1. All secp256k1 work goes through@noble/curvesin the platform layer. It IS already a dependency there (used for verification), and was just added toaccount-uifor the recovery-key feature. -
Existing accounts are P-256 and must not break. pds.js is one-account-per-instance; a live instance never calls
generateKeyPairagain, so it stays P-256. The code must remain dual-curve and default any untagged/legacy stored key to P-256. -
The account key is dual-purpose — this is the crux. The same key returned by
getSigningKey()signs BOTH:- the atproto artifacts (repo commits, service-auth JWTs, PLC ops), AND
- the OAuth authorization-server access tokens (
pds.js#createAccessToken,at+jwt,alg: ES256,kid: pds-oauth-key), advertised viagetPublicKeyJwk.
Switching the account key to secp256k1 would make OAuth access tokens
ES256Kand the JWKS advertise a secp256k1 key. That's non-standard for OAuth (P-256/ES256 is expected) andcrypto.subtle.exportKey('jwk', …)can't even export a secp256k1 JWK. So the OAuth AS key must be separated from the account key first.
Plan (two steps)
Step 1 — Separate the OAuth AS key from the account signing key
Give the OAuth authorization server its own dedicated P-256 / ES256 key, independent of the account's atproto key (this is how the reference PDS does it). After this step the account key is used ONLY for atproto artifacts, so its curve can change freely.
- Introduce a server-level OAuth signing key (generated once, stored in shared/server storage, not per-account). Keep it P-256/ES256.
createAccessToken(pds.js:~3746) signs with the OAuth key, notgetSigningKey().- The JWKS /
getPublicKeyJwkpath advertises the OAuth key underkid: pds-oauth-key. - Migration concern: existing deployments have OAuth tokens signed with the account key. Either (a) keep verifying both during a rollover window, or (b) accept that live OAuth sessions re-auth. For a single-tenant PDS this is low-impact; document the choice.
Files: packages/core/src/pds.js (createAccessToken, JWKS handler, getPublicKeyJwk),
storage port + platform storage impls if the OAuth key needs persistence, packages/core/test/*.
Step 2 — Dual-curve account signing key, secp256k1 for new accounts
Mirror the verifier injection with a signer injection.
-
New port
SignerPort(incore/src/ports.js), injected intoPersonalDataServeralongsideverifier:{ generate(): Promise<{ privateKey: Uint8Array, publicKey: Uint8Array }>, // secp256k1 sign(privateKey: Uint8Array, data: Uint8Array): Promise<Uint8Array>, // low-S r||s }Implemented in
cloudflare/node/denowith@noble/curves(likesecp256k1Verify). When no signer is injected (core-only), fall back to P-256 keygen so nothing regresses. -
Curve tagging on the stored key.
getPrivateKey()returns bare bytes today (32 for P-256; secp256k1 scalars are also 32 → indistinguishable). Make new keys self-describing: store[0xe7, ...scalar](33 bytes, secp256k1 multicodec low byte as tag) and treat a legacy 32-byte value as P-256. A smalldecodeStoredKey/encodeStoredKeypair incrypto.jskeeps theactorStorageport unchanged. AllgetPrivateKey/setPrivateKeycallers go through it. -
Curve-aware signing. Replace the cached WebCrypto
_signingKeywith a small signer object{ curve, sign(data) }:p256→ existingcrypto.subtlepath.secp256k1→ injectedsigner.sign(scalar, data). Update the three signing sites: repo commit (pds.js:~7084), service JWT (auth.js#createServiceJwt— header algES256→ES256Kfor secp256k1), genesis/updates PLC op signing.
-
Curve-aware public-key derivation.
derivePublicKeyandpublicKeyToDidKeymust produce the secp256k1did:key(zQ3sh…, multicodec0xe7 0x01) for secp256k1 keys.getRecommendedDidCredentialsthen advertises the correctverificationMethods.atproto. -
Key generation at account creation.
handleOnboardingCreateand the migrationcreateAccountpath call the injectedsigner.generate()(secp256k1) when available.
Files: packages/core/src/{crypto.js,auth.js,verify.js→signer wiring,ports.js,pds.js},
packages/{cloudflare,node,deno}/src/index.js, packages/core/test/*, test/*.
Test plan
- Dual-curve unit tests: sign+verify round-trip for both curves; a secp256k1 account
produces a
zQ3sh…did:key,ES256Kservice-JWT headers, and azQ3sh…verificationMethods.atproto; a legacy 32-byte stored key still resolves as P-256 and signsES256. - Stored-key encoding:
decodeStoredKey(encodeStoredKey(...))round-trips; a bare 32-byte value decodes as P-256. - OAuth regression (Step 1): access tokens stay
ES256and the JWKS advertises the dedicated OAuth key regardless of the account curve. - End-to-end migration: simulate a secp256k1 source → confirm the new account's signing
key is secp256k1 and a minted service token verifies (locally) against the new
did:key. - Full suite green; watch the pre-existing 5 live-AppView consent failures don't grow.
Risks & rollout
- Highest-risk surface is repo-commit signing — a bug corrupts the repo. Keep the P-256 path byte-for-byte unchanged; add secp256k1 as a parallel branch, never a rewrite.
- Only fresh instances benefit. Existing accounts (incl.
chadtmiller.com) keep their P-256 key; this change does not re-key them. To move a live account to secp256k1 would need a separate signing-key-rotation PLC op — out of scope here, and itself incurs the same ~1h cache wait during that transition (old P-256 cached vs new secp256k1 token). - Do Step 1 before Step 2. Shipping Step 2 without Step 1 makes OAuth tokens
ES256K, which is the outcome we're avoiding.
Pointers (as of 2026-08-04)
- Injection pattern to mirror:
core/src/verify.js#createVerifier, injected atcloudflare/src/index.js:686,node/src/index.js:294,deno/src/index.js:143. - Account signing key lifecycle:
pds.js#getSigningKey(~682),derivePublicKey(~410),getPublicKeyJwk(~694),crypto.js#{generateKeyPair,importPrivateKey,sign}. - Signing sites: repo commit (~7084), OAuth token (~3760, moves in Step 1), PLC op (~3772 is the OAuth one; genesis op signing is in the onboarding-create path).
- did:key encoding:
plc.js#publicKeyToDidKey(currentlyP256_MULTICODEC); secp256k1 multicodec[0xe7, 0x01]already inverify.js. - Upstream behavior confirming the fix:
~/code/atproto/packages/xrpc-server/src/auth.ts(verifyJwt throw-vs-false),packages/crypto/src/verify.ts(alg-mismatch throw),packages/identity/src/did/memory-cache.ts(staleTTL=1h, maxTTL=24h).