diff --git a/API.md b/API.md index 3eaa2a6..2346b50 100644 --- a/API.md +++ b/API.md @@ -237,7 +237,7 @@ with a key. - response: `{ "lists": [...] }` - `GET /api/v1/lists/items?listId=` — get a list and its items (`items:read`). - response: `{ "list": {...} | null, "items": [...] }` -- `GET /api/v1/action-records?itemId=` — action records for an item (`items:read`). +- `GET /api/v1/action-records?itemId=` — signed action records for an item (`items:read`). - `GET /api/v1/action-records?listId=` — a list's own records, e.g. created/renamed (`lists:read`). - response: `{ "records": [...] }`, newest first, at most 50. Each record carries the exact signed `payload`, its `digest`, `status` (`pending` | `signed` | `failed` | `unsigned`) and, @@ -256,7 +256,7 @@ The existing mutation endpoints now also accept an `X-API-Key`. All require the Writes made via an API key are authorized by the key's owner account. Creating, completing and reopening items and creating or renaming lists each write an [action record](docs/action-records.md) naming that account and the API key row -used. Records are not signed yet. A +used; it is signed asynchronously with the account's Turnkey-held key. A caller-supplied DID or a key's `agentDid` never establishes who acted. ### Scopes diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 0f53487..8bc3e93 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as actionRecordSigning from "../actionRecordSigning.js"; import type * as actionRecords from "../actionRecords.js"; import type * as activity from "../activity.js"; import type * as activityHttp from "../activityHttp.js"; @@ -45,6 +46,7 @@ import type * as invitations from "../invitations.js"; import type * as invitationMail from "../invitationMail.js"; import type * as items from "../items.js"; import type * as itemsHttp from "../itemsHttp.js"; +import type * as lib_actionRecordSigner from "../lib/actionRecordSigner.js"; import type * as lib_actionRecords from "../lib/actionRecords.js"; import type * as lib_actor from "../lib/actor.js"; import type * as lib_analytics from "../lib/analytics.js"; @@ -114,6 +116,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + actionRecordSigning: typeof actionRecordSigning; actionRecords: typeof actionRecords; activity: typeof activity; activityHttp: typeof activityHttp; @@ -151,6 +154,7 @@ declare const fullApi: ApiFromModules<{ invitationMail: typeof invitationMail; items: typeof items; itemsHttp: typeof itemsHttp; + "lib/actionRecordSigner": typeof lib_actionRecordSigner; "lib/actionRecords": typeof lib_actionRecords; "lib/actor": typeof lib_actor; "lib/analytics": typeof lib_analytics; diff --git a/convex/actionRecordSigning.ts b/convex/actionRecordSigning.ts new file mode 100644 index 0000000..110fd36 --- /dev/null +++ b/convex/actionRecordSigning.ts @@ -0,0 +1,18 @@ +"use node"; + +/** + * Asynchronous signer for action records. Scheduled by recordActions (and by + * retries); signs each pending record with its owner's Turnkey sub-org key, + * server-initiated like didCreation. All logic lives in lib/actionRecordSigner. + */ +import { v } from "convex/values"; +import { internalAction } from "./_generated/server"; +import { signActionRecords } from "./lib/actionRecordSigner"; +import { turnkeySigningKey } from "./turnkeyHelpers"; + +export const sign = internalAction({ + args: { recordIds: v.array(v.id("actionRecords")) }, + handler: async (ctx, args): Promise => { + await signActionRecords(ctx, args.recordIds, subOrgId => turnkeySigningKey(subOrgId)); + }, +}); diff --git a/convex/actionRecords.ts b/convex/actionRecords.ts index b0dfc7b..74ab67f 100644 --- a/convex/actionRecords.ts +++ b/convex/actionRecords.ts @@ -1,14 +1,20 @@ /** - * Action records (#237): reads for provenance and independent verification. - * Records are written by lib/actionRecords.ts recordActions. No signer is wired - * up yet; see docs/action-records.md. + * Signed action records (#237): reads for provenance/verification and the + * database half of asynchronous signing. The Turnkey half is the "use node" + * action in actionRecordSigning.ts. See docs/action-records.md. */ import { v } from "convex/values"; -import { internalMutation } from "./_generated/server"; -import type { Doc } from "./_generated/dataModel"; +import { internalMutation, internalQuery } from "./_generated/server"; +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; import { actorQuery } from "./lib/authenticated"; import { deleteActionRecords } from "./lib/actionRecords"; +// One initial try plus three retries, then the record is marked failed. +export const MAX_SIGNING_ATTEMPTS = 4; +const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 25 * 60_000]; +// Longer than the largest backoff, so the sweep never races a scheduled retry. +const STALE_PENDING_MS = 60 * 60_000; const MAX_RECORDS_PER_READ = 50; /** Everything shared/actionRecord.ts verifyActionRecord needs, and nothing internal. */ @@ -54,3 +60,114 @@ export const deleteRemaining = internalMutation({ args: v.union(v.object({ listId: v.id("lists") }), v.object({ itemId: v.id("items") })), handler: async (ctx, target) => deleteActionRecords(ctx, target), }); + +/** Pending records with their owner's current signing identity. Already-settled + * or deleted records are skipped, which is what makes a repeated signer run a no-op. */ +export const loadForSigning = internalQuery({ + args: { recordIds: v.array(v.id("actionRecords")) }, + handler: async (ctx, args) => { + const owners = new Map, Doc<"users"> | null>(); + const pending = []; + for (const recordId of new Set(args.recordIds)) { + const record = await ctx.db.get(recordId); + if (record?.status !== "pending") continue; + if (!owners.has(record.ownerUserId)) owners.set(record.ownerUserId, await ctx.db.get(record.ownerUserId)); + const owner = owners.get(record.ownerUserId); + pending.push({ + recordId, payload: record.payload, digest: record.digest, ownerUserId: record.ownerUserId, + ownerDid: owner?.did, turnkeySubOrgId: owner?.turnkeySubOrgId, + }); + } + return pending; + }, +}); + +const recordId = v.id("actionRecords"); +const outcome = v.union( + v.object({ + kind: v.literal("signed"), recordId, digest: v.string(), signature: v.string(), + publicKeyMultibase: v.string(), verificationMethod: v.string(), + }), + v.object({ kind: v.literal("unsigned"), recordId }), + // `reason` is a fixed non-secret code, never a provider error message. + v.object({ kind: v.literal("failed"), recordId, reason: v.string(), retry: v.boolean() }), +); + +/** Applies signer results. Only pending records change, so duplicate or late + * deliveries cannot replace a stored signature. Retries are scheduled here so + * the attempt count and its retry commit together. */ +export const settle = internalMutation({ + args: { outcomes: v.array(outcome) }, + handler: async (ctx, args) => { + const now = Date.now(); + const retries = new Map[]>(); + for (const result of args.outcomes) { + const record = await ctx.db.get(result.recordId); + if (record?.status !== "pending") continue; + const attempts = record.attempts + 1; + if (result.kind === "unsigned") { + await ctx.db.patch(record._id, { status: "unsigned" }); + } else if (result.kind === "signed" && result.digest === record.digest) { + await ctx.db.patch(record._id, { + status: "signed", signature: result.signature, publicKeyMultibase: result.publicKeyMultibase, + verificationMethod: result.verificationMethod, signedAt: now, attempts, lastAttemptAt: now, + }); + } else if (result.kind === "failed" && result.retry && attempts < MAX_SIGNING_ATTEMPTS) { + await ctx.db.patch(record._id, { attempts, lastAttemptAt: now }); + const delay = RETRY_DELAYS_MS[attempts - 1]; + retries.set(delay, [...(retries.get(delay) ?? []), record._id]); + } else { + await ctx.db.patch(record._id, { + status: "failed", attempts, lastAttemptAt: now, + error: result.kind === "failed" ? result.reason : "payload_integrity", + }); + } + } + for (const [delay, recordIds] of retries) { + await ctx.scheduler.runAfter(delay, internal.actionRecordSigning.sign, { recordIds }); + } + }, +}); + +/** Scheduled actions run at most once. This cron re-queues records whose signer + * run never reported back, within the same attempt bound. */ +export const sweepStalePending = internalMutation({ + args: {}, + handler: async (ctx) => { + const now = Date.now(); + const pending = await ctx.db.query("actionRecords") + .withIndex("by_status", q => q.eq("status", "pending")).take(200); + const recordIds: Id<"actionRecords">[] = []; + for (const record of pending) { + if (now - (record.lastAttemptAt ?? record._creationTime) <= STALE_PENDING_MS) continue; + const attempts = record.attempts + 1; + if (attempts >= MAX_SIGNING_ATTEMPTS) { + await ctx.db.patch(record._id, { status: "failed", error: "signer_unresponsive", attempts, lastAttemptAt: now }); + } else { + await ctx.db.patch(record._id, { attempts, lastAttemptAt: now }); + recordIds.push(record._id); + } + } + if (recordIds.length > 0) { + await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds }); + } + }, +}); + +/** Operator recovery when an outage outlasted the retry window. */ +export const requeueFailed = internalMutation({ + args: { recordIds: v.array(v.id("actionRecords")) }, + handler: async (ctx, args) => { + const recordIds: Id<"actionRecords">[] = []; + for (const id of new Set(args.recordIds)) { + const record = await ctx.db.get(id); + if (record?.status !== "failed") continue; + await ctx.db.patch(id, { status: "pending", attempts: 0, error: undefined, lastAttemptAt: undefined }); + recordIds.push(id); + } + if (recordIds.length > 0) { + await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds }); + } + return recordIds.length; + }, +}); diff --git a/convex/agentReadHttp.ts b/convex/agentReadHttp.ts index ee1a552..2b0f309 100644 --- a/convex/agentReadHttp.ts +++ b/convex/agentReadHttp.ts @@ -66,7 +66,7 @@ export const getListWithItems = httpAction(async (ctx, request) => { * GET /api/v1/action-records?itemId= (requires "items:read") * GET /api/v1/action-records?listId= (requires "lists:read"; list-level records) * - * Action records in the shape shared/actionRecord.ts verifyActionRecord + * Signed action records in the shape shared/actionRecord.ts verifyActionRecord * takes, so a caller can verify them without trusting this server. * Response: { "records": [...] } */ diff --git a/convex/crons.ts b/convex/crons.ts index fcad098..9653b26 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -15,4 +15,10 @@ crons.interval( internal.siteActions.pollPendingCustomHostnames ); +crons.interval( + "re-queue stale pending action record signatures", + { hours: 1 }, + internal.actionRecords.sweepStalePending +); + export default crons; diff --git a/convex/lib/actionRecordSigner.ts b/convex/lib/actionRecordSigner.ts new file mode 100644 index 0000000..4a2e276 --- /dev/null +++ b/convex/lib/actionRecordSigner.ts @@ -0,0 +1,84 @@ +/** Signing orchestration, free of Turnkey and Node imports so tests can drive + * it with a real keypair behind a stubbed client. */ +import type { ActionCtx } from "../_generated/server"; +import type { Id } from "../_generated/dataModel"; +import { internal } from "../_generated/api"; +import { + actionRecordDigest, actionRecordSigningInput, canonicalize, ed25519Multikey, + encodeSignature, parseActionRecordPayload, verifyActionSignature, +} from "../../shared/actionRecord"; + +export type SigningKey = { publicKey: Uint8Array; sign: (message: Uint8Array) => Promise }; +export type ResolveSigningKey = (turnkeySubOrgId: string) => Promise; + +type Pending = { + recordId: Id<"actionRecords">; payload: string; digest: string; + ownerUserId: Id<"users">; ownerDid?: string; turnkeySubOrgId?: string; +}; +type Outcome = + | { kind: "signed"; recordId: Id<"actionRecords">; digest: string; signature: string; publicKeyMultibase: string; verificationMethod: string } + | { kind: "unsigned"; recordId: Id<"actionRecords"> } + | { kind: "failed"; recordId: Id<"actionRecords">; reason: string; retry: boolean }; + +function isIntact(record: Pending): boolean { + try { + return canonicalize(JSON.parse(record.payload)) === record.payload + && actionRecordDigest(record.payload) === record.digest; + } catch { + return false; + } +} + +async function signOne(record: Pending, keyFor: ResolveSigningKey): Promise { + const { recordId } = record; + const fail = (reason: string, retry: boolean): Outcome => ({ kind: "failed", recordId, reason, retry }); + + // Sign only bytes that are still exactly what the mutation persisted. + const payload = isIntact(record) ? parseActionRecordPayload(record.payload) : null; + if (!payload) return fail("payload_integrity", false); + // The key must belong to the account the payload names, under the DID it names. + if (payload.owner.userId !== record.ownerUserId || payload.owner.did !== record.ownerDid) { + return fail("owner_binding_changed", false); + } + if (!record.turnkeySubOrgId) return { kind: "unsigned", recordId }; + + let key: SigningKey; + try { key = await keyFor(record.turnkeySubOrgId); } catch { return fail("signing_key_unavailable", true); } + let signature: Uint8Array; + try { signature = await key.sign(actionRecordSigningInput(record.payload)); } catch { return fail("signing_request_failed", true); } + // Never store a signature this deployment could not itself verify. + if (!await verifyActionSignature(record.payload, signature, key.publicKey)) return fail("signature_invalid", true); + + const publicKeyMultibase = ed25519Multikey(key.publicKey); + return { + kind: "signed", recordId, digest: record.digest, signature: encodeSignature(signature), + publicKeyMultibase, verificationMethod: `did:key:${publicKeyMultibase}#${publicKeyMultibase}`, + }; +} + +export async function signActionRecords( + ctx: Pick, + recordIds: Id<"actionRecords">[], + resolveKey: ResolveSigningKey, +): Promise { + const pending: Pending[] = await ctx.runQuery(internal.actionRecords.loadForSigning, { recordIds }); + // One key lookup per owner per run; a failed lookup fails that owner's records alike. + const keys = new Map>(); + const keyFor: ResolveSigningKey = (subOrgId) => { + if (!keys.has(subOrgId)) keys.set(subOrgId, resolveKey(subOrgId)); + return keys.get(subOrgId)!; + }; + + const unsettled: Outcome[] = []; + for (const record of pending) { + const outcome = await signOne(record, keyFor); + // Store each signature as it arrives so a later failure cannot lose it. + if (outcome.kind === "signed") await ctx.runMutation(internal.actionRecords.settle, { outcomes: [outcome] }); + else unsettled.push(outcome); + } + if (unsettled.length > 0) { + // Reason codes only: provider errors may carry identifiers we do not log. + console.warn("[actionRecords] not signed:", unsettled.map(o => `${o.recordId}:${o.kind === "failed" ? o.reason : o.kind}`).join(",")); + await ctx.runMutation(internal.actionRecords.settle, { outcomes: unsettled }); + } +} diff --git a/convex/lib/actionRecords.ts b/convex/lib/actionRecords.ts index f61cd64..b9d73c9 100644 --- a/convex/lib/actionRecords.ts +++ b/convex/lib/actionRecords.ts @@ -38,23 +38,29 @@ export const listRenamed = (list: ListRef & { name: string }, name: string): Rec }); /** - * Persists the exact canonical payloads for this mutation's actions. Records - * are stored unsigned until a signer exists (see docs/action-records.md), so - * recording can never fail the write. Identity comes only from ctx.actor. + * Persists the exact canonical payloads for this mutation's actions and queues + * one signer run for them. Never signs inline: Turnkey is Node/network-only, so + * a signing outage cannot fail the write. Identity comes only from ctx.actor. */ export async function recordActions(ctx: ActorCtx, changes: RecordedChange[]): Promise { if (changes.length === 0) return; + const owner = await ctx.db.get(ctx.actor.userId); + const status = owner?.turnkeySubOrgId ? "pending" as const : "unsigned" as const; const occurredAt = Date.now(); + const recordIds: Id<"actionRecords">[] = []; for (const change of changes) { const { payload, digest } = buildActionRecord(change, { occurredAt, owner: { did: ctx.actor.did, userId: ctx.actor.userId }, credential: ctx.actor.credential, }); - await ctx.db.insert("actionRecords", { + recordIds.push(await ctx.db.insert("actionRecords", { payload, digest, listId: change.subject.listId, itemId: change.subject.itemId, - ownerUserId: ctx.actor.userId, status: "unsigned", - }); + ownerUserId: ctx.actor.userId, status, attempts: 0, + })); + } + if (status === "pending") { + await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds }); } } diff --git a/convex/schema.ts b/convex/schema.ts index 3003e6d..550f3ab 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -293,7 +293,7 @@ export default defineSchema({ .index("by_parent", ["parentId"]) .index("by_due_date", ["listId", "dueDate"]), - // Action records (#237), replacing the unsigned vcProof(s) placeholders. + // Signed action records (#237), replacing the unsigned vcProof(s) placeholders. // `payload` is the exact canonical JSON that gets signed; never rewrite it, and // keep this table out of DID-rewrite migrations. See docs/action-records.md. actionRecords: defineTable({ @@ -302,15 +302,15 @@ export default defineSchema({ // Index columns copied from the payload; verification requires them to agree. listId: v.id("lists"), itemId: v.optional(v.id("items")), - ownerUserId: v.id("users"), // Account that authorized the action - // Only "unsigned" is written until a signer exists; the other states are - // what the provenance UI and verifier already understand. + ownerUserId: v.id("users"), // Account whose Turnkey key signs this record status: v.union( - v.literal("pending"), // Awaiting a signature + v.literal("pending"), // Awaiting the asynchronous signer v.literal("signed"), - v.literal("failed"), // Signing did not complete; see error - v.literal("unsigned"), // No signature exists; nothing is fabricated + v.literal("failed"), // Retries exhausted; see error + v.literal("unsigned"), // Owner has no Turnkey sub-org; nothing is fabricated ), + attempts: v.number(), + lastAttemptAt: v.optional(v.number()), signature: v.optional(v.string()), // Multibase base58btc Ed25519 signature verificationMethod: v.optional(v.string()), // did:key of the key bound at signing time publicKeyMultibase: v.optional(v.string()), @@ -318,7 +318,8 @@ export default defineSchema({ error: v.optional(v.string()), // Non-secret reason code, set when failed }) .index("by_list", ["listId", "itemId"]) - .index("by_item", ["itemId"]), + .index("by_item", ["itemId"]) + .index("by_status", ["status"]), // Item assignees table - tracks who is assigned to each item (Phase 1 foundation) itemAssignees: defineTable({ diff --git a/convex/turnkeyHelpers.ts b/convex/turnkeyHelpers.ts index cdb49e8..d1a27c5 100644 --- a/convex/turnkeyHelpers.ts +++ b/convex/turnkeyHelpers.ts @@ -6,7 +6,12 @@ * Provides wallet-account resolution logic for DID creation flows. */ +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { createTurnkeyClient } from "./lib/turnkeyClient"; +import type { SigningKey } from "./lib/actionRecordSigner"; +import { parseEd25519PublicKey } from "../shared/actionRecord"; + +type TurnkeyClient = ReturnType; /** * Look up the first Ed25519 wallet account for a Turnkey sub-org. @@ -14,8 +19,10 @@ import { createTurnkeyClient } from "./lib/turnkeyClient"; * Returns the Turnkey client, the Ed25519 account, and a convenience * `verificationMethodId` string (`did:key:
`). */ -export async function getEd25519Account(subOrgId: string) { - const turnkeyClient = createTurnkeyClient(); +export async function getEd25519Account( + subOrgId: string, + turnkeyClient: TurnkeyClient = createTurnkeyClient() +) { // Get wallets for the sub-org const walletsResponse = await turnkeyClient.apiClient().getWallets({ @@ -58,3 +65,39 @@ export async function getEd25519Account(subOrgId: string) { signingOrganizationId, }; } + +/** + * The sub-org's Ed25519 key as a raw signer, for action records. Same + * server-initiated signRawPayload call the did:webvh signer makes. + */ +export async function turnkeySigningKey( + subOrgId: string, + turnkeyClient: TurnkeyClient = createTurnkeyClient() +): Promise { + const { address, signingOrganizationId } = await getEd25519Account(subOrgId, turnkeyClient); + // A Turnkey Ed25519 (Solana-format) address is the base58 public key itself. + const publicKey = parseEd25519PublicKey(address); + if (!publicKey) throw new Error("Turnkey Ed25519 address is not a public key"); + + return { + publicKey, + sign: async (message) => { + const result = await turnkeyClient.apiClient().signRawPayload({ + organizationId: signingOrganizationId, + signWith: address, + payload: `0x${bytesToHex(message)}`, + encoding: "PAYLOAD_ENCODING_HEXADECIMAL", + hashFunction: "HASH_FUNCTION_NO_OP", + }); + const signRawResult = result.activity?.result?.signRawPayloadResult; + if (!signRawResult?.r || !signRawResult?.s) { + throw new Error("No signature returned from Turnkey"); + } + const signature = hexToBytes((signRawResult.r + signRawResult.s).replace(/^0x/, "")); + if (signature.length !== 64) { + throw new Error(`Invalid Ed25519 signature length: ${signature.length} (expected 64 bytes)`); + } + return signature; + }, + }; +} diff --git a/docs/action-records.md b/docs/action-records.md index fc08c7b..639992b 100644 --- a/docs/action-records.md +++ b/docs/action-records.md @@ -1,18 +1,16 @@ # Action records and signature boundaries Recorded actions are written as **action records**: an exact, canonical payload -naming the account and credential that authorized the action (#237). Records are -stored **unsigned** for now. Who holds the signing key, and whether the account -holder must be present to sign, is still being decided (#244), so no signer is -wired up and nothing claims a signature. They replace the unsigned `vcProof` / `vcProofs` placeholders, which remain +that the authorizing account's Turnkey-held key signs asynchronously (#237). +They replace the unsigned `vcProof` / `vcProofs` placeholders, which remain readable as historical data and are no longer written. | Piece | Where | | --- | --- | | Payload contract, canonical bytes, verifier | `shared/actionRecord.ts` (pure: no Convex, no network) | | The one writer | `convex/lib/actionRecords.ts` `recordActions` | -| Storage and reads | `convex/actionRecords.ts`, table `actionRecords` | -| Signing | Not implemented yet; see [Custody](#custody) | +| Storage, reads, signer bookkeeping | `convex/actionRecords.ts`, table `actionRecords` | +| Signing | `convex/actionRecordSigning.ts` → `convex/lib/actionRecordSigner.ts` | | Standalone check | `scripts/verify-action-records.mjs` | ## Authenticated identity @@ -48,8 +46,8 @@ Do not fabricate historical signatures or alter genuine evidence's signed bytes or historical identity bindings. The legacy DID migrations still rewrite the placeholder wrapper fields, and `celAssetDids` still rebuilds a legacy list's unsigned ownership placeholder for its new asset DID (the constructor now lives -in that migration, its only remaining user). Action records are kept out of -every rewrite path, because their payload is meant to be signed as written. +in that migration, its only remaining user). Signed action records are kept out +of every rewrite path. ## Identifier badges and evidence boundaries @@ -73,18 +71,15 @@ personal-ownership verification. ## Custody -Undecided. #244 rules out boop's parent-org Turnkey API key signing as a user -without the user present, which is what a server-initiated `signRawPayload` in -the account's sub-org would be. Until the replacement model lands: +Signing custody is **author-held at Turnkey**. Each record is signed by the +Ed25519 key in the authorizing account's own Turnkey sub-organization +(`users.turnkeySubOrgId`, resolved with `getEd25519Account`), through the same +server-initiated `signRawPayload` call `didCreation` uses. There is no boop +service key and no service attestation. -- every record is written with `status: "unsigned"`; -- the payload carries no `signer` or custody field, so stored bytes make no - claim about a key that does not exist yet; -- the schema, verifier and provenance UI already understand `pending`, `signed` - and `failed`, so a signer can be added without changing them. - -Records written now will not be signed retroactively under an author-present -model, because the author is no longer present for them. +"Author-held" describes where the key lives, not who pressed the button. boop's +server asks Turnkey to sign on the account's behalf; the account holder does not +approve each signature. See [what a signature proves](#what-a-signature-proves). ## What is recorded @@ -101,8 +96,7 @@ replay, or the HTTP API: | `list.renamed` | `renameList` | A batch writes one record per item, identical in shape to the record the -individual call writes. Recording happens inside the mutation and schedules -nothing, so it cannot fail or delay the write. +individual call writes. A mutation's records are signed by one signer run. Not recorded: item edits other than the above, deletion, reordering, assignment, and the items carried by `copyList` (they keep their original @@ -110,7 +104,7 @@ attribution fields, so a creation record would claim someone else's work). ## Payload (version 1) -`payload` is stored as a string and is the exact UTF-8 text a signature covers. +`payload` is stored as a string and is the exact UTF-8 text that gets signed. It is canonical JSON per RFC 8785: object keys sorted by UTF-16 code unit, no whitespace, strings and finite numbers as `JSON.stringify` writes them. Values JSON cannot carry are never put in a payload. @@ -130,7 +124,8 @@ JSON cannot carry are never put in a payload. "origin": { "kind": "recurrence", "sourceItemId": "…" }, // optional, *.created only "occurredAt": 1700000000000, // server clock, ms "owner": { "did": "did:webvh:…", "userId": "" }, - "credential": { "kind": "session", "id": "" } + "credential": { "kind": "session", "id": "" }, + "signer": { "did": "did:webvh:…", "custody": "turnkey", "keyType": "Ed25519" } } ``` @@ -147,6 +142,8 @@ JSON cannot carry are never put in a payload. - `credential` is the authenticated session row or API-key row from `ctx.actor.credential`. A human session and each API key of one account are distinct here while sharing one `owner`. +- `signer.did` always equals `owner.did`: the signature is expected under that + account's own Turnkey-held Ed25519 key. - `checkedAt` is the value stored on the item. The individual call takes it from the client (`null` here if it was not a finite number); the batch call uses the server clock. `occurredAt` is always the server clock. @@ -164,22 +161,51 @@ The signed message is 64 bytes: SHA-256("boop.action-record/v1") || SHA-256(payload) ``` -The prefix separates these signatures from anything else the same key signs. `signature` is the Ed25519 signature, multibase base58btc (`z…`). +The prefix separates these signatures from the did:webvh proofs the same key can +sign. `signature` is the Ed25519 signature, multibase base58btc (`z…`). `publicKeyMultibase` (Multikey, `z6Mk…`) and `verificationMethod` (`did:key:#`) record the key that signed. ## Status lifecycle -| Status | Meaning | Written today | -| --- | --- | --- | -| `unsigned` | No signature exists | Yes, for every record | -| `pending` | Awaiting a signature | No | -| `signed` | Carries `signature`, `publicKeyMultibase`, `verificationMethod`, `signedAt` | No | -| `failed` | Signing did not complete; `error` holds a non-secret reason code | No | +``` +recordActions ──► pending ──► signed + │ ▲ + │ └── retry (attempts < 4) + ├────► failed retries exhausted, or the record is no longer signable + └────► unsigned the account has no Turnkey sub-org +recordActions ──► unsigned same, known at write time +``` -Whatever signer is added must sign only the stored bytes, never a payload -rebuilt from live list or item state, and must check that they still hash to -`digest`. +1. The mutation writes the record as `pending` and schedules + `actionRecordSigning.sign`. It does not contact Turnkey. If the account has no + sub-org it writes `unsigned` and schedules nothing. +2. The signer re-reads each record. It signs only if the stored payload is still + canonical, still hashes to the stored digest, and still names the record's + owner under that owner's current DID. It never rebuilds a payload from live + list or item state. +3. It checks the returned signature against the looked-up key before storing it. +4. `settle` changes only `pending` records, so repeated, duplicate or late runs + cannot re-sign or replace a stored signature. + +### Availability and failure policy + +- A write never fails or waits because of signing. A Turnkey outage delays + signatures; it does not block or roll back actions. +- Transient failures (key lookup, signing request, a signature that does not + verify) are retried after 1, 5 and 25 minutes. The fourth failure marks the + record `failed`. +- Integrity or binding failures (`payload_integrity`, `owner_binding_changed`) + mark the record `failed` immediately and are not retried. +- Scheduled actions run at most once. An hourly cron (`sweepStalePending`) + re-queues records whose signer run never reported back, counting against the + same attempt bound (`signer_unresponsive`). +- `error` holds a fixed reason code. Provider error text is never stored or + logged. +- After an outage longer than the retry window, an operator can run the internal + mutation `actionRecords.requeueFailed` with the affected record ids. Nothing + re-queues failed records automatically, and `unsigned` records are not signed + later if the account gains a sub-org. ## Independent verification @@ -188,8 +214,8 @@ needs only the record and keys the caller already trusts. It: 1. requires `status: "signed"` and a well-formed signature; 2. re-canonicalizes `payload` and requires byte equality, then recomputes the digest; -3. requires the row's index columns to match the payload, and any `expected` - owner DID or credential to match; +3. requires `signer.did === owner.did`, the row's index columns to match the + payload, and any `expected` owner DID or credential to match; 4. takes the caller's trusted keys **for `payload.owner.did`** and requires the record's declared key to be one of them; 5. verifies the Ed25519 signature under that trusted key. @@ -208,21 +234,23 @@ bun scripts/verify-action-records.mjs records.json 'did:webvh:…=z6Mk…' ### Where the trusted key comes from -Unsolved, and tied to the custody decision. An account's did:webvh is minted in -the browser with a browser-held key (`src/lib/webvh.ts`), so a verifier can only -take a signing key from that DID document if the signing key is listed there. -Otherwise it has to pin "this owner DID → this key" by a route it trusts. If the -only source of the key is boop, verification shows the record is intact and was -signed by the key boop names, not that the key is the owner's. +This is the part boop does not yet solve for outside verifiers. An account's +did:webvh is minted in the browser with a browser-held key (`src/lib/webvh.ts`), +so its DID document does **not** list the Turnkey key that signs action records. +A verifier therefore has to pin "this owner DID → this Turnkey key" by a route +it trusts: the account holder publishing the key, a lookup of the Turnkey +sub-org by someone entitled to make it, or pinning on first use and watching for +change. If the only source of the key is boop, verification shows the record is +intact and was signed by the key boop names, not that the key is the owner's. -An account whose DID is a `did:key` of the signing key is the exception: the DID -is the key, so no pin is needed. +An account whose DID is a `did:key` of that same Turnkey key is the exception: +the DID is the key, so no pin is needed. ### Key rotation A record keeps the `verificationMethod` and `publicKeyMultibase` it was signed with, and its payload keeps the owner DID that authorized it. Neither is -rewritten when the account's key or DID later changes, and the +rewritten when the account's Turnkey key or DID later changes, and the `remintUserDid` migration deliberately leaves `actionRecords` alone. A verifier must accept the key bound at signing time, so `trustedKeys` takes a list per owner DID: keep rotated-out keys there for as long as older records should @@ -233,14 +261,14 @@ of a compromise must stop trusting that key itself. ## What a signature proves -No record is signed today, so action records currently prove nothing -cryptographically. They are boop's own account of what happened, and are shown -as **Unsigned**. +A verified record proves that the Ed25519 key in the named account's Turnkey +sub-organization signed exactly this payload, and so that the payload has not +changed since. boop requested that signature after an operation authenticated by +the named session or API key. -Once a signer exists, a verified record will prove that the named key signed -exactly this payload, and so that it has not changed since. What else it proves -depends on the custody model. Even then it does **not** prove: +It does **not** prove: +- personal intent, or that the account holder saw or approved the action; - who or what was holding the session or API key; - that the action was permitted (authorization is enforced at write time and is not part of the evidence); @@ -249,6 +277,9 @@ depends on the custody model. Even then it does **not** prove: - that the record set is complete. Records are deleted with their item or list, and nothing chains them together, so an absent record proves nothing. +`pending`, `failed` and `unsigned` records carry no signature and prove nothing +cryptographically. They are shown as such. + ## Presentation `ProvenanceInfo` lists action records for an item, and list-level records for a @@ -256,12 +287,12 @@ list, under **Action records**, separate from **Historical action records**: | Status | Label | | --- | --- | -| `signed` | **Signed** | +| `signed` | **Signed (Turnkey-held owner key)** | | `pending` | **Pending signature** | | `failed` | **Signing failed** | | `unsigned` | **Unsigned** | -Only **Unsigned** occurs today. Each row names the authorizing account and whether a signed-in session or an +Each row names the authorizing account and whether a signed-in session or an API key was used. The browser re-checks every signed record. It says "signature verified" only when the owner DID is itself a `did:key` for the signing key. Otherwise it says the signature matches the key boop recorded and that the key @@ -281,24 +312,28 @@ for the rest, so deleting a list or item never hits Convex's per-mutation limits ## Verification status and remaining gaps -Covered by `scripts/signed-action-records.test.mjs`, -`scripts/provenance-presentation.test.mjs` and the share-modal E2E tests: -canonical determinism; create, complete, reopen, batch, recurring, template, -copy and rename flows; individual versus batch equivalence; session versus -distinct API keys; every record written unsigned with nothing scheduled; -verification of records signed with a test key, and rejection of altered -payloads, signatures, keys and bindings and of every non-signed status; reads, +Covered by `scripts/signed-action-records.test.mjs` and +`scripts/provenance-presentation.test.mjs`: canonical determinism; create, +complete, reopen, batch, recurring, template, copy and rename flows; individual +versus batch equivalence; session versus distinct API keys; signing through a +stubbed Turnkey client holding a real Ed25519 key; rejection of altered payloads, +signatures, keys and bindings and of every non-signed status; retry to failed; +no-sub-org to unsigned; idempotent re-signing; the stale-pending sweep; reads, the HTTP export and the standalone verifier; deletion; the re-mint migration -leaving records untouched; all four rendered statuses. +leaving records untouched; rendered statuses. Not done or not verified: -- **No signer.** Signing waits on the custody follow-up from #244. -- Where an outside verifier gets the signing key from is unsolved (above). +- **Live Turnkey has not been exercised.** The signer mirrors the request the + did:webvh signer makes (64-byte hex payload, `HASH_FUNCTION_NO_OP`), but no + real signature has been produced or checked against a real sub-org key. +- The Turnkey key is not published in the owner's DID document, so outside + verifiers must pin it themselves (above). - No revocation or validity window for signing keys. - Records are not chained or anchored; completeness and ordering are not provable. - Existing historical placeholders are not converted, and never will be signed retroactively. - Item edits, deletions, reordering and assignment are not recorded. +- Failed records need an operator to re-queue; there is no user-facing retry. - Native builds and live flows have not been exercised. diff --git a/scripts/copy-list.test.mjs b/scripts/copy-list.test.mjs index aac0251..eb15482 100644 --- a/scripts/copy-list.test.mjs +++ b/scripts/copy-list.test.mjs @@ -56,6 +56,8 @@ function makeCtx({ items = [], lists: extraLists = [], user = null, subscription let seq = 0; return { rows, + // copyList queues signing of its list.created action record. + scheduler: { runAfter: async () => {} }, db: { get: async (id) => byId.get(id) ?? null, patch: async (id, fields) => Object.assign(byId.get(id), fields), diff --git a/scripts/provenance-presentation.test.mjs b/scripts/provenance-presentation.test.mjs index 1bc3a59..13cc027 100644 --- a/scripts/provenance-presentation.test.mjs +++ b/scripts/provenance-presentation.test.mjs @@ -82,7 +82,7 @@ test('new action records show their truthful signing status without claiming ver fireEvent.click(container.querySelector('button')); const text = () => container.textContent; assert.match(text(), /Action records \(4\)/); - assert.match(text(), /Creation recordSigned/); + assert.match(text(), /Creation recordSigned \(Turnkey-held owner key\)/); assert.match(text(), /Completion recordPending signature/); assert.match(text(), /Reopen recordSigning failed/); assert.match(text(), /Completion recordUnsigned/); @@ -90,10 +90,10 @@ test('new action records show their truthful signing status without claiming ver assert.match(text(), /Via signed-in session …abc123/); assert.match(text(), /Via API key …111aaa/); assert.match(text(), /Via API key …222bbb/); - // A did:webvh owner's key cannot be checked from the DID alone, so nothing says "verified". + // A did:webvh owner's Turnkey key cannot be checked from the DID alone, so nothing says "verified". await waitFor(() => assert.match(text(), /matches the key boop recorded.*not independently verified here/)); assert.doesNotMatch(text(), /Signature verified/); - assert.match(text(), /after an operation authenticated by the named session or API key.*does not show personal intent or who was using that credential/); + assert.match(text(), /at boop's request.*does not show personal intent or who was using that credential/); assert.doesNotMatch(text(), /Verifiable Credential|cryptographic proof|Historical action records/); }); @@ -104,7 +104,7 @@ test('a signature is called verified only against a key the owner DID itself enc serve({ 'actionRecords:getItemActionRecords': [genuine] }); const first = render(React.createElement(ItemProvenanceInfo, { item: bareItem })); fireEvent.click(first.container.querySelector('button')); - await waitFor(() => assert.match(first.container.textContent, /Completion recordSignedSignature verified against the key in the authorizing account's DID/)); + await waitFor(() => assert.match(first.container.textContent, /Completion recordSigned \(Turnkey-held owner key\)Signature verified against the key in the authorizing account's DID/)); cleanup(); // Same record, signed by a key the did:key owner does not name: integrity holds, binding does not. @@ -121,7 +121,7 @@ test('a signature is called verified only against a key the owner DID itself enc const third = render(React.createElement(ItemProvenanceInfo, { item: bareItem })); fireEvent.click(third.container.querySelector('button')); await waitFor(() => assert.match(third.container.textContent, /Completion recordSignature check failed/)); - assert.doesNotMatch(third.container.textContent, /recordSigned|Signature verified/); + assert.doesNotMatch(third.container.textContent, /Signed \(Turnkey-held owner key\)|Signature verified/); }); test('list-level records and historical placeholders are presented side by side with separate labels', async () => { @@ -138,7 +138,7 @@ test('list-level records and historical placeholders are presented side by side const { container } = render(React.createElement(MemoryRouter, null, React.createElement(ListProvenanceInfo, { list }))); fireEvent.click(container.querySelector('button')); assert.match(container.textContent, /Rename recordPending signature/); - assert.match(container.textContent, /List creation recordSigned/); + assert.match(container.textContent, /List creation recordSigned \(Turnkey-held owner key\)/); assert.match(container.textContent, /Historical ownership record \(unsigned\)/); await waitFor(() => assert.match(container.textContent, /not independently verified here/)); }); diff --git a/scripts/signed-action-records.test.mjs b/scripts/signed-action-records.test.mjs index 21fb478..dcb6981 100644 --- a/scripts/signed-action-records.test.mjs +++ b/scripts/signed-action-records.test.mjs @@ -17,37 +17,46 @@ import { } from '../shared/actionRecord.ts'; const modules = await loadReplayModules('signed-action-records'); -const extra = ['templates', 'actionRecords', 'agentReadHttp', 'users', 'didResources', 'migrations/remintUserDidDb']; +const extra = ['templates', 'actionRecords', 'agentReadHttp', 'users', 'didResources', 'lib/actionRecordSigner', 'turnkeyHelpers', 'migrations/remintUserDidDb']; await build({ entryPoints: extra.map(n => `convex/${n}.ts`), outdir: 'tmp/signed-action-records-extra', bundle: true, platform: 'node', format: 'esm', external: ['convex/*', '@turnkey/*'], outExtension: { '.js': '.mjs' }, }); for (const name of extra) modules[name] = await import(pathToFileURL(`${process.cwd()}/tmp/signed-action-records-extra/${name}.mjs`)); +const { signActionRecords } = modules['lib/actionRecordSigner']; +const { turnkeySigningKey } = modules.turnkeyHelpers; const digest = value => createHash('sha256').update(value).digest('hex'); +const hex = bytes => Buffer.from(bytes).toString('hex'); -/** Test-only Ed25519 keys, one per account DID. No signer is wired up yet, so - * tests sign stored records themselves to exercise the verifier and read paths. */ -async function testKeys() { +/** A stand-in for the Turnkey API holding real Ed25519 keys, one per sub-org. */ +async function fakeTurnkey() { const keys = new Map(); - const keyFor = async did => { - if (!keys.has(did)) { + const calls = []; + let failing = false; + const keyFor = async subOrgId => { + if (!keys.has(subOrgId)) { const secret = ed25519.utils.randomSecretKey(); - keys.set(did, { secret, publicKey: await ed25519.getPublicKeyAsync(secret) }); + keys.set(subOrgId, { secret, publicKey: await ed25519.getPublicKeyAsync(secret) }); } - return keys.get(did); + return keys.get(subOrgId); }; - return { - keyFor, - trusted: async user => ({ [user.did]: [ed25519Multikey((await keyFor(user.did)).publicKey)] }), - /** Settles a stored record as signed, in the evidence shape the verifier takes. */ - async sign(record, user) { - const { secret, publicKey } = await keyFor(user.did); - const publicKeyMultibase = ed25519Multikey(publicKey); - return Object.assign(record, { - status: 'signed', signature: encodeSignature(await ed25519.signAsync(actionRecordSigningInput(record.payload), secret)), - publicKeyMultibase, verificationMethod: `did:key:${publicKeyMultibase}#${publicKeyMultibase}`, signedAt: Date.now(), - }); + const client = { apiClient: () => ({ + getWallets: async ({ organizationId }) => ({ wallets: [{ walletId: `wallet-${organizationId}` }] }), + getWalletAccounts: async ({ organizationId }) => ({ accounts: [ + { curve: 'CURVE_SECP256K1', address: '0xnot-this-one', organizationId }, + { curve: 'CURVE_ED25519', address: base58Encode((await keyFor(organizationId)).publicKey), organizationId }, + ] }), + signRawPayload: async request => { + calls.push(request); + if (failing) throw new Error('turnkey unavailable: api-secret-must-not-be-stored'); + const signature = hex(await ed25519.signAsync(Buffer.from(request.payload.slice(2), 'hex'), (await keyFor(request.organizationId)).secret)); + return { activity: { result: { signRawPayloadResult: { r: signature.slice(0, 64), s: signature.slice(64) } } } }; }, + }) }; + return { + calls, keyFor, client, + fail(value) { failing = value; }, + trusted: async user => ({ [user.did]: [ed25519Multikey((await keyFor(user.turnkeySubOrgId)).publicKey)] }), }; } @@ -56,16 +65,18 @@ async function fixture() { const query = f.ctx.db.query; f.ctx.db.query = table => { const q = query(table); q.order = () => q; return q; }; f.ctx.runQuery = f.ctx.runMutation = (ref, args) => { const [ns, name] = getFunctionName(ref).split(':'); return modules[ns][name]._handler(f.ctx, args); }; - const keys = await testKeys(); + const turnkey = await fakeTurnkey(); + const signerJobs = () => f.effects.filter(([, ref]) => getFunctionName(ref) === 'actionRecordSigning:sign'); return Object.assign(f, { - keys, + turnkey, signerJobs, records: () => f.rows.actionRecords ?? [], payloads: (action) => (f.rows.actionRecords ?? []).map(r => JSON.parse(r.payload)).filter(p => !action || p.action === action), - /** Signs every unsigned record with its authorizing account's test key. */ - async signAll() { - for (const record of f.rows.actionRecords ?? []) { - if (record.status === 'unsigned') await keys.sign(record, f.rows.users.find(u => u._id === record.ownerUserId)); - } + /** Runs queued signer jobs the way the scheduled action would; returns how many ran. */ + async runSigner() { + const jobs = signerJobs().splice(0); + for (const job of jobs) f.effects.splice(f.effects.indexOf(job), 1); + for (const [, , args] of jobs) await signActionRecords(f.ctx, args.recordIds, subOrg => turnkeySigningKey(subOrg, turnkey.client)); + return jobs.length; }, async addApiKey(raw, owner = f.owner.user) { return f.ctx.db.insert('agentApiKeys', { ownerDid: owner.did, keyHash: digest(raw), scopes: ['items:write', 'items:read', 'lists:read'], agentDid: 'did:agent:caller-claimed' }); @@ -82,7 +93,7 @@ test('canonical payload bytes are deterministic and reject values that have no s const reordered = { after: { checkedAt: 5, checked: true }, before: { checked: false }, subject: { itemId: 'I', listId: 'L' }, action: 'item.completed' }; const a = buildActionRecord(change, context), b = buildActionRecord(reordered, { credential: { id: 'S', kind: 'session' }, owner: { userId: 'U', did: 'did:owner' }, occurredAt: 1700000000000 }); assert.deepEqual(a, b); - assert.equal(a.payload, '{"action":"item.completed","after":{"checked":true,"checkedAt":5},"before":{"checked":false},"credential":{"id":"S","kind":"session"},"occurredAt":1700000000000,"owner":{"did":"did:owner","userId":"U"},"schema":"boop.action-record","subject":{"itemId":"I","listId":"L"},"version":1}'); + assert.equal(a.payload, '{"action":"item.completed","after":{"checked":true,"checkedAt":5},"before":{"checked":false},"credential":{"id":"S","kind":"session"},"occurredAt":1700000000000,"owner":{"did":"did:owner","userId":"U"},"schema":"boop.action-record","signer":{"custody":"turnkey","did":"did:owner","keyType":"Ed25519"},"subject":{"itemId":"I","listId":"L"},"version":1}'); assert.equal(a.digest, digest(a.payload)); assert.equal(canonicalize({ b: [1, 'é"\n', null, true], a: {} }), '{"a":{},"b":[1,"é\\"\\n",null,true]}'); assert.equal(canonicalize({ n: [1.5, -0, 1e21, 2 ** 60] }), '{"n":[1.5,0,1e+21,1152921504606847000]}'); @@ -100,7 +111,7 @@ test('canonical payload bytes are deterministic and reject values that have no s assert.equal(parseEd25519PublicKey('not base58 0OIl'), null); }); -test('create, complete, reopen, list create and rename each write one unsigned record bound to the authenticated session', async () => { +test('create, complete, reopen, list create and rename each write one pending record bound to the authenticated session', async () => { const f = await fixture(); const listId = await f.call('lists', 'createList', { assetDid: 'did:cel:new', name: 'Errands', createdAt: 1 }); await f.call('lists', 'renameList', { listId, name: 'Chores' }); @@ -118,19 +129,19 @@ test('create, complete, reopen, list create and rename each write one unsigned r for (const [record, payload] of f.records().map((r, i) => [r, f.payloads()[i]])) { assert.deepEqual(payload.owner, { did: f.owner.user.did, userId: f.owner.user._id }); assert.deepEqual(payload.credential, { kind: 'session', id: f.owner.accessSession._id }); - assert.equal(payload.signer, undefined, 'custody is not decided, so the payload makes no claim about it'); + assert.deepEqual(payload.signer, { did: f.owner.user.did, custody: 'turnkey', keyType: 'Ed25519' }); assert.deepEqual([payload.schema, payload.version], ['boop.action-record', 1]); assert.ok(Math.abs(payload.occurredAt - Date.now()) < 5000); assert.equal(record.payload, canonicalize(payload)); assert.equal(record.digest, actionRecordDigest(record.payload)); - assert.deepEqual([record.status, record.signature, record.ownerUserId, record.listId, record.itemId], - ['unsigned', undefined, f.owner.user._id, listId, payload.subject.itemId]); + assert.deepEqual([record.status, record.attempts, record.signature, record.ownerUserId, record.listId, record.itemId], + ['pending', 0, undefined, f.owner.user._id, listId, payload.subject.itemId]); } // No new unsigned placeholder JSON is written anywhere. assert.equal(f.rows.lists.find(l => l._id === listId).vcProof, undefined); assert.equal(f.rows.items.find(i => i._id === itemId).vcProofs, undefined); - // Recording happens entirely inside the mutation; nothing is scheduled for it. - assert.deepEqual(f.effects.filter(([, ref]) => getFunctionName(ref).startsWith('actionRecord')), []); + assert.equal(f.signerJobs().length, 5); + assert.deepEqual(f.signerJobs().flatMap(([delay, , args]) => [delay, args.recordIds.length]), [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]); }); test('batch and individual actions produce equivalent records, including recurring generation', async () => { @@ -144,7 +155,7 @@ test('batch and individual actions produce equivalent records, including recurri await batch.call('items', 'batchCheckItems', { itemIds: ['I1', 'I2'] }); await batch.call('items', 'batchUncheckItems', { itemIds: ['I1', 'I2'] }); - const normalize = f => f.payloads().map(p => ({ ...shape(p), owner: 'OWNER', credential: 'CREDENTIAL', item: p.action === 'item.created' ? 'NEXT' : p.subject.itemId })) + const normalize = f => f.payloads().map(p => ({ ...shape(p), owner: 'OWNER', credential: 'CREDENTIAL', signer: 'SIGNER', item: p.action === 'item.created' ? 'NEXT' : p.subject.itemId })) .sort((a, b) => `${a.action}${a.item}`.localeCompare(`${b.action}${b.item}`)); assert.deepEqual(normalize(batch), normalize(single)); assert.deepEqual(batch.payloads().map(p => p.action).sort(), ['item.completed', 'item.completed', 'item.created', 'item.reopened', 'item.reopened']); @@ -159,6 +170,9 @@ test('batch and individual actions produce equivalent records, including recurri // Historical evidence on the source item is neither replaced nor extended. assert.deepEqual(f.rows.items[0].vcProofs, [{ type: 'ExistingSignedProof', proof: 'do-not-replace', issuer: f.owner.user.did }]); } + // One mutation queues one signer run for all of its records. + assert.deepEqual(batch.signerJobs().map(([, , args]) => args.recordIds.length), [3, 2]); + assert.deepEqual(single.signerJobs().map(([, , args]) => args.recordIds.length), [2, 1, 1, 1]); }); test('DID resource toggles record the same completion and reopen as checkItem and uncheckItem', async () => { @@ -167,7 +181,7 @@ test('DID resource toggles record the same completion and reopen as checkItem an await direct.call('items', 'uncheckItem', { itemId: 'I1' }); await shared.call('didResources', 'checkSharedItem', { listId: 'L1', itemId: 'I1' }); await shared.call('didResources', 'uncheckSharedItem', { listId: 'L1', itemId: 'I1' }); - const normalize = f => f.payloads().map(p => ({ ...shape(p), owner: 'OWNER', credential: 'CREDENTIAL' })); + const normalize = f => f.payloads().map(p => ({ ...shape(p), owner: 'OWNER', credential: 'CREDENTIAL', signer: 'SIGNER' })); assert.deepEqual(normalize(shared), normalize(direct)); const [completed] = shared.payloads('item.completed'); assert.ok(Math.abs(completed.after.checkedAt - Date.now()) < 5000, 'the shared toggle stamps server time'); @@ -186,8 +200,9 @@ test('a session and two API keys of one account are distinguishable from each ot { kind: 'session', id: f.owner.accessSession._id }, { kind: 'apiKey', id: first }, { kind: 'apiKey', id: second }, ]); for (const payload of [session, keyOne, keyTwo]) assert.deepEqual(payload.owner, { did: f.owner.user.did, userId: f.owner.user._id }); - // An editor's action is authorized by the editor's account, not the list owner's. + // An editor's action is authorized and signed by the editor's account, not the list owner's. assert.deepEqual(editor.owner, { did: f.collaborator.user.did, userId: f.collaborator.user._id }); + assert.equal(editor.signer.did, f.collaborator.user.did); assert.deepEqual(editor.credential, { kind: 'session', id: f.collaborator.accessSession._id }); // Identity is never taken from caller input or key metadata, and no secret material is recorded. await assert.rejects(f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 3, userDid: 'did:somebody-else' }), /Identity assertion/); @@ -198,32 +213,42 @@ test('a session and two API keys of one account are distinguishable from each ot assert.equal(f.payloads().length, 4); }); -test('a record signed with the authorizing account key verifies independently, and only under that account', async () => { +test('the signer signs the exact persisted bytes with the owner sub-org key and the result verifies independently', async () => { const f = await fixture(); await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); await f.call('items', 'uncheckItem', { itemId: 'I1' }, f.collaborator); - await f.signAll(); + assert.equal(await f.runSigner(), 2); const [byOwner, byEditor] = f.records(); for (const [record, user] of [[byOwner, f.owner.user], [byEditor, f.collaborator.user]]) { - const publicKey = (await f.keys.keyFor(user.did)).publicKey; - const result = await verifyActionRecord(record, { trustedKeys: await f.keys.trusted(user) }); + const publicKey = (await f.turnkey.keyFor(user.turnkeySubOrgId)).publicKey; + assert.deepEqual([record.status, record.attempts, record.error], ['signed', 1, undefined]); + assert.equal(record.publicKeyMultibase, ed25519Multikey(publicKey)); + assert.equal(record.verificationMethod, `did:key:${record.publicKeyMultibase}#${record.publicKeyMultibase}`); + assert.ok(Math.abs(record.signedAt - Date.now()) < 5000); + const result = await verifyActionRecord(record, { trustedKeys: await f.turnkey.trusted(user) }); assert.equal(result.verified, true); assert.equal(result.publicKeyMultibase, record.publicKeyMultibase); assert.deepEqual(result.payload, JSON.parse(record.payload)); - // Equivalent trust sources: a resolver, raw bytes, and a bare base58 key. + // Equivalent trust sources: a resolver, raw bytes, and the Turnkey address form. assert.equal((await verifyActionRecord(record, { resolvePublicKey: (did, method) => did === user.did && method === record.verificationMethod ? publicKey : null })).verified, true); assert.equal((await verifyActionRecord(record, { trustedKeys: { [user.did]: [base58Encode(publicKey)] } })).verified, true); } + assert.deepEqual(f.turnkey.calls.map(c => [c.organizationId, c.encoding, c.hashFunction, c.payload.length]), [ + [f.owner.user.turnkeySubOrgId, 'PAYLOAD_ENCODING_HEXADECIMAL', 'HASH_FUNCTION_NO_OP', 130], + [f.collaborator.user.turnkeySubOrgId, 'PAYLOAD_ENCODING_HEXADECIMAL', 'HASH_FUNCTION_NO_OP', 130], + ]); + assert.equal(f.turnkey.calls[0].payload, `0x${hex(actionRecordSigningInput(byOwner.payload))}`); + assert.equal(f.turnkey.calls[0].signWith, base58Encode((await f.turnkey.keyFor(f.owner.user.turnkeySubOrgId)).publicKey)); // The owner's key does not vouch for the editor's record, and vice versa. - assert.deepEqual(await verifyActionRecord(byEditor, { trustedKeys: await f.keys.trusted(f.owner.user) }), { verified: false, reason: 'no_trusted_key' }); + assert.deepEqual(await verifyActionRecord(byEditor, { trustedKeys: await f.turnkey.trusted(f.owner.user) }), { verified: false, reason: 'no_trusted_key' }); }); test('the verifier rejects altered payloads, signatures, keys, bindings and every non-signed status', async () => { const f = await fixture(); await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); - await f.signAll(); + await f.runSigner(); const [record] = structuredClone(f.records()); - const trustedKeys = await f.keys.trusted(f.owner.user); + const trustedKeys = await f.turnkey.trusted(f.owner.user); const verify = (changes, options = { trustedKeys }) => verifyActionRecord({ ...record, ...changes }, options).then(r => r.verified ? 'verified' : r.reason); const rewrite = edit => { const payload = JSON.parse(record.payload); edit(payload); const text = canonicalize(payload); return { payload: text, digest: actionRecordDigest(text) }; }; assert.equal(await verify({}), 'verified'); @@ -244,7 +269,7 @@ test('the verifier rejects altered payloads, signatures, keys, bindings and ever assert.equal(await verify({ signature: encodeSignature(signature) }), 'bad_signature'); assert.equal(await verify({ signature: encodeSignature(signature.slice(1)) }), 'not_signed'); await f.call('items', 'uncheckItem', { itemId: 'I1' }); - await f.signAll(); + await f.runSigner(); assert.equal(await verify({ signature: f.records()[1].signature }), 'bad_signature'); // Key: an attacker re-signs with their own key and supplies it in the record. @@ -268,15 +293,52 @@ test('the verifier rejects altered payloads, signatures, keys, bindings and ever assert.equal(await verify({}, { trustedKeys, expected: { ownerDid: f.collaborator.user.did } }), 'binding_mismatch'); assert.equal(await verify({}, { trustedKeys, expected: { credential: { kind: 'apiKey', id: f.owner.accessSession._id } } }), 'binding_mismatch'); assert.equal(await verify({}, { trustedKeys, expected: { ownerDid: f.owner.user.did, credential: { kind: 'session', id: f.owner.accessSession._id } } }), 'verified'); - // Naming another account as owner needs that account's key, even with the original key attached. - const reattributed = { ...rewrite(p => { p.owner = { did: f.collaborator.user.did, userId: f.collaborator.user._id }; }), ownerUserId: f.collaborator.user._id }; - assert.equal(await verify(reattributed), 'no_trusted_key'); - assert.equal(await verify(reattributed, { trustedKeys: { ...trustedKeys, ...await f.keys.trusted(f.collaborator.user) } }), 'untrusted_key'); + // Naming another account as owner needs that account's key, even with the signer's key attached. + const reattributed = rewrite(p => { p.owner = { did: f.collaborator.user.did, userId: f.collaborator.user._id }; }); + assert.equal(await verify({ ...reattributed, ownerUserId: f.collaborator.user._id }), 'binding_mismatch'); + const fully = rewrite(p => { p.owner = { did: f.collaborator.user.did, userId: f.collaborator.user._id }; p.signer.did = f.collaborator.user.did; }); + assert.equal(await verify({ ...fully, ownerUserId: f.collaborator.user._id }, { trustedKeys: { ...trustedKeys, ...await f.turnkey.trusted(f.collaborator.user) } }), 'untrusted_key'); for (const status of ['pending', 'failed', 'unsigned']) assert.equal(await verify({ status }), 'not_signed'); assert.equal(await verify({ signature: undefined }), 'not_signed'); }); +test('signing failures never touch the write, retry with backoff, then settle as failed with a non-secret reason', async () => { + const f = await fixture(); + f.turnkey.fail(true); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + assert.equal(f.rows.items[0].checked, true, 'the mutation committed without any signing'); + const delays = []; + for (let attempt = 1; attempt <= 4; attempt++) { + const [[delay]] = f.signerJobs(); + delays.push(delay); + assert.equal(await f.runSigner(), 1); + assert.deepEqual([f.records()[0].status, f.records()[0].attempts], [attempt < 4 ? 'pending' : 'failed', attempt]); + } + assert.deepEqual(delays, [0, 60_000, 300_000, 1_500_000]); + assert.equal(f.signerJobs().length, 0, 'retries are bounded'); + const [failed] = f.records(); + assert.equal(failed.error, 'signing_request_failed'); + assert.deepEqual([failed.signature, failed.signedAt, failed.publicKeyMultibase], [undefined, undefined, undefined]); + assert.ok(!JSON.stringify(f.records()).includes('api-secret'), 'provider error text is never persisted'); + assert.equal(f.rows.items[0].checked, true); + assert.deepEqual(await verifyActionRecord(failed, { trustedKeys: await f.turnkey.trusted(f.owner.user) }), { verified: false, reason: 'not_signed' }); + + // A failed lookup of the key itself is retried the same way. + const lookup = await fixture(); + await lookup.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + const [[, , job]] = lookup.signerJobs().splice(0); + await signActionRecords(lookup.ctx, job.recordIds, async () => { throw new Error('No wallets found for sub-org'); }); + assert.deepEqual([lookup.records()[0].status, lookup.records()[0].attempts], ['pending', 1]); + + // Operator recovery once the outage is over. + f.turnkey.fail(false); + assert.equal(await modules.actionRecords.requeueFailed._handler(f.ctx, { recordIds: [failed._id] }), 1); + await f.runSigner(); + assert.deepEqual([f.records()[0].status, f.records()[0].error], ['signed', undefined]); + assert.equal((await verifyActionRecord(f.records()[0], { trustedKeys: await f.turnkey.trusted(f.owner.user) })).verified, true); +}); + test('no caller-supplied value can make recording fail the write', async () => { const f = await fixture(); for (const checkedAt of [1700000000000.5, -100000, NaN, Infinity]) { @@ -284,27 +346,109 @@ test('no caller-supplied value can make recording fail the write', async () => { } await f.call('items', 'addItem', { listId: 'L1', name: 'lone surrogate \ud800 "quoted" \n\u2028 ünïcode', createdAt: 1 }); assert.deepEqual(f.payloads('item.completed').map(p => p.after.checkedAt), [1700000000000.5, -100000, null, null]); - assert.ok(f.records().every(r => r.status === 'unsigned')); - await f.signAll(); - const trustedKeys = await f.keys.trusted(f.owner.user); + await f.runSigner(); + const trustedKeys = await f.turnkey.trusted(f.owner.user); for (const record of f.records()) { + assert.equal(record.status, 'signed'); assert.equal((await verifyActionRecord(JSON.parse(JSON.stringify(record)), { trustedKeys })).verified, true); } }); -test('recording never depends on signing infrastructure, so an account without a Turnkey sub-org records the same way', async () => { +test('an account without a Turnkey sub-org gets an explicit unsigned record and no signer run', async () => { const f = await fixture(); // API-key authentication does not depend on a sub-org; the account simply has none. await f.addApiKey('key-one'); delete f.rows.users[0].turnkeySubOrgId; await f.viaKey('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }, 'key-one'); const [record] = f.records(); - assert.deepEqual([record.status, record.signature], ['unsigned', undefined]); + assert.deepEqual([record.status, record.signature, record.attempts], ['unsigned', undefined, 0]); + assert.equal(f.signerJobs().length, 0); assert.equal(f.rows.items[0].checked, true); assert.deepEqual(await verifyActionRecord(record, { trustedKeys: { [f.owner.user.did]: ['z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'] } }), { verified: false, reason: 'not_signed' }); + + // A sub-org removed between the write and the signer run is also recorded as unsigned. + const g = await fixture(); + await g.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + delete g.rows.users[0].turnkeySubOrgId; + await g.runSigner(); + assert.deepEqual([g.records()[0].status, g.turnkey.calls.length], ['unsigned', 0]); +}); + +test('signing is idempotent: repeated and late runs never re-sign or replace a stored signature', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + const [[, , job]] = f.signerJobs(); + await f.runSigner(); + const signed = structuredClone(f.records()[0]); + const resolve = subOrg => turnkeySigningKey(subOrg, f.turnkey.client); + await signActionRecords(f.ctx, job.recordIds, resolve); + await signActionRecords(f.ctx, [...job.recordIds, ...job.recordIds], resolve); + assert.equal(f.turnkey.calls.length, 1); + assert.deepEqual(f.records()[0], signed); + // A duplicate delivery of a different result cannot overwrite the first. + await modules.actionRecords.settle._handler(f.ctx, { outcomes: [ + { kind: 'signed', recordId: signed._id, digest: signed.digest, signature: 'zOther', publicKeyMultibase: 'zOther', verificationMethod: 'did:key:zOther' }, + { kind: 'failed', recordId: signed._id, reason: 'late', retry: true }, + { kind: 'unsigned', recordId: signed._id }, + ] }); + assert.deepEqual(f.records()[0], signed); + assert.equal(f.signerJobs().length, 0); + // A record deleted before its signer runs is skipped. + await f.call('items', 'uncheckItem', { itemId: 'I1' }); + await f.call('items', 'removeItem', { itemId: 'I1' }); + assert.equal(await f.runSigner(), 1); + assert.equal(f.turnkey.calls.length, 1); +}); + +test('the signer refuses bytes or bindings that changed after the write, without calling Turnkey', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 9 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }, f.collaborator); + const [edited, reserialized, misfiled, renamed] = f.rows.actionRecords; + edited.payload = edited.payload.replace('"checkedAt":7', '"checkedAt":8'); + reserialized.payload = JSON.stringify(JSON.parse(reserialized.payload), null, 2); + reserialized.digest = digest(reserialized.payload); + misfiled.ownerUserId = f.collaborator.user._id; + f.rows.users[1].did = 'did:collaborator-reminted'; + await f.runSigner(); + assert.deepEqual(f.records().map(r => [r.status, r.error, r.attempts]), [ + ['failed', 'payload_integrity', 1], ['failed', 'payload_integrity', 1], + ['failed', 'owner_binding_changed', 1], ['failed', 'owner_binding_changed', 1], + ]); + assert.equal(f.turnkey.calls.length, 0); + assert.equal(f.signerJobs().length, 0, 'integrity failures are not retried'); + // A signature that does not verify under the looked-up key is never stored. + const g = await fixture(); + await g.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + const [[, , job]] = g.signerJobs().splice(0); + await signActionRecords(g.ctx, job.recordIds, async () => ({ publicKey: (await g.turnkey.keyFor('org')).publicKey, sign: async () => new Uint8Array(64) })); + assert.deepEqual([g.records()[0].status, g.records()[0].signature, g.records()[0].attempts], ['pending', undefined, 1]); +}); + +test('the sweep re-queues signer runs that never reported back, within the same attempt bound', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }); + f.signerJobs().splice(0).forEach(job => f.effects.splice(f.effects.indexOf(job), 1)); // both signer runs are lost + const sweep = () => modules.actionRecords.sweepStalePending._handler(f.ctx, {}); + for (const row of f.rows.actionRecords) row._creationTime = Date.now(); // the fixture numbers rows instead of timing them + await sweep(); + assert.equal(f.signerJobs().length, 0, 'fresh pending records are left to their scheduled run'); + const hourAgo = Date.now() - 61 * 60_000; + f.rows.actionRecords[0]._creationTime = hourAgo; + f.rows.actionRecords[1]._creationTime = hourAgo; + f.rows.actionRecords[1].lastAttemptAt = Date.now(); // waiting on a backoff retry + await sweep(); + assert.deepEqual(f.signerJobs().map(([delay, , args]) => [delay, args.recordIds]), [[0, [f.rows.actionRecords[0]._id]]]); + assert.deepEqual(f.records().map(r => r.attempts), [1, 0]); + for (let i = 0; i < 3; i++) { f.rows.actionRecords[0].lastAttemptAt = hourAgo; await sweep(); } + assert.deepEqual([f.records()[0].status, f.records()[0].error, f.records()[0].attempts], ['failed', 'signer_unresponsive', 4]); + assert.equal(f.records()[1].status, 'pending'); }); -test('template creation records the list and every item through the same path', async () => { +test('template creation records the list and every item through the same path and signs them in one run', async () => { const f = await fixture(); f.rows.listTemplates = [{ _id: 'T1', name: 'Saved', ownerDid: f.owner.user.did, isPublic: false, items: [{ name: 'Step one', order: 0 }, { name: 'Step two', order: 1 }] }]; const listId = await f.call('templates', 'createListFromTemplate', { templateId: 'T1', listName: 'From template', assetDid: 'did:cel:template' }); @@ -319,9 +463,11 @@ test('template creation records the list and every item through the same path', const [fromTemplate, , added] = f.payloads('item.created'); assert.equal(added.subject.itemId, direct); assert.deepEqual(shape({ ...fromTemplate, origin: undefined }), shape({ ...added, origin: undefined })); - await f.signAll(); - const trustedKeys = await f.keys.trusted(f.owner.user); + assert.deepEqual(f.signerJobs().map(([, , args]) => args.recordIds.length), [3, 1]); + await f.runSigner(); + const trustedKeys = await f.turnkey.trusted(f.owner.user); for (const record of f.records()) assert.equal((await verifyActionRecord(record, { trustedKeys })).verified, true); + assert.equal(f.turnkey.calls.length, 4); }); test('copying a list records the new list only, and notes are recorded as notes', async () => { @@ -343,13 +489,13 @@ test('records are readable with the access of their subject and are removed with await f.call('lists', 'renameList', { listId: 'L1', name: 'Food' }); await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); await f.call('items', 'addItem', { listId: 'L1', name: 'Bread', createdAt: 8 }); - await f.signAll(); + await f.runSigner(); const itemRecords = await f.call('actionRecords', 'getItemActionRecords', { itemId: 'I1' }, f.collaborator); assert.deepEqual(itemRecords.map(r => JSON.parse(r.payload).action), ['item.completed']); const listRecords = await f.call('actionRecords', 'getListActionRecords', { listId: 'L1' }, f.collaborator); assert.deepEqual(listRecords.map(r => JSON.parse(r.payload).action), ['list.renamed']); // What a reader receives is sufficient for independent verification, and nothing internal. - assert.equal((await verifyActionRecord(itemRecords[0], { trustedKeys: await f.keys.trusted(f.owner.user) })).verified, true); + assert.equal((await verifyActionRecord(itemRecords[0], { trustedKeys: await f.turnkey.trusted(f.owner.user) })).verified, true); assert.deepEqual(Object.keys(itemRecords[0]).sort(), ['_id', 'digest', 'error', 'itemId', 'listId', 'ownerUserId', 'payload', 'publicKeyMultibase', 'signature', 'signedAt', 'status', 'verificationMethod']); await assert.rejects(f.call('actionRecords', 'getItemActionRecords', { itemId: 'I1' }, stranger)); await assert.rejects(f.call('actionRecords', 'getListActionRecords', { listId: 'L1' }, stranger)); @@ -362,13 +508,13 @@ test('records are readable with the access of their subject and are removed with assert.equal(fetched.status, 200); const { records } = await fetched.json(); assert.deepEqual(records.map(r => r._id), itemRecords.map(r => r._id)); - assert.equal((await verifyActionRecord(records[0], { trustedKeys: await f.keys.trusted(f.owner.user) })).verified, true); + assert.equal((await verifyActionRecord(records[0], { trustedKeys: await f.turnkey.trusted(f.owner.user) })).verified, true); assert.deepEqual((await (await http('listId=L1', 'reader-key')).json()).records.map(r => JSON.parse(r.payload).action), ['list.renamed']); assert.equal((await http('itemId=I1&listId=L1', 'reader-key')).status, 400); // The standalone verifier accepts that export against a pinned key, and nothing else. const exported = 'tmp/signed-action-records-extra/export.json'; - const pin = Object.entries(await f.keys.trusted(f.owner.user)).map(([did, [key]]) => `${did}=${key}`); + const pin = Object.entries(await f.turnkey.trusted(f.owner.user)).map(([did, [key]]) => `${did}=${key}`); const cli = (...args) => spawnSync(process.execPath, ['scripts/verify-action-records.mjs', ...args], { encoding: 'utf8' }); writeFileSync(exported, JSON.stringify({ records })); const accepted = cli(exported, ...pin); @@ -426,14 +572,14 @@ test('historical placeholders stay untouched and unsigned; a DID re-mint never r proof: JSON.stringify({ '@context': ['https://www.w3.org/2018/credentials/v1'], type: ['VerifiableCredential', 'ItemAuthorshipCredential'], issuer: f.owner.user.did, credentialSubject: { id: f.owner.user.did } }) }; f.rows.items[0].vcProofs = [placeholder]; await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); - await f.signAll(); + await f.runSigner(); assert.deepEqual(f.rows.items[0].vcProofs, [placeholder]); assert.equal(legacyActionEvidence(f.rows.items[0].vcProofs[0].proof), 'unsigned'); assert.equal(legacyActionEvidence(f.records()[0].payload), 'unverified', 'a new record is never mistaken for a legacy placeholder'); const before = structuredClone(f.records()); const oldDid = f.owner.user.did; - const [signingKey] = Object.values(await f.keys.trusted(f.owner.user))[0]; + const [signingKey] = Object.values(await f.turnkey.trusted(f.owner.user))[0]; const result = await modules['migrations/remintUserDidDb'].applyRemint._handler(f.ctx, { userId: f.owner.user._id, oldDid, newDid: 'did:webvh:reminted' }); assert.ok(result.rewritten > 0); assert.equal(f.rows.items[0].createdByDid, 'did:webvh:reminted', 'sanity: the re-mint did rewrite mutable attribution'); diff --git a/scripts/templates.test.mjs b/scripts/templates.test.mjs index 780ca58..ed0036e 100644 --- a/scripts/templates.test.mjs +++ b/scripts/templates.test.mjs @@ -123,7 +123,7 @@ test('retry returns the complete original list without extra items, even at quot assert.ok(ctx.rows.items.every(i => i.checked === false && i.createdByDid === OWNER)); assert.equal((await ctx.db.get(id)).vcProof, undefined, 'no new unsigned ownership placeholder'); assert.equal(records(ctx, 'list.created').length, 1); - assert.deepEqual(ctx.scheduled, [], 'recording schedules no background work'); + assert.equal(ctx.scheduled.length, 1, 'the retry queues no second signer run'); }); test('quick-start and saved template items get one creation record each, like ordinary creation, across retries', async () => { for (const saved of [false, true]) { @@ -153,8 +153,9 @@ test('quick-start and saved template items get one creation record each, like or assert.deepEqual(record.owner, { did: OWNER, userId: ownerSession.user._id }); assert.deepEqual(record.credential, { kind: 'session', id: ownerSession.accessSession._id }); } - assert.equal(ctx.rows.actionRecords.length, items.length + 1); - assert.ok(ctx.rows.actionRecords.every(r => r.status === 'unsigned' && r.signature === undefined)); + // One signer run covers the whole mutation's records. + assert.deepEqual(ctx.scheduled.map(job => job.args.recordIds.length), [items.length + 1]); + assert.ok(ctx.rows.actionRecords.every(r => r.status === 'pending' && r.signature === undefined)); const before = structuredClone([ctx.rows.items, ctx.rows.actionRecords]); assert.equal(await create(ctx, request), listId); assert.deepEqual([ctx.rows.items, ctx.rows.actionRecords], before); diff --git a/shared/actionRecord.ts b/shared/actionRecord.ts index fc4090b..3ca5077 100644 --- a/shared/actionRecord.ts +++ b/shared/actionRecord.ts @@ -7,7 +7,7 @@ import { verifyAsync } from "@noble/ed25519"; export const ACTION_RECORD_SCHEMA = "boop.action-record"; export const ACTION_RECORD_VERSION = 1; -// Domain-separates action signatures from anything else the same key signs. +// Domain-separates action signatures from the DID-log proofs the same key signs. const SIGNING_DOMAIN = `${ACTION_RECORD_SCHEMA}/v${ACTION_RECORD_VERSION}`; /** Row identity of the authenticated session or API key; never a token or hash. */ @@ -65,6 +65,8 @@ export type ActionRecordPayload = ActionChange & { /** Account that owns the credential and authorized the action; not necessarily the list owner. */ owner: { did: string; userId: string }; credential: ActionCredential; + /** Binding the signature is expected under: the owner's own Turnkey-held key. */ + signer: { did: string; custody: "turnkey"; keyType: "Ed25519" }; }; export type ActionRecordStatus = "pending" | "signed" | "failed" | "unsigned"; @@ -122,6 +124,7 @@ export function buildActionRecord(change: ActionChange, context: { occurredAt: context.occurredAt, owner: { did: context.owner.did, userId: context.owner.userId }, credential: { kind: context.credential.kind, id: context.credential.id }, + signer: { did: context.owner.did, custody: "turnkey", keyType: "Ed25519" }, }; const payload = canonicalize(body); return { payload, digest: actionRecordDigest(payload) }; @@ -135,13 +138,14 @@ export function parseActionRecordPayload(payload: string): ActionRecordPayload | let parsed: unknown; try { parsed = JSON.parse(payload); } catch { return null; } if (!isRecord(parsed) || parsed.schema !== ACTION_RECORD_SCHEMA || parsed.version !== ACTION_RECORD_VERSION) return null; - const { action, subject, owner, credential, occurredAt } = parsed; + const { action, subject, owner, credential, signer, occurredAt } = parsed; if (typeof action !== "string" || !ACTION_TYPES.includes(action)) return null; if (!isRecord(subject) || typeof subject.listId !== "string") return null; if (action.startsWith("item.") !== (typeof subject.itemId === "string")) return null; if (!isRecord(owner) || typeof owner.did !== "string" || typeof owner.userId !== "string") return null; if (!isRecord(credential) || typeof credential.id !== "string" || (credential.kind !== "session" && credential.kind !== "apiKey")) return null; + if (!isRecord(signer) || typeof signer.did !== "string" || signer.custody !== "turnkey" || signer.keyType !== "Ed25519") return null; if (typeof occurredAt !== "number" || !isRecord(parsed.after)) return null; return parsed as ActionRecordPayload; } @@ -269,7 +273,8 @@ export async function verifyActionRecord( if (actionRecordDigest(record.payload) !== record.digest) return fail("digest_mismatch"); const itemId = "itemId" in payload.subject ? payload.subject.itemId : undefined; - if ((record.listId !== undefined && record.listId !== payload.subject.listId) + if (payload.signer.did !== payload.owner.did + || (record.listId !== undefined && record.listId !== payload.subject.listId) || (record.itemId !== undefined && record.itemId !== itemId) || (record.ownerUserId !== undefined && record.ownerUserId !== payload.owner.userId)) return fail("binding_mismatch"); const { expected } = options; diff --git a/src/components/ProvenanceInfo.tsx b/src/components/ProvenanceInfo.tsx index 87be143..47a1db5 100644 --- a/src/components/ProvenanceInfo.tsx +++ b/src/components/ProvenanceInfo.tsx @@ -566,19 +566,19 @@ function actionRecordPresentation( ): { status: string; detail: string; tone: string } { const neutral = "text-gray-500 dark:text-gray-400"; if (record.status === "pending") { - return { status: "Pending signature", detail: "Waiting for this record to be signed.", tone: neutral }; + return { status: "Pending signature", detail: "Waiting for the account's Turnkey-held key to sign this record.", tone: neutral }; } if (record.status === "failed") { return { status: "Signing failed", detail: "Signing did not complete, so this record carries no signature.", tone: "text-red-600 dark:text-red-400" }; } if (record.status === "unsigned") { - return { status: "Unsigned", detail: "No signature exists for this record.", tone: neutral }; + return { status: "Unsigned", detail: "This account has no Turnkey signing key, so no signature exists.", tone: neutral }; } if (check === "invalid") { return { status: "Signature check failed", detail: "The stored signature does not match this record. Treat it as unverified.", tone: "text-red-600 dark:text-red-400" }; } return { - status: "Signed", + status: "Signed (Turnkey-held owner key)", detail: check === "verified" ? "Signature verified against the key in the authorizing account's DID." : check === "unbound" @@ -664,9 +664,9 @@ function ActionRecordList({ ))}

