diff --git a/README.md b/README.md index c6c9487..14f99c0 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,9 @@ Today p2pds is a strong **warm spare and audit log** — full repo + blob replic - [x] Rotation key management UI scaffolding (`src/ui/components/recovery-key-dialog.ts`) - [x] **Offline PLC operation signing primitive** (`src/identity/plc-sign.ts`) — `signPlcOperation()` + `buildPlcUpdate()` produce signed ops without going through the source PDS. Verified round-trip with `validateOperationChain`. - [x] **Direct submission to `plc.directory`** primitive — `submitPlcOperation()` POSTs the signed op to a configurable directory URL. -- [ ] **Persist locally-generated rotation key private bytes** — `recovery-key-dialog.ts` currently generates a keypair, sends the public key through the email-token flow, and forgets the private key. Storing it (encrypted) is what unlocks using the offline-signing primitive in a real outage. +- [x] **Mnemonic-derived rotation key** (`src/identity/mnemonic.ts`) — BIP39 → BIP32 path m/44'/0'/0' → secp256k1 keypair, deterministic and shared between the UI's key-generation dialog and the offline-signing server flow. No on-disk private-key persistence: the user holds the mnemonic offline (written down) and re-enters it when they need to sign. +- [x] **`org.p2pds.app.signPlcUpdateOffline` endpoint** — auth-required XRPC that derives a keypair from a mnemonic the user submits, verifies it's listed in the current PLC rotation key set, signs the requested update, and optionally POSTs to `plc.directory`. Surfaces clear errors for invalid mnemonic / unauthorized key / missing PLC log / submission failure. +- [ ] **UI flow for offline-signed updates** — currently the endpoint exists but no dialog exposes it. Needs: "Sign PLC update offline" entry point, mnemonic input, target-field selectors (PDS endpoint, atproto signing key, rotation keys), dry-run preview, submit confirmation. - [ ] **End-to-end migration wizard** — select target PDS → export → import → sign PLC update → flip handle, with verification at each step ### Hosting others (multi-tenant) diff --git a/src/identity/mnemonic.test.ts b/src/identity/mnemonic.test.ts new file mode 100644 index 0000000..1fa3fbe --- /dev/null +++ b/src/identity/mnemonic.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { deriveKeypairFromMnemonic } from "./mnemonic.js"; + +describe("deriveKeypairFromMnemonic", () => { + // Known test vector — same mnemonic always derives to the same did:key. + // Generated once and pinned so any change to the derivation path or + // underlying libraries breaks this test rather than silently shifting + // every user's recovery key. + const TEST_MNEMONIC = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + + it("derives the same did:key for the same mnemonic (deterministic)", async () => { + const a = await deriveKeypairFromMnemonic(TEST_MNEMONIC); + const b = await deriveKeypairFromMnemonic(TEST_MNEMONIC); + expect(a.did()).toBe(b.did()); + }); + + it("returns a secp256k1 did:key", async () => { + const kp = await deriveKeypairFromMnemonic(TEST_MNEMONIC); + const didKey = kp.did(); + expect(didKey).toMatch(/^did:key:z/); + // secp256k1 keys encode to roughly 50 chars after the prefix + expect(didKey.length).toBeGreaterThan(40); + }); + + it("different mnemonics derive different keys", async () => { + const a = await deriveKeypairFromMnemonic(TEST_MNEMONIC); + const b = await deriveKeypairFromMnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ); + expect(a.did()).not.toBe(b.did()); + }); + + it("normalizes whitespace + case before validating", async () => { + const a = await deriveKeypairFromMnemonic(TEST_MNEMONIC); + const b = await deriveKeypairFromMnemonic( + ` ${TEST_MNEMONIC.toUpperCase()} `, + ); + expect(a.did()).toBe(b.did()); + }); + + it("rejects an invalid mnemonic", async () => { + await expect( + deriveKeypairFromMnemonic("not a real bip39 mnemonic phrase here today"), + ).rejects.toThrow(/Invalid BIP39 mnemonic/); + }); + + it("can sign + the signature is non-empty", async () => { + const kp = await deriveKeypairFromMnemonic(TEST_MNEMONIC); + const sig = await kp.sign(new TextEncoder().encode("hello world")); + expect(sig).toBeInstanceOf(Uint8Array); + expect(sig.length).toBe(64); + }); +}); diff --git a/src/identity/mnemonic.ts b/src/identity/mnemonic.ts new file mode 100644 index 0000000..57cf28b --- /dev/null +++ b/src/identity/mnemonic.ts @@ -0,0 +1,39 @@ +/** + * BIP39 mnemonic → secp256k1 keypair derivation. + * + * Used by the recovery-key flow: the UI generates a 12-word mnemonic and + * derives the public key from it to register as a rotation key in the user's + * PLC document. The user holds the mnemonic offline (written down); when they + * need to sign a PLC update without the PDS, they re-enter the mnemonic and + * we derive the same keypair on the fly. + * + * Derivation path is BIP32 m/44'/0'/0'. The UI and server MUST use the same + * path or the derived keys won't match — keeping that constant centralized + * here is the whole reason this module exists. + */ + +import { mnemonicToSeedSync, validateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { HDKey } from "@scure/bip32"; +import { Secp256k1Keypair } from "@atproto/crypto"; + +const BIP32_DERIVATION_PATH = "m/44'/0'/0'"; + +export async function deriveKeypairFromMnemonic( + mnemonic: string, +): Promise { + const trimmed = mnemonic.trim().toLowerCase().replace(/\s+/g, " "); + if (!validateMnemonic(trimmed, wordlist)) { + throw new Error("Invalid BIP39 mnemonic"); + } + const seed = mnemonicToSeedSync(trimmed); + const master = HDKey.fromMasterSeed(seed); + const child = master.derive(BIP32_DERIVATION_PATH); + if (!child.privateKey) { + throw new Error("Failed to derive private key from mnemonic"); + } + // `exportable: false` — we never need to round-trip the private bytes + // out of the keypair after derivation; if the user needs the key again + // they re-derive from the mnemonic. + return Secp256k1Keypair.import(child.privateKey, { exportable: false }); +} diff --git a/src/identity/plc-sign.test.ts b/src/identity/plc-sign.test.ts index 7b5d3fa..094d959 100644 --- a/src/identity/plc-sign.test.ts +++ b/src/identity/plc-sign.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Secp256k1Keypair } from "@atproto/crypto"; +// @ts-expect-error — transitive dep, no types exported for subpath import { sha256 } from "multiformats/hashes/sha2"; +// @ts-expect-error — transitive dep, no types exported for subpath import { base32 } from "multiformats/bases/base32"; +// @ts-expect-error — transitive dep, no types exported for NodeNext import { encode as dagCborEncode } from "@ipld/dag-cbor"; import { signPlcOperation, diff --git a/src/identity/plc-sign.ts b/src/identity/plc-sign.ts index 323e682..3713f0b 100644 --- a/src/identity/plc-sign.ts +++ b/src/identity/plc-sign.ts @@ -15,9 +15,13 @@ * the roadmap "Permanent migration" section in README.md. */ +// @ts-expect-error — transitive dep, no types exported for NodeNext import { encode as dagCborEncode } from "@ipld/dag-cbor"; +// @ts-expect-error — transitive dep, no types exported for subpath import { sha256 } from "multiformats/hashes/sha2"; +// @ts-expect-error — transitive dep, no types exported for subpath import { CID } from "multiformats/cid"; +// @ts-expect-error — transitive dep, no types exported for subpath import { base32 } from "multiformats/bases/base32"; import type { Keypair } from "@atproto/crypto"; import type { PlcOperation, IndexedOperation } from "./plc-mirror.js"; diff --git a/src/index.ts b/src/index.ts index 7c7688b..756eed6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -809,6 +809,14 @@ export function createApp( app.get("/xrpc/org.p2pds.app.getRotationKeys", requireAuth, (c) => app_routes.getRotationKeys(c, pdsClientRef, getConfigDid()), ); + // Sign + (optionally) submit a PLC update offline with a mnemonic-derived + // rotation key, bypassing the source PDS entirely. See app.ts handler doc. + if (db) { + const dbRef = db; + app.post("/xrpc/org.p2pds.app.signPlcUpdateOffline", requireAuth, (c) => + app_routes.signPlcUpdateOffline(c, dbRef, getConfigDid()), + ); + } // Recovery export endpoints app.get("/xrpc/org.p2pds.app.exportRepo", requireAuth, (c) => diff --git a/src/sequencer.test.ts b/src/sequencer.test.ts index cfc5753..c3e1cb1 100644 --- a/src/sequencer.test.ts +++ b/src/sequencer.test.ts @@ -45,6 +45,7 @@ describe("Sequencer.sequenceRelayedCommit", () => { const seqEvent = await sequencer.sequenceRelayedCommit(event); expect(seqEvent.type).toBe("commit"); + if (seqEvent.type !== "commit") throw new Error("expected commit"); expect(seqEvent.event.repo).toBe("did:plc:remote1"); expect(seqEvent.event.rev).toBe("3abcdefghijkl"); // Local seq must be independent of upstream seq (42) @@ -55,6 +56,7 @@ describe("Sequencer.sequenceRelayedCommit", () => { it("preserves CAR bytes verbatim (no re-encoding)", async () => { const event = makeFirehoseEvent(1, "did:plc:remote1"); const seqEvent = await sequencer.sequenceRelayedCommit(event); + if (seqEvent.type !== "commit") throw new Error("expected commit"); expect(Array.from(seqEvent.event.blocks)).toEqual([1, 2, 3, 4]); }); diff --git a/src/ui/components/recovery-key-dialog.ts b/src/ui/components/recovery-key-dialog.ts index 432f6af..acf1be2 100644 --- a/src/ui/components/recovery-key-dialog.ts +++ b/src/ui/components/recovery-key-dialog.ts @@ -1,11 +1,9 @@ import { LitElement, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { generateMnemonic, mnemonicToSeedSync } from "@scure/bip39"; +import { generateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; -import { HDKey } from "@scure/bip32"; -import { secp256k1 } from "@noble/curves/secp256k1"; -import { base58btc } from "multiformats/bases/base58"; import { apiFetch, apiPost } from "../state/api.js"; +import { deriveKeypairFromMnemonic } from "../../identity/mnemonic.js"; type DialogStep = | "idle" @@ -16,32 +14,6 @@ type DialogStep = | "success" | "error"; -/** - * Derive a did:key from a BIP39 mnemonic using BIP32 path m/44'/0'/0'. - * Returns compressed secp256k1 public key encoded as did:key. - */ -function deriveDidKey(mnemonic: string): string { - const seed = mnemonicToSeedSync(mnemonic); - const master = HDKey.fromMasterSeed(seed); - const child = master.derive("m/44'/0'/0'"); - if (!child.privateKey) { - throw new Error("Failed to derive private key"); - } - // Get compressed public key (33 bytes) - const pubkey = secp256k1.getPublicKey(child.privateKey, true); - - // Multicodec prefix for secp256k1-pub: 0xe7 0x01 (varint) - const multicodecPrefix = new Uint8Array([0xe7, 0x01]); - const keyBytes = new Uint8Array(multicodecPrefix.length + pubkey.length); - keyBytes.set(multicodecPrefix); - keyBytes.set(pubkey, multicodecPrefix.length); - - // base58btc encode with 'z' multibase prefix - const encoded = base58btc.encode(keyBytes); - - return `did:key:${encoded}`; -} - @customElement("p2p-recovery-key-dialog") export class RecoveryKeyDialog extends LitElement { createRenderRoot() { @@ -55,10 +27,11 @@ export class RecoveryKeyDialog extends LitElement { @state() private _token: string = ""; @state() private _errorMessage: string = ""; - open() { + async open() { const mnemonic = generateMnemonic(wordlist, 128); this._mnemonic = mnemonic; - this._publicKeyDidKey = deriveDidKey(mnemonic); + const keypair = await deriveKeypairFromMnemonic(mnemonic); + this._publicKeyDidKey = keypair.did(); this._confirmed = false; this._token = ""; this._errorMessage = ""; diff --git a/src/xrpc/app.test.ts b/src/xrpc/app.test.ts index dd83766..a7ae03a 100644 --- a/src/xrpc/app.test.ts +++ b/src/xrpc/app.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,6 +12,16 @@ import { PolicyEngine } from "../policy/engine.js"; import type { Config } from "../config.js"; import type { NetworkService } from "../ipfs.js"; import type { PolicySet } from "../policy/types.js"; +import { Secp256k1Keypair } from "@atproto/crypto"; +// @ts-expect-error — transitive dep, no types exported for subpath +import { sha256 as mfSha256 } from "multiformats/hashes/sha2"; +// @ts-expect-error — transitive dep, no types exported for subpath +import { base32 as mfBase32 } from "multiformats/bases/base32"; +// @ts-expect-error — transitive dep, no types exported for NodeNext +import { encode as mfDagCborEncode } from "@ipld/dag-cbor"; +import { signPlcOperation, computeOperationCid } from "../identity/plc-sign.js"; +import { deriveKeypairFromMnemonic } from "../identity/mnemonic.js"; +import type { PlcOperation, IndexedOperation } from "../identity/plc-mirror.js"; function testConfig(dataDir: string, replicateDids: string[] = []): Config { return { @@ -818,3 +828,222 @@ describe("App: addDid / removeDid", () => { expect(replicationManager.getDidSource("did:plc:unknown")).toBeNull(); }); }); + +// ============================================ +// signPlcUpdateOffline +// ============================================ + +describe("App: signPlcUpdateOffline", () => { + const TEST_MNEMONIC = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + + let tmpDir: string; + let db: InstanceType; + let app: ReturnType; + let mnemonicDid: string; + let originalFetch: typeof globalThis.fetch; + let fetchMock: ReturnType; + + /** Build a genesis PLC op signed by a mnemonic-derived key, derive its DID, + * and populate the plc_mirror SQLite table so the handler sees it. */ + async function setupChain( + signedBy: Secp256k1Keypair, + atprotoSigningDidKey: string, + ): Promise<{ did: string; chain: IndexedOperation[] }> { + const unsigned: Omit = { + type: "plc_operation", + prev: null, + rotationKeys: [signedBy.did()], + verificationMethods: { atproto: atprotoSigningDidKey }, + alsoKnownAs: ["at://test.example.com"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://old-pds.example.com", + }, + }, + }; + const signed = await signPlcOperation(unsigned, signedBy); + const cid = await computeOperationCid(signed); + + const genesisBytes = mfDagCborEncode(signed); + const digest = await mfSha256.digest(genesisBytes); + const did = `did:plc:${mfBase32.encode(digest.digest).slice(1).slice(0, 24)}`; + + const chain: IndexedOperation[] = [ + { + did, + operation: signed, + cid, + nullified: false, + createdAt: new Date().toISOString(), + }, + ]; + + // Mirror this chain into the plc_mirror table the handler reads from. + db.exec(` + CREATE TABLE IF NOT EXISTS plc_mirror ( + did TEXT PRIMARY KEY, + operations_json TEXT NOT NULL, + op_count INTEGER NOT NULL, + last_fetched_at TEXT NOT NULL, + last_op_created_at TEXT, + validated INTEGER NOT NULL DEFAULT 1, + is_tombstoned INTEGER NOT NULL DEFAULT 0 + ) + `); + db.prepare( + `INSERT INTO plc_mirror (did, operations_json, op_count, last_fetched_at, validated, is_tombstoned) + VALUES (?, ?, ?, ?, 1, 0)`, + ).run(did, JSON.stringify(chain), chain.length, new Date().toISOString()); + + return { did, chain }; + } + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "plc-sign-offline-test-")); + db = new Database(join(tmpDir, "test.db")); + + const mnemonicKp = await deriveKeypairFromMnemonic(TEST_MNEMONIC); + const atprotoKp = await Secp256k1Keypair.create({ exportable: true }); + const { did } = await setupChain(mnemonicKp, atprotoKp.did()); + mnemonicDid = did; + + const config: Config = { + ...testConfig(tmpDir), + DID: mnemonicDid, + }; + const repoManager = new RepoManager(db, config); + repoManager.init(); + const firehose = new Firehose(repoManager.sequencer); + app = createApp( + config, + firehose, + undefined, + undefined, + undefined, + undefined, + undefined, + repoManager, + undefined, + undefined, + undefined, + db, + ); + + originalFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + db.close(); + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it("signs an update with the mnemonic-derived key (submit=false)", async () => { + const res = await authPost(app, "/xrpc/org.p2pds.app.signPlcUpdateOffline", { + mnemonic: TEST_MNEMONIC, + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://my-p2pds.example.com", + }, + }, + submit: false, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + did: string; + signedOp: { sig: string; services?: Record }; + cid: string; + submitted: boolean; + }; + expect(body.did).toBe(mnemonicDid); + expect(body.signedOp.sig).toBeTruthy(); + expect(body.signedOp.services?.atproto_pds?.endpoint).toBe( + "https://my-p2pds.example.com", + ); + expect(body.submitted).toBe(false); + // submit=false → no network call + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects a mnemonic whose derived key isn't in the rotation set", async () => { + const res = await authPost(app, "/xrpc/org.p2pds.app.signPlcUpdateOffline", { + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://my-p2pds.example.com", + }, + }, + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("UnauthorizedKey"); + }); + + it("rejects an invalid BIP39 mnemonic", async () => { + const res = await authPost(app, "/xrpc/org.p2pds.app.signPlcUpdateOffline", { + mnemonic: "not a real bip39 mnemonic phrase here today", + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("InvalidMnemonic"); + }); + + it("returns 400 when mnemonic is missing", async () => { + const res = await authPost(app, "/xrpc/org.p2pds.app.signPlcUpdateOffline", {}); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("MissingParameter"); + }); + + it("submits to plc.directory when submit=true and reports the result", async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + const res = await authPost(app, "/xrpc/org.p2pds.app.signPlcUpdateOffline", { + mnemonic: TEST_MNEMONIC, + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://my-p2pds.example.com", + }, + }, + submit: true, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { submitted: boolean }; + expect(body.submitted).toBe(true); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0]?.[0]).toContain(encodeURIComponent(mnemonicDid)); + }); + + it("surfaces submit errors without crashing", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => "rejected because prev mismatch", + }); + const res = await authPost(app, "/xrpc/org.p2pds.app.signPlcUpdateOffline", { + mnemonic: TEST_MNEMONIC, + submit: true, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { submitted: boolean; submitError?: string }; + expect(body.submitted).toBe(false); + expect(body.submitError).toMatch(/prev mismatch/); + }); + + it("requires auth", async () => { + const res = await noAuthPost( + app, + "/xrpc/org.p2pds.app.signPlcUpdateOffline", + { mnemonic: TEST_MNEMONIC }, + ); + expect(res.status).toBe(401); + }); +}); diff --git a/src/xrpc/app.ts b/src/xrpc/app.ts index 0ef8dc3..86f5beb 100644 --- a/src/xrpc/app.ts +++ b/src/xrpc/app.ts @@ -10,6 +10,14 @@ import { exportRepoAsCar, exportBlobs } from "../replication/car-export.js"; import { detectContentType } from "../format.js"; import * as rotationKeys from "../identity/rotation-keys.js"; import { getStoredLog } from "../identity/plc-mirror.js"; +import { normalizeCreateOp } from "../identity/plc-mirror.js"; +import { + signPlcOperation, + submitPlcOperation, + buildPlcUpdate, + computeOperationCid, +} from "../identity/plc-sign.js"; +import { deriveKeypairFromMnemonic } from "../identity/mnemonic.js"; import type Database from "better-sqlite3"; const VERSION = "0.1.0"; @@ -1223,3 +1231,138 @@ export async function getRotationKeys( return c.json({ error: "FetchFailed", message }, 500); } } + +/** + * Sign (and optionally submit) a PLC document update using a locally-derived + * rotation keypair — does NOT route through the source PDS. Lets the user + * update their DID document even when their PDS is unreachable, as long as + * the mnemonic they provide derives to a key authorized in their current + * PLC chain. + * + * Body shape: + * { + * mnemonic: string, // BIP39 12-word phrase (server derives keypair) + * rotationKeys?: string[], // new value, or omitted to carry forward + * verificationMethods?: ... // ditto + * alsoKnownAs?: string[], + * services?: ..., + * submit?: boolean, // POST signed op to plc.directory if true + * directoryUrl?: string, // override the PLC directory URL + * } + * + * SECURITY: the mnemonic is sent over localhost HTTP and held in memory only + * for the duration of the request. Avoid logging the request body. We never + * persist the derived private key — re-deriving on each call keeps the + * exposure window short. + */ +export async function signPlcUpdateOffline( + c: Context, + db: Database.Database, + configDid: string, +): Promise { + if (!configDid) { + return c.json( + { error: "NotAuthenticated", message: "No DID established" }, + 401, + ); + } + + type Body = { + mnemonic?: string; + rotationKeys?: string[]; + verificationMethods?: Record; + alsoKnownAs?: string[]; + services?: Record; + submit?: boolean; + directoryUrl?: string; + }; + const body: Body = await c.req.json().catch(() => ({}) as Body); + + if (!body.mnemonic || typeof body.mnemonic !== "string") { + return c.json( + { error: "MissingParameter", message: "mnemonic is required" }, + 400, + ); + } + + const stored = getStoredLog(db, configDid); + if (!stored || stored.operations.length === 0) { + return c.json( + { + error: "NoPlcLog", + message: + "PLC log not available for this DID — wait for the initial mirror fetch to complete", + }, + 404, + ); + } + + const chain = stored.operations; + const head = chain[chain.length - 1]!; + const prevCid = head.cid; + + // Confirm the derived key is actually one of the active rotation keys. + // If not, the submission would fail at plc.directory anyway — better to + // return a clear error here before exposing the user to a confusing 4xx + // from the PLC server. + const normalizedHead = + head.operation.type === "create" + ? normalizeCreateOp(head.operation) + : head.operation; + const authorizedKeys = normalizedHead.rotationKeys ?? []; + + let keypair: Awaited>; + try { + keypair = await deriveKeypairFromMnemonic(body.mnemonic); + } catch (err) { + return c.json( + { + error: "InvalidMnemonic", + message: err instanceof Error ? err.message : "Invalid mnemonic", + }, + 400, + ); + } + + const derivedDidKey = keypair.did(); + if (!authorizedKeys.includes(derivedDidKey)) { + return c.json( + { + error: "UnauthorizedKey", + message: `Derived key is not in the current PLC rotation key set. Did you register this mnemonic's public key as a rotation key first?`, + derivedDidKey, + authorizedKeys, + }, + 403, + ); + } + + const unsigned = buildPlcUpdate(chain, prevCid, { + rotationKeys: body.rotationKeys, + verificationMethods: body.verificationMethods, + alsoKnownAs: body.alsoKnownAs, + services: body.services, + }); + + const signedOp = await signPlcOperation(unsigned, keypair); + const signedCid = await computeOperationCid(signedOp); + + let submitted = false; + let submitError: string | undefined; + if (body.submit) { + try { + await submitPlcOperation(configDid, signedOp, body.directoryUrl); + submitted = true; + } catch (err) { + submitError = err instanceof Error ? err.message : String(err); + } + } + + return c.json({ + did: configDid, + signedOp, + cid: signedCid, + submitted, + ...(submitError ? { submitError } : {}), + }); +}