~ 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).
```