~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
183 lines · 9.7 KB Raw History

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#verifyJwt has a force-refresh retry for exactly this ("key rotated") case — but only when signature verification returns false.
  • crypto/verify.ts#verifySignature throws (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 → verifyJwt catches it and raises BadJwtSignature before reaching the force-refresh retry. The token is rejected until the service's identity cache goes stale on its own (@atproto/identity MemoryCache defaults: 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)

  1. packages/core cannot import @noble/curves. P-256 signing/verifying uses crypto.subtle natively. secp256k1 verification is injected from the platform packages via createVerifier({ secp256k1 }) (see core/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.

  2. crypto.subtle has no secp256k1. All secp256k1 work goes through @noble/curves in the platform layer. It IS already a dependency there (used for verification), and was just added to account-ui for the recovery-key feature.

  3. Existing accounts are P-256 and must not break. pds.js is one-account-per-instance; a live instance never calls generateKeyPair again, so it stays P-256. The code must remain dual-curve and default any untagged/legacy stored key to P-256.

  4. 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 via getPublicKeyJwk.

    Switching the account key to secp256k1 would make OAuth access tokens ES256K and the JWKS advertise a secp256k1 key. That's non-standard for OAuth (P-256/ES256 is expected) and crypto.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, not getSigningKey().
  • The JWKS / getPublicKeyJwk path advertises the OAuth key under kid: 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 (in core/src/ports.js), injected into PersonalDataServer alongside verifier:

    {
      generate(): Promise<{ privateKey: Uint8Array, publicKey: Uint8Array }>, // secp256k1
      sign(privateKey: Uint8Array, data: Uint8Array): Promise<Uint8Array>,    // low-S r||s
    }
    

    Implemented in cloudflare/node/deno with @noble/curves (like secp256k1Verify). 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 small decodeStoredKey/encodeStoredKey pair in crypto.js keeps the actorStorage port unchanged. All getPrivateKey/setPrivateKey callers go through it.

  • Curve-aware signing. Replace the cached WebCrypto _signingKey with a small signer object { curve, sign(data) }:

    • p256 → existing crypto.subtle path.
    • secp256k1 → injected signer.sign(scalar, data). Update the three signing sites: repo commit (pds.js:~7084), service JWT (auth.js#createServiceJwt — header alg ES256→ES256K for secp256k1), genesis/updates PLC op signing.
  • Curve-aware public-key derivation. derivePublicKey and publicKeyToDidKey must produce the secp256k1 did:key (zQ3sh…, multicodec 0xe7 0x01) for secp256k1 keys. getRecommendedDidCredentials then advertises the correct verificationMethods.atproto.

  • Key generation at account creation. handleOnboardingCreate and the migration createAccount path call the injected signer.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, ES256K service-JWT headers, and a zQ3sh… verificationMethods.atproto; a legacy 32-byte stored key still resolves as P-256 and signs ES256.
  • Stored-key encoding: decodeStoredKey(encodeStoredKey(...)) round-trips; a bare 32-byte value decodes as P-256.
  • OAuth regression (Step 1): access tokens stay ES256 and 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 at cloudflare/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 (currently P256_MULTICODEC); secp256k1 multicodec [0xe7, 0x01] already in verify.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).