# 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, // 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). ```