- A signature shows that the key recorded with it signed this record, after an - operation authenticated by the named session or API key. It does not show - personal intent or who was using that credential. + A signature shows that the account's Turnkey-held key signed this record at boop's + request, after an operation authenticated by the named session or API key. It does + not show personal intent or who was using that credential.

); diff --git a/tests/fixtures/server.mjs b/tests/fixtures/server.mjs index 52ad5c5..7d2265f 100644 --- a/tests/fixtures/server.mjs +++ b/tests/fixtures/server.mjs @@ -17,7 +17,7 @@ const user = { email: 'e2e@example.test', did: 'did:webvh:e2e:boop.ad:user-e2e', displayName: 'E2E User', }; -// Test-only Ed25519 key for seeding signed records; no real signer exists yet. +// Test-only stand-in for the account's Turnkey-held Ed25519 key. const SIGNING_SEED = new Uint8Array(32).fill(7); const signingKey = ed25519Multikey(await getPublicKeyAsync(SIGNING_SEED)); @@ -27,10 +27,10 @@ const itemSubject = (account, item) => { return { ...(list ? listSubject(list) : { listId: item.listId }), itemId: item._id }; }; -// Mirrors convex/lib/actionRecords.ts recordActions, except the status can be -// seeded via options.actionRecordStatus. Shape matches convex/actionRecords.ts evidence(). +// Mirrors convex/lib/actionRecords.ts recordActions, except the signer settles +// at once to options.actionRecordStatus. Shape matches convex/actionRecords.ts evidence(). async function recordAction(account, change) { - const status = account.options.actionRecordStatus ?? 'unsigned'; + const status = account.options.actionRecordStatus ?? 'signed'; const { payload, digest } = buildActionRecord(change, { occurredAt: Date.now(), owner: { did: user.did, userId: user._id }, credential: { kind: 'session', id: `actorSessions:${account.key}` }, diff --git a/tests/share-modal.e2e.ts b/tests/share-modal.e2e.ts index edd488f..e3af161 100644 --- a/tests/share-modal.e2e.ts +++ b/tests/share-modal.e2e.ts @@ -92,12 +92,12 @@ test.describe("Share / publish flow (POO-14)", () => { ).toBeVisible({ timeout: 10000 }); }); - // Action records (#237): the list's creation record shows its true status. + // Signed action records (#237): the list's creation record shows its true status. for (const [status, label, detail] of [ - ["signed", "Signed", /not independently verified here/], - ["pending", "Pending signature", /Waiting for this record to be signed/], + ["signed", "Signed (Turnkey-held owner key)", /not independently verified here/], + ["pending", "Pending signature", /Waiting for the account's Turnkey-held key/], ["failed", "Signing failed", /carries no signature/], - ["unsigned", "Unsigned", /No signature exists for this record/], + ["unsigned", "Unsigned", /no Turnkey signing key/], ] as const) { test(`8. provenance shows a ${status} list creation record`, async ({ screen, scenario }) => { await openList(scenario, screen, { actionRecordStatus: status });