diff --git a/packages/core/src/inbound/confirm.ts b/packages/core/src/inbound/confirm.ts deleted file mode 100644 index ccea410..0000000 --- a/packages/core/src/inbound/confirm.ts +++ /dev/null @@ -1,211 +0,0 @@ -// RP→wallet confirmation protocol, conformant to the published -// `confirm/{request,response}/0.1` Trust-Task specs (trusttasks-tf). -// -// An RP authcrypts a `confirm/request` to the wallet's holder DID (routed via -// its mediator); the wallet shows a consent prompt and authcrypts a -// `confirm/response` back. Both legs ride the framework DIDComm binding -// (`https://trusttasks.org/binding/didcomm/0.1/envelope`): the DIDComm message -// `type` is always {@link TRUST_TASK_ENVELOPE_TYPE} and its `body` is a full -// `TrustTask` document whose own `type` selects the operation — the same shape -// the wallet already uses for VTA passkey-VM trust-tasks (see -// `../vta/protocol.ts`). -// -// Authentication is two-layered. The authcrypt envelope authenticates the -// transport hop (the wallet trusts the RP because the request is authcrypted -// from the RP's DID; the RP trusts the response's origin because it's -// authcrypted from the holder DID it addressed). On top of that, the -// `confirm/response` carries a W3C Data Integrity `proof` — per the spec the -// proof *is* the consent record: it binds the user's `decision` over the -// `challenge` to a key the subject controls, so the RP can retain it as -// audit-grade evidence independent of the transport. - -import { packAuthcrypt, packAuthcryptJson, wrapForward, type Identity } from "../didcomm/index.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; -import { TRUST_TASK_ENVELOPE_TYPE, type TrustTask } from "../vta/protocol.js"; -import { signTrustTask } from "../trust-tasks/sign.js"; -import type { SigningIdentity } from "../siop/self-issued.js"; - -// Canonical RP→wallet consent specs from trusttasks-tf; payload shapes at -// https://trusttasks.org/spec/confirm/request/0.1 and /response/0.1. -export const CONFIRM_REQUEST_TYPE = "https://trusttasks.org/spec/confirm/request/0.1"; -export const CONFIRM_RESPONSE_TYPE = "https://trusttasks.org/spec/confirm/response/0.1"; - -/** Payload of an inbound `confirm/request/0.1` (RP → wallet). */ -export interface ConfirmRequestPayload { - /** The VID whose consent the RP is asking for. The wallet MUST verify it - * speaks for this subject. */ - subject: string; - /** RP-issued nonce (base64url, ≥128 bits). Echoed and signed in the - * response so the RP can correlate and prevent replay. */ - challenge: string; - /** Human-readable action description, shown to the user verbatim. */ - reason: string; - /** Optional machine-readable action category (e.g. `payment.transfer`). */ - actionType?: string; - /** Optional structured action detail the wallet MAY surface. */ - actionDetails?: Record; - /** Advisory seconds within which the RP expects a response. */ - ttl?: number; -} - -/** Payload of the `confirm/response/0.1` the wallet returns. */ -export interface ConfirmResponsePayload { - /** Echoed from the request. */ - subject: string; - /** Echoed from the request. */ - challenge: string; - /** The user's signed decision. */ - decision: "approved" | "denied"; - /** Required by the spec when `decision` is `denied`. */ - deniedReason?: string; -} - -/** A parsed, validated inbound `confirm/request`. */ -export interface ParsedConfirmRequest { - /** The requesting RP's DID (the authcrypt sender, cross-checked against the - * document `issuer` when present). */ - rpDid: string; - /** Thread id to echo on the response so the RP correlates it. */ - thid: string; - request: ConfirmRequestPayload; -} - -/** - * Validate a decrypted inbound DIDComm message as a `confirm/request/0.1` - * document carried over the Trust-Task DIDComm binding. Returns `null` if it - * isn't one (so an `onInbound` handler can ignore other traffic). The `from` - * field is the authcrypt-authenticated RP DID. - */ -export function parseConfirmRequest( - message: Record, -): ParsedConfirmRequest | null { - if (message.type !== TRUST_TASK_ENVELOPE_TYPE) return null; - const from = typeof message.from === "string" ? message.from : null; - if (!from) return null; - - const doc = (message.body ?? {}) as Partial>>; - if (doc.type !== CONFIRM_REQUEST_TYPE) return null; - // In-band issuer, when present, must match the transport sender (SPEC §4.8.1 - // — the in-band identity is authoritative and the transport is a cross-check; - // a mismatch is a validation failure). - if (typeof doc.issuer === "string" && doc.issuer !== from) return null; - - const payload = (doc.payload ?? {}) as Partial; - if ( - typeof payload.subject !== "string" || - typeof payload.challenge !== "string" || - typeof payload.reason !== "string" - ) { - return null; - } - - const thid = - (typeof message.thid === "string" ? message.thid : undefined) ?? - (typeof doc.threadId === "string" ? doc.threadId : undefined) ?? - (typeof doc.id === "string" ? doc.id : undefined) ?? - (typeof message.id === "string" ? message.id : ""); - - return { - rpDid: from, - thid, - request: { - subject: payload.subject, - challenge: payload.challenge, - reason: payload.reason, - ...(typeof payload.actionType === "string" ? { actionType: payload.actionType } : {}), - ...(payload.actionDetails && typeof payload.actionDetails === "object" - ? { actionDetails: payload.actionDetails as Record } - : {}), - ...(typeof payload.ttl === "number" ? { ttl: payload.ttl } : {}), - }, - }; -} - -export interface BuildConfirmResponseArgs { - /** The wallet's holder identity (authcrypt sender of the response). */ - holder: Identity; - /** The wallet's Ed25519 signing identity — signs the Data Integrity proof. - * Its `did` is the response `subject`/`issuer` and its `kid` the proof's - * `verificationMethod`. */ - signing: SigningIdentity; - /** The RP's resolved keyAgreement endpoint (authcrypt recipient). */ - rp: RemoteDidcommEndpoint; - /** Mediator to forward through (the shared mediator for the demo). */ - mediator: RemoteDidcommEndpoint; - /** The user's decision. */ - approved: boolean; - /** The request's challenge, echoed back for correlation + binding. */ - challenge: string; - /** The request's `subject`, echoed back verbatim. */ - subject: string; - /** The request's thread id, echoed as the response `thid`. */ - thid: string; - /** Human-readable rationale, attached when the user denies. */ - deniedReason?: string; -} - -/** - * Assemble and sign the `confirm/response/0.1` Trust-Task document. The proof - * IS the consent record: the document is signed with the subject's key - * (`proofPurpose: assertionMethod`) so the RP can retain it as audit-grade - * evidence of the user's decision, independent of the transport. Split out - * from {@link buildConfirmResponse} so it's directly unit-testable (the packed - * form is a double-authcrypted JWE that can't be inspected without the RP and - * mediator private keys). - */ -export async function buildConfirmResponseDocument( - args: Pick, -): Promise & { proof?: unknown }> { - const decision: "approved" | "denied" = args.approved ? "approved" : "denied"; - const payload: ConfirmResponsePayload = { - subject: args.subject, - challenge: args.challenge, - decision, - ...(decision === "denied" && args.deniedReason ? { deniedReason: args.deniedReason } : {}), - }; - - const document: TrustTask & { proof?: unknown } = { - id: globalThis.crypto.randomUUID(), - type: CONFIRM_RESPONSE_TYPE, - issuer: args.signing.did, - recipient: args.rp.did, - threadId: args.thid, - issuedAt: new Date().toISOString(), - payload, - }; - - await signTrustTask({ - envelope: document as unknown as Record & { proof?: unknown }, - signing: args.signing, - proofPurpose: "assertionMethod", - }); - return document; -} - -/** - * Build the outer (routing/2.0/forward) JWE for a `confirm/response/0.1`, - * ready to `send()` over the wallet's mediator session. Wraps the signed - * Trust-Task document in the DIDComm binding envelope, authcrypts it to the - * RP, then wraps that in a forward to the mediator — the same outbound shape - * as `loginViaDidcomm`/`requestVtaApproval`. - */ -export async function buildConfirmResponse(args: BuildConfirmResponseArgs): Promise { - const document = await buildConfirmResponseDocument(args); - - const message = { - id: document.id, - type: TRUST_TASK_ENVELOPE_TYPE, - from: args.holder.did, - to: [args.rp.did], - thid: args.thid, - body: document, - }; - - const inner = await packAuthcrypt(message, args.holder, [ - { kid: args.rp.keyAgreementKid, jwk: args.rp.keyAgreementPublicJwk }, - ]); - const forwardJson = wrapForward(args.rp.did, args.holder.did, args.mediator.did, inner); - return packAuthcryptJson(forwardJson, args.holder, [ - { kid: args.mediator.keyAgreementKid, jwk: args.mediator.keyAgreementPublicJwk }, - ]); -} diff --git a/packages/core/src/inbound/index.ts b/packages/core/src/inbound/index.ts index 4ba247f..e649061 100644 --- a/packages/core/src/inbound/index.ts +++ b/packages/core/src/inbound/index.ts @@ -1,4 +1,3 @@ -export * from "./confirm.js"; export * from "./task-consent.js"; export * from "./effect-format.js"; export * from "./dedup.js"; diff --git a/packages/core/src/inbound/task-consent.ts b/packages/core/src/inbound/task-consent.ts index b8db3f8..ad2ffa0 100644 --- a/packages/core/src/inbound/task-consent.ts +++ b/packages/core/src/inbound/task-consent.ts @@ -7,8 +7,9 @@ // // ## Why this is not `confirm/*` // -// The superficially similar `confirm/request` (see `./confirm.ts`) carries an -// **RP-authored `reason` shown to the user verbatim**, and that is correct there: +// The superficially similar `confirm/request` (retired ecosystem-wide; the +// registry marks it supersededBy task-consent) carried an +// **RP-authored `reason` shown to the user verbatim**, and that was correct there: // in `confirm/*` the relying party holds the authority and is merely asking a // human to vouch for something it will then do itself. The RP is the executing // party, so RP-authored prose is prose from the party who will act. @@ -18,9 +19,12 @@ // the requester could author what the human reads, it would be writing the basis // of a decision that authorizes it — while every signature still verified. So: // -// **This module renders only content it has verified came from the user's own -// VTA.** A request whose proof does not verify, or which was signed by anyone -// other than the VTA this device is enrolled with, MUST NOT reach a human. +// **This module renders only content it has verified came from an executor +// this device is enrolled with.** A request whose proof does not verify, or +// which was signed by anyone outside the enrolled-executor set — the user's +// own VTA(s) plus any other executors the operator has enrolled (e.g. a +// DID-hosting control plane that signs task-consent requests) — MUST NOT +// reach a human. // // ## Why the effects, and not the payload // @@ -139,8 +143,10 @@ export interface TaskConsentRequestPayload { } export interface ParsedTaskConsentRequest { - /** The VTA that signed it — verified, not merely claimed. */ - vtaDid: string; + /** The enrolled executor that signed it — verified, not merely claimed. + * Decisions are routed back to this DID (the issuer awaiting the answer), + * which for the classic flow is the device's own VTA. */ + executorDid: string; request: TaskConsentRequestPayload; thid: string; } @@ -156,9 +162,11 @@ export type ParseTaskConsentResult = | { ok: false; reason: TaskConsentRequestRejection; detail?: string }; export interface ParseTaskConsentOptions { - /** The VTA this device is enrolled with. A request signed by anyone else is - * refused — it does not matter how well-formed it is. */ - expectedVtaDid: string; + /** The executors this device is enrolled with: its own VTA DID(s), plus any + * additional executor DIDs the operator has enrolled (e.g. a DID-hosting + * control plane). A request signed by any DID outside this set is refused — + * it does not matter how well-formed it is. */ + enrolledExecutorDids: readonly string[]; /** This device's holder DID: who the request must be addressed to, and who it * would be approving as. */ holderDid: string; @@ -205,10 +213,10 @@ export async function parseTaskConsentRequest( if (!verification.verified) { return reject("untrusted_issuer", verification.reason ?? "proof did not verify"); } - if (verification.signer !== opts.expectedVtaDid) { + if (!verification.signer || !opts.enrolledExecutorDids.includes(verification.signer)) { return reject( "untrusted_issuer", - `signed by ${verification.signer ?? "an unknown key"}, not this device's VTA`, + `signed by ${verification.signer ?? "an unknown key"}, not an executor this device is enrolled with`, ); } // The in-band issuer must agree with the proven signer (SPEC §4.8.1). @@ -264,7 +272,7 @@ export async function parseTaskConsentRequest( return { ok: true, parsed: { - vtaDid: verification.signer, + executorDid: verification.signer, thid, request: payload as TaskConsentRequestPayload, }, diff --git a/packages/core/src/rp-login/step-up.ts b/packages/core/src/rp-login/step-up.ts index 9671b28..5753e68 100644 --- a/packages/core/src/rp-login/step-up.ts +++ b/packages/core/src/rp-login/step-up.ts @@ -18,6 +18,7 @@ // camelCase login responses). import { signTrustTask } from "../trust-tasks/sign.js"; +import { verifyTrustTaskProof } from "../trust-tasks/verify.js"; import type { SigningIdentity } from "../siop/self-issued.js"; import type { TrustTask } from "../vta/protocol.js"; import { withFetchTimeout } from "../http/timeout-fetch.js"; @@ -25,8 +26,16 @@ import { withFetchTimeout } from "../http/timeout-fetch.js"; // Canonical step-up approval spec from trusttasks-tf. The proof on the // approve-response is what the RP verifies to elevate the session's acr. const MSG_APPROVE_RESPONSE = "https://trusttasks.org/spec/auth/step-up/approve-response/0.2"; +/** The RP→approver request halves this wallet accepts. 0.2 is what the + * did-hosting control plane mints on `start`; 0.1 is the VTA-pushed flavor + * (same required payload members) — both are gated identically. */ +export const STEP_UP_APPROVE_REQUEST_TYPES = [ + "https://trusttasks.org/spec/auth/step-up/approve-request/0.2", + "https://trusttasks.org/spec/auth/step-up/approve-request/0.1", +] as const; -/** The RP's `approve-request/0.2` payload, returned by {@link stepUpVtaStart}. */ +/** The RP's `approve-request/0.2` payload, verified out of the signed + * Trust-Task document by {@link verifyStepUpApproveRequest}. */ export interface StepUpApproveRequest { /** The VID whose session is being elevated — the wallet must speak for it. */ subject: string; @@ -38,6 +47,141 @@ export interface StepUpApproveRequest { reason?: string; } +/** Raw body of the RP's step-up `start` response: the legacy top-level fields + * plus the signed `auth/step-up/approve-request/0.2` Trust-Task `document`. + * Nothing here is trusted until {@link verifyStepUpApproveRequest} passes — + * in particular the legacy fields exist only for the cross-check; every value + * the wallet acts on comes out of the verified document. */ +export interface StepUpStartResponse { + subject?: string; + sessionId?: string; + challenge?: string; + reason?: string; + /** The full signed `auth/step-up/approve-request/0.2` document. REQUIRED — + * a start response without it is refused (the proofless legacy path was + * removed deliberately once the control plane began signing requests). */ + document?: Record; +} + +export type VerifyStepUpApproveRequestResult = + | { + ok: true; + /** Built ONLY from the verified document's payload — never from the + * legacy top-level fields. */ + request: StepUpApproveRequest; + /** The proven signer (== the document's `issuer`). */ + issuer: string; + expiresAt?: string; + } + | { ok: false; reason: string }; + +export interface VerifyStepUpApproveRequestOptions { + /** The executors this wallet is enrolled with (its VTA DID(s) plus any + * operator-enrolled executor DIDs, e.g. the webvh control plane). The + * approve-request's proven signer must be in this set. */ + enrolledExecutorDids: readonly string[]; + /** Defaults to now. Injected for tests. */ + now?: Date; +} + +/** + * Verify an RP step-up approve-request before anything derived from it is + * shown to a human or signed over. + * + * Spec rule (auth/step-up/approve-request/0.2): the `reason` is the basis of + * the user's consent decision, so "consumers MUST verify the proof BEFORE + * surfacing the reason". Accordingly: + * + * - the signed `document` is REQUIRED — a start response without one is + * refused outright (the legacy proofless `{subject, sessionId, challenge, + * reason}` path was removed deliberately; the control plane now always + * returns a signed document); + * - its Data-Integrity proof must verify (`eddsa-jcs-2022`, + * `assertionMethod`), the in-band `issuer` must equal the proven signer, + * and that signer must be an executor this wallet is enrolled with; + * - when the legacy top-level fields are also present they must agree with + * the verified payload (a mismatch means someone altered the unsigned + * copy — refuse rather than guess); + * - the returned request is built ONLY from the verified document. + */ +export async function verifyStepUpApproveRequest( + start: StepUpStartResponse, + opts: VerifyStepUpApproveRequestOptions, +): Promise { + const refuse = (reason: string): VerifyStepUpApproveRequestResult => ({ ok: false, reason }); + + const doc = start.document; + if (!doc || typeof doc !== "object") { + return refuse( + "start response carried no signed approve-request document — refusing the proofless legacy shape", + ); + } + const type = doc.type; + if (typeof type !== "string" || !(STEP_UP_APPROVE_REQUEST_TYPES as readonly string[]).includes(type)) { + return refuse(`document type ${String(type)} is not a step-up approve-request`); + } + + const verification = await verifyTrustTaskProof(doc, { + expectedProofPurpose: "assertionMethod", + }); + if (!verification.verified || !verification.signer) { + return refuse(verification.reason ?? "proof did not verify"); + } + if (typeof doc.issuer !== "string" || doc.issuer !== verification.signer) { + return refuse("issuer does not match the proven signer"); + } + if (!opts.enrolledExecutorDids.includes(verification.signer)) { + return refuse( + `signed by ${verification.signer}, not an executor this wallet is enrolled with`, + ); + } + + const payload = (doc.payload ?? {}) as { + subject?: unknown; + sessionId?: unknown; + challenge?: unknown; + reason?: unknown; + expiresAt?: unknown; + }; + if ( + typeof payload.subject !== "string" || + typeof payload.sessionId !== "string" || + typeof payload.challenge !== "string" + ) { + return refuse("verified document is missing subject/sessionId/challenge"); + } + + // Legacy top-level fields, when present, must agree with what was signed. + // They carry no authority of their own; a disagreement means the unsigned + // copy was altered in flight and nothing here should be acted on. + for (const k of ["subject", "sessionId", "challenge"] as const) { + if (typeof start[k] === "string" && start[k] !== payload[k]) { + return refuse(`legacy field ${k} does not match the signed document`); + } + } + + if (typeof payload.expiresAt === "string") { + const expiry = new Date(payload.expiresAt); + if (Number.isNaN(expiry.getTime()) || expiry <= (opts.now ?? new Date())) { + return refuse(`approve-request lapsed at ${payload.expiresAt}`); + } + } + + return { + ok: true, + issuer: verification.signer, + request: { + subject: payload.subject, + sessionId: payload.sessionId, + challenge: payload.challenge, + // The reason a human may be shown comes from inside the signature, never + // from the unsigned top-level copy. + ...(typeof payload.reason === "string" ? { reason: payload.reason } : {}), + }, + ...(typeof payload.expiresAt === "string" ? { expiresAt: payload.expiresAt } : {}), + }; +} + /** Payload of the `approve-response/0.2` the wallet signs. */ export interface StepUpApproveResponsePayload { subject: string; @@ -98,14 +242,16 @@ export async function buildStepUpApproval( /** * Step 1 — RP start. Authenticated with the existing `aal1` access token, - * returns the `approve-request` fields the wallet echoes into the signed - * approve-response. + * returns the raw start response: the signed `approve-request` `document` + * plus the legacy top-level fields. **Nothing in it is trusted yet** — the + * caller MUST pass it through {@link verifyStepUpApproveRequest} before + * surfacing or signing anything derived from it. */ export async function stepUpVtaStart( baseUrl: string, accessToken: string, fetchFn?: typeof fetch, -): Promise { +): Promise { const f = withFetchTimeout(fetchFn); const base = baseUrl.replace(/\/+$/, ""); const res = await f(`${base}/auth/step-up/vta/start`, { @@ -119,15 +265,19 @@ export async function stepUpVtaStart( if (!res.ok) { throw new Error(`vta step-up start: failed (${res.status}): ${await res.text()}`); } - const json = (await res.json()) as Partial; - if (!json.subject || !json.sessionId || !json.challenge) { + const json = (await res.json()) as unknown; + if (!json || typeof json !== "object") { throw new Error(`vta step-up start: malformed response: ${JSON.stringify(json)}`); } + const body = json as Record; return { - subject: json.subject, - sessionId: json.sessionId, - challenge: json.challenge, - ...(json.reason ? { reason: json.reason } : {}), + ...(typeof body.subject === "string" ? { subject: body.subject } : {}), + ...(typeof body.sessionId === "string" ? { sessionId: body.sessionId } : {}), + ...(typeof body.challenge === "string" ? { challenge: body.challenge } : {}), + ...(typeof body.reason === "string" ? { reason: body.reason } : {}), + ...(body.document && typeof body.document === "object" + ? { document: body.document as Record } + : {}), }; } diff --git a/packages/core/src/trust-tasks/verify.ts b/packages/core/src/trust-tasks/verify.ts index 6729cb5..c0dfe38 100644 --- a/packages/core/src/trust-tasks/verify.ts +++ b/packages/core/src/trust-tasks/verify.ts @@ -1,8 +1,9 @@ // Verify a W3C Data Integrity proof (`eddsa-jcs-2022`) on a Trust-Task // envelope — the inverse of `./sign.ts`. The wallet uses this to verify the -// `proof` on inbound proof-bearing Trust-Task documents (e.g. a spec-conformant -// `confirm/request` whose `reason` is bound to the RP's key), mirroring the -// Rust `verify_trust_task_proof` in the VTA. +// `proof` on inbound proof-bearing Trust-Task documents (e.g. a +// `task-consent/request` whose effects are bound to the executor's key, or a +// step-up `approve-request` whose `reason` is bound to the RP's key), +// mirroring the Rust `verify_trust_task_proof` in the VTA. // // Same canonicalization (JCS / RFC 8785) and signing input as `sign.ts`: // SHA-256(JCS(proofConfig-minus-proofValue)) || SHA-256(JCS(doc-minus-proof)), diff --git a/packages/core/tests/inbound.confirm.mjs b/packages/core/tests/inbound.confirm.mjs deleted file mode 100644 index 443b797..0000000 --- a/packages/core/tests/inbound.confirm.mjs +++ /dev/null @@ -1,197 +0,0 @@ -// Round-trip test for the spec-conformant `confirm/{request,response}/0.1` -// flow. Proves: -// 1. `buildConfirmResponseDocument` emits a spec-shaped Trust-Task document -// (issuer/recipient/payload{subject,challenge,decision}) with an -// eddsa-jcs-2022 proof that verifies against the holder's did:key — this -// is the exact document + proof the RP (rp-sdk-js) must verify. -// 2. Tampering with the signed payload breaks verification. -// 3. `parseConfirmRequest` accepts a spec-shaped request over the DIDComm -// binding and rejects malformed / non-confirm traffic. - -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { - buildConfirmResponseDocument, - parseConfirmRequest, - verifyTrustTaskProof, - signTrustTask, - generateSigningIdentity, - CONFIRM_REQUEST_TYPE, - CONFIRM_RESPONSE_TYPE, - TRUST_TASK_ENVELOPE_TYPE, -} from "../dist/index.js"; - -const CHALLENGE = "VHJhbnNmZXJDb25maXJtTm9uY2VYWQ"; // ≥128-bit base64url nonce - -test("buildConfirmResponseDocument: signed approved response verifies", async () => { - const holder = generateSigningIdentity(); - const rpDid = generateSigningIdentity().did; - - const doc = await buildConfirmResponseDocument({ - signing: holder, - rp: { did: rpDid }, - approved: true, - subject: holder.did, - challenge: CHALLENGE, - thid: "req-thread-1", - }); - - // Spec document shape. - assert.equal(doc.type, CONFIRM_RESPONSE_TYPE); - assert.equal(doc.issuer, holder.did); - assert.equal(doc.recipient, rpDid); - assert.equal(doc.threadId, "req-thread-1"); - assert.equal(doc.payload.subject, holder.did); - assert.equal(doc.payload.challenge, CHALLENGE); - assert.equal(doc.payload.decision, "approved"); - assert.ok(!("deniedReason" in doc.payload), "approved response has no deniedReason"); - assert.equal(doc.proof.type, "DataIntegrityProof"); - assert.equal(doc.proof.cryptosuite, "eddsa-jcs-2022"); - assert.equal(doc.proof.proofPurpose, "assertionMethod"); - assert.equal(doc.proof.verificationMethod, holder.kid); - - // The proof IS the consent record — it must verify against the subject key. - const result = await verifyTrustTaskProof(doc, { expectedProofPurpose: "assertionMethod" }); - assert.equal(result.verified, true, result.reason); - assert.equal(result.signer, holder.did); -}); - -test("buildConfirmResponseDocument: denied response carries a signed deniedReason", async () => { - const holder = generateSigningIdentity(); - const rpDid = generateSigningIdentity().did; - - const doc = await buildConfirmResponseDocument({ - signing: holder, - rp: { did: rpDid }, - approved: false, - subject: holder.did, - challenge: CHALLENGE, - thid: "req-thread-2", - deniedReason: "User does not recognize this transfer.", - }); - - assert.equal(doc.payload.decision, "denied"); - assert.equal(doc.payload.deniedReason, "User does not recognize this transfer."); - const result = await verifyTrustTaskProof(doc); - assert.equal(result.verified, true, result.reason); -}); - -test("verifyTrustTaskProof: rejects a tampered decision", async () => { - const holder = generateSigningIdentity(); - const doc = await buildConfirmResponseDocument({ - signing: holder, - rp: { did: generateSigningIdentity().did }, - approved: false, // signed as denied… - subject: holder.did, - challenge: CHALLENGE, - thid: "t", - }); - doc.payload.decision = "approved"; // …flipped after signing - - const result = await verifyTrustTaskProof(doc); - assert.equal(result.verified, false); -}); - -test("verifyTrustTaskProof: rejects a wrong required proofPurpose", async () => { - const holder = generateSigningIdentity(); - const doc = await buildConfirmResponseDocument({ - signing: holder, - rp: { did: generateSigningIdentity().did }, - approved: true, - subject: holder.did, - challenge: CHALLENGE, - thid: "t", - }); - const result = await verifyTrustTaskProof(doc, { expectedProofPurpose: "authentication" }); - assert.equal(result.verified, false); -}); - -test("parseConfirmRequest: accepts a spec request over the DIDComm binding", async () => { - const rp = generateSigningIdentity(); - const subject = generateSigningIdentity().did; - - const requestDoc = { - id: "confirm-req-1", - type: CONFIRM_REQUEST_TYPE, - issuer: rp.did, - recipient: subject, - issuedAt: "2026-05-23T18:00:00Z", - payload: { - subject, - challenge: CHALLENGE, - reason: "Confirm transfer of $1,000 to did:web:bob.example", - actionType: "payment.transfer", - actionDetails: { amount: "1000", currency: "USD" }, - ttl: 180, - }, - }; - await signTrustTask({ envelope: requestDoc, signing: rp, proofPurpose: "assertionMethod" }); - - const message = { - id: requestDoc.id, - type: TRUST_TASK_ENVELOPE_TYPE, - from: rp.did, - to: [subject], - thid: "confirm-req-1", - body: requestDoc, - }; - - const parsed = parseConfirmRequest(message); - assert.ok(parsed, "parsed a valid confirm request"); - assert.equal(parsed.rpDid, rp.did); - assert.equal(parsed.thid, "confirm-req-1"); - assert.equal(parsed.request.subject, subject); - assert.equal(parsed.request.challenge, CHALLENGE); - assert.equal(parsed.request.reason, "Confirm transfer of $1,000 to did:web:bob.example"); - assert.equal(parsed.request.actionType, "payment.transfer"); - assert.deepEqual(parsed.request.actionDetails, { amount: "1000", currency: "USD" }); - assert.equal(parsed.request.ttl, 180); - - // The RP's request proof is verifiable too (the wallet MAY verify it). - const rpProof = await verifyTrustTaskProof(requestDoc); - assert.equal(rpProof.verified, true, rpProof.reason); - assert.equal(rpProof.signer, rp.did); -}); - -test("parseConfirmRequest: rejects non-binding, wrong-type, and issuer-mismatch traffic", () => { - const rp = generateSigningIdentity(); - const subject = generateSigningIdentity().did; - const goodBody = { - type: CONFIRM_REQUEST_TYPE, - issuer: rp.did, - payload: { subject, challenge: CHALLENGE, reason: "why" }, - }; - - // Not the binding envelope type. - assert.equal(parseConfirmRequest({ type: CONFIRM_REQUEST_TYPE, from: rp.did, body: goodBody }), null); - // No authcrypt sender. - assert.equal(parseConfirmRequest({ type: TRUST_TASK_ENVELOPE_TYPE, body: goodBody }), null); - // Body isn't a confirm/request document. - assert.equal( - parseConfirmRequest({ - type: TRUST_TASK_ENVELOPE_TYPE, - from: rp.did, - body: { type: "https://trusttasks.org/spec/other/1.0", payload: {} }, - }), - null, - ); - // In-band issuer contradicts the transport sender (SPEC §4.8.1). - assert.equal( - parseConfirmRequest({ - type: TRUST_TASK_ENVELOPE_TYPE, - from: "did:key:zSomeoneElse", - body: goodBody, - }), - null, - ); - // Missing a required payload field (reason). - assert.equal( - parseConfirmRequest({ - type: TRUST_TASK_ENVELOPE_TYPE, - from: rp.did, - body: { type: CONFIRM_REQUEST_TYPE, issuer: rp.did, payload: { subject, challenge: CHALLENGE } }, - }), - null, - ); -}); diff --git a/packages/core/tests/inbound.task-consent.mjs b/packages/core/tests/inbound.task-consent.mjs index 997f1af..890860e 100644 --- a/packages/core/tests/inbound.task-consent.mjs +++ b/packages/core/tests/inbound.task-consent.mjs @@ -17,6 +17,9 @@ import { TRUST_TASK_ENVELOPE_TYPE } from "../dist/vta/protocol.js"; // Real did:key identities — the wallet's own minting helper, so the DID, the // verification method and the key actually agree with what the verifier resolves. const VTA = generateSigningIdentity(); +// A second enrolled executor — e.g. a DID-hosting control plane that signs +// task-consent requests. Enrolled below, unlike IMPOSTOR. +const CONTROL_PLANE = generateSigningIdentity(); const IMPOSTOR = generateSigningIdentity(); const DEVICE = generateSigningIdentity(); const HOLDER = DEVICE.did; @@ -63,15 +66,21 @@ async function inbound({ as = VTA, over = {}, drop = [], recipient = HOLDER, uns return { id: doc.id, type: TRUST_TASK_ENVELOPE_TYPE, from: as.did, body: doc }; } -const opts = { expectedVtaDid: VTA.did, holderDid: HOLDER }; +const opts = { enrolledExecutorDids: [VTA.did, CONTROL_PLANE.did], holderDid: HOLDER }; test("a request signed by this device's VTA is accepted", async () => { const res = await parseTaskConsentRequest(await inbound(), opts); assert.equal(res.ok, true); - assert.equal(res.parsed.vtaDid, VTA.did); + assert.equal(res.parsed.executorDid, VTA.did); assert.equal(res.parsed.request.taskType, "https://trusttasks.org/spec/webvh/dids/update/1.0"); }); +test("a request signed by any enrolled executor (e.g. a control plane) is accepted", async () => { + const res = await parseTaskConsentRequest(await inbound({ as: CONTROL_PLANE }), opts); + assert.equal(res.ok, true); + assert.equal(res.parsed.executorDid, CONTROL_PLANE.did); +}); + test("an unsigned request never reaches a human", async () => { // The transport authenticates the hop, not the content. Without the proof, // anything that can reach this device could author the effects the user reads. @@ -80,11 +89,11 @@ test("an unsigned request never reaches a human", async () => { assert.equal(res.reason, "untrusted_issuer"); }); -test("a request signed by someone other than this device's VTA is refused", async () => { +test("a request signed by an executor this device is NOT enrolled with is refused", async () => { const res = await parseTaskConsentRequest(await inbound({ as: IMPOSTOR }), opts); assert.equal(res.ok, false); assert.equal(res.reason, "untrusted_issuer"); - assert.match(res.detail, /not this device's VTA/); + assert.match(res.detail, /not an executor this device is enrolled with/); }); test("a tampered request is refused — the effects are inside the signature", async () => { diff --git a/packages/core/tests/rp-login.step-up.mjs b/packages/core/tests/rp-login.step-up.mjs index af0988f..9fec3ff 100644 --- a/packages/core/tests/rp-login.step-up.mjs +++ b/packages/core/tests/rp-login.step-up.mjs @@ -10,11 +10,15 @@ import assert from "node:assert/strict"; import { buildStepUpApproval, + verifyStepUpApproveRequest, verifyTrustTaskProof, generateSigningIdentity, + signTrustTask, } from "../dist/index.js"; const APPROVE_RESPONSE_TYPE = "https://trusttasks.org/spec/auth/step-up/approve-response/0.2"; +const APPROVE_REQUEST_TYPE_02 = "https://trusttasks.org/spec/auth/step-up/approve-request/0.2"; +const APPROVE_REQUEST_TYPE_01 = "https://trusttasks.org/spec/auth/step-up/approve-request/0.1"; test("buildStepUpApproval: signed approved response echoes the request and verifies", async () => { const holder = generateSigningIdentity(); @@ -66,3 +70,127 @@ test("buildStepUpApproval: tampering the signed challenge breaks verification", const result = await verifyTrustTaskProof(doc); assert.equal(result.verified, false); }); + +// ── verifyStepUpApproveRequest: the inbound half ───────────────────────────── +// +// The RP's `start` response now carries a full signed +// `auth/step-up/approve-request/0.2` document. Every field the wallet surfaces +// or echoes into the signed approve-response must come from *inside* that +// signature, and the signer must be an executor the wallet is enrolled with. + +const RP = generateSigningIdentity(); // the control plane / RP — enrolled +const STRANGER = generateSigningIdentity(); // not enrolled + +function requestPayload(over = {}) { + return { + subject: "did:key:zSubject", + sessionId: "sess-42", + challenge: "a".repeat(32), + reason: "Confirm the transfer of $1,000 to ACME Corp.", + expiresAt: new Date(Date.now() + 300_000).toISOString(), + ...over, + }; +} + +/** A signed approve-request document, plus the legacy top-level echo. */ +async function startResponse({ + as = RP, + type = APPROVE_REQUEST_TYPE_02, + over = {}, + unsigned = false, + legacy = true, + withDocument = true, +} = {}) { + const payload = requestPayload(over); + const document = { + id: "step-up-req-1", + type, + issuer: as.did, + recipient: "did:key:zApprover", + issuedAt: new Date().toISOString(), + payload, + }; + if (!unsigned) await signTrustTask({ envelope: document, signing: as }); + return { + ...(legacy + ? { subject: payload.subject, sessionId: payload.sessionId, challenge: payload.challenge } + : {}), + ...(withDocument ? { document } : {}), + }; +} + +const enrolled = { enrolledExecutorDids: [RP.did] }; + +test("verifyStepUpApproveRequest: a signed request from an enrolled executor verifies, fields come from the document", async () => { + const start = await startResponse(); + // Tamper the *legacy* reason only — it carries no authority and is not + // cross-checked; what the human sees must come from inside the signature. + start.reason = "Totally harmless, do not read the signed copy."; + const res = await verifyStepUpApproveRequest(start, enrolled); + assert.equal(res.ok, true, res.reason); + assert.equal(res.issuer, RP.did); + assert.equal(res.request.subject, "did:key:zSubject"); + assert.equal(res.request.sessionId, "sess-42"); + assert.equal(res.request.challenge, "a".repeat(32)); + assert.equal(res.request.reason, "Confirm the transfer of $1,000 to ACME Corp."); +}); + +test("verifyStepUpApproveRequest: no document → refused (the proofless legacy shape never prompts)", async () => { + const res = await verifyStepUpApproveRequest( + await startResponse({ withDocument: false }), + enrolled, + ); + assert.equal(res.ok, false); + assert.match(res.reason, /no signed approve-request document/); +}); + +test("verifyStepUpApproveRequest: a signer the wallet is not enrolled with is refused", async () => { + const res = await verifyStepUpApproveRequest(await startResponse({ as: STRANGER }), enrolled); + assert.equal(res.ok, false); + assert.match(res.reason, /not an executor this wallet is enrolled with/); +}); + +test("verifyStepUpApproveRequest: an unsigned document is refused", async () => { + const res = await verifyStepUpApproveRequest(await startResponse({ unsigned: true }), enrolled); + assert.equal(res.ok, false); +}); + +test("verifyStepUpApproveRequest: a tampered reason breaks the proof", async () => { + const start = await startResponse(); + start.document.payload.reason = "Approve everything forever."; + const res = await verifyStepUpApproveRequest(start, enrolled); + assert.equal(res.ok, false); +}); + +test("verifyStepUpApproveRequest: legacy fields that disagree with the signed copy are refused", async () => { + const start = await startResponse(); + start.sessionId = "some-other-session"; + const res = await verifyStepUpApproveRequest(start, enrolled); + assert.equal(res.ok, false); + assert.match(res.reason, /legacy field sessionId/); +}); + +test("verifyStepUpApproveRequest: the VTA-pushed 0.1 flavor passes the same gate", async () => { + const res = await verifyStepUpApproveRequest( + await startResponse({ type: APPROVE_REQUEST_TYPE_01, legacy: false }), + enrolled, + ); + assert.equal(res.ok, true, res.reason); +}); + +test("verifyStepUpApproveRequest: any other document type is refused", async () => { + const res = await verifyStepUpApproveRequest( + await startResponse({ type: APPROVE_RESPONSE_TYPE }), + enrolled, + ); + assert.equal(res.ok, false); +}); + +test("verifyStepUpApproveRequest: a lapsed request is refused", async () => { + const res = await verifyStepUpApproveRequest( + await startResponse({ over: { expiresAt: new Date(Date.now() - 1000).toISOString() } }), + enrolled, + ); + assert.equal(res.ok, false); + assert.match(res.reason, /lapsed/); +}); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 60738f8..de255ff 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -63,7 +63,6 @@ import { RUNTIME_VAULT_UPSERT, OFFSCREEN_LOCK_WALLET, RUNTIME_CONSENT_RESULT, - RUNTIME_INBOUND_CONSENT, RUNTIME_BROADCAST_EVENT, RUNTIME_LOCK_WALLET, RUNTIME_LOGIN, @@ -100,7 +99,6 @@ import { type RuntimeApiGetResponse, type RuntimeApiPostRequest, type RuntimeConsentResult, - type RuntimeInboundConsentRequest, type RuntimeLoginDidcommRequest, type RuntimeLoginRequest, type RuntimeLoginResponse, @@ -1871,15 +1869,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // async sendResponse } - // Offscreen asks us to prompt the user for an inbound RP confirm request. - if ((message as { type?: string })?.type === RUNTIME_INBOUND_CONSENT) { - const req = message as RuntimeInboundConsentRequest; - requestConsent({ rpDid: req.rpDid, action: req.action }) - .then((approved) => sendResponse({ approved })) - .catch(() => sendResponse({ approved: false })); - return true; // async sendResponse - } - if ((message as { type?: string })?.type === RUNTIME_CONSENT_RESULT) { const { consentId, approved, remember, prfOutputB64u } = message as RuntimeConsentResult; pendingConsents.get(consentId)?.(approved, !!remember, prfOutputB64u); diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index ebe9df1..a1e3316 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -261,13 +261,11 @@ export const RUNTIME_SIGN_TRUST_TASK = "vta-wallet/sign-trust-task" as const; export const RUNTIME_REQUEST_TASK = "vta-wallet/request-task" as const; export const RUNTIME_CONSENT_RESULT = "vta-wallet/consent-result" as const; -/** offscreen → background: an inbound RP confirm request needs user consent. */ -export const RUNTIME_INBOUND_CONSENT = "vta-wallet/inbound-consent" as const; -/** offscreen → background: an inbound, VTA-signed `task-consent/request` needs a - * human. Distinct from {@link RUNTIME_INBOUND_CONSENT} because the surface is - * different in kind: it renders executor-authored effects, it is never - * short-circuited by origin trust (the VTA is asking, not a site), and its - * approval is single-use so there is nothing to remember. */ +/** offscreen → background: an inbound, executor-signed `task-consent/request` + * needs a human. Unlike the generic login consent prompt, the surface renders + * executor-authored effects, it is never short-circuited by origin trust (an + * enrolled executor is asking, not a site), and its approval is single-use so + * there is nothing to remember. */ export const RUNTIME_TASK_CONSENT = "vta-wallet/task-consent" as const; /** confirm popup → background → offscreen: resolve + verify an RP DID so the * consent prompt can render a verification badge. Reply via sendResponse is a @@ -288,18 +286,6 @@ export interface RuntimeLockWalletResponse { error?: string; } -/** offscreen → background: prompt the user to approve an inbound RP confirm. - * Reply via `sendResponse` is `{ approved: boolean }`. */ -export interface RuntimeInboundConsentRequest { - type: typeof RUNTIME_INBOUND_CONSENT; - /** The requesting RP's DID (authcrypt-authenticated). */ - rpDid: string; - /** Human-readable action being confirmed (shown in the prompt). */ - action: string; - /** Optional RP display name. */ - rpName?: string; -} - /** content → background: perform a REST SIOPv2 login for the calling page. */ export interface RuntimeLoginRequest { type: typeof RUNTIME_LOGIN; diff --git a/packages/extension/src/config.ts b/packages/extension/src/config.ts index 176f40e..eebc394 100644 --- a/packages/extension/src/config.ts +++ b/packages/extension/src/config.ts @@ -25,6 +25,18 @@ export interface WalletSettings { defaultStepUpVtaDid?: string; /** Optional default VTA mediator DID prefilled into the step-up flow. */ defaultStepUpVtaMediatorDid?: string; + /** + * Additional executor DIDs this wallet is enrolled with, beyond its + * onboarded VTA(s) — e.g. a did:webvh DID-hosting control plane that signs + * `task-consent/request`s and step-up `approve-request`s. + * + * Every approval request an approver renders must be a Trust-Task document + * signed by an executor the approver is enrolled with; this list is the + * operator's way of enrolling executors that are not onboarded VTAs. The + * onboarded VTA DIDs are always members of the effective set — this only + * ever widens it. + */ + enrolledExecutorDids?: string[]; /** * H1 from the May 2026 security review: encrypt the persisted * Ed25519 root secret with a key derived from the operator's @@ -105,6 +117,13 @@ export async function getSettings(): Promise { ...(s?.defaultStepUpVtaMediatorDid ? { defaultStepUpVtaMediatorDid: s.defaultStepUpVtaMediatorDid } : {}), + ...(Array.isArray(s?.enrolledExecutorDids) + ? { + enrolledExecutorDids: s.enrolledExecutorDids.filter( + (d): d is string => typeof d === "string" && d.length > 0, + ), + } + : {}), encryptHolderSecret, preferTsp: typeof s?.preferTsp === "boolean" ? s.preferTsp : true, ...(s?.pushGatewayUrl ? { pushGatewayUrl: s.pushGatewayUrl } : {}), diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 017f2dc..8e79952 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -7,7 +7,6 @@ // those, which is why this lives here rather than in `background.ts`. import { - buildConfirmResponse, connectMediatorSession, createStopwatch, DidcommVtaTransport, @@ -20,7 +19,6 @@ import { markInboundHandled, type MediatorConnection, MediatorSessionBridge, - parseConfirmRequest, buildStepUpApproval, resolveKeyAgreement, parseTaskConsentRequest, @@ -44,9 +42,11 @@ import { MediatorSessionTspTransport, tspHolderIdentityFromSecret, setDeviceWake, + type SigningIdentity, signingIdentityFromSecret, stepUpVtaFinish, stepUpVtaStart, + verifyStepUpApproveRequest, signTrustTask, deriveSigningKeyId, forgetHolderRecord, @@ -102,7 +102,6 @@ import { OFFSCREEN_VAULT_RELEASE, OFFSCREEN_VAULT_UPSERT, OFFSCREEN_VERIFY_DID, - RUNTIME_INBOUND_CONSENT, RUNTIME_TASK_CONSENT, RUNTIME_EMIT_WALLET_EVENT, type OffscreenDidcommLoginRequest, @@ -208,6 +207,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (msg.type === OFFSCREEN_START_INBOUND) { const req = message as { vtaDids: string[] }; + // Background sends the full onboarded-VTA set on boot and on every + // connection change; it doubles as the base of the enrolled-executor set + // (offscreen has no chrome.storage, so the list is threaded in here). + knownVtaDids = [...(req.vtaDids ?? [])]; void reconcileInbound(req.vtaDids ?? []); return false; // fire-and-forget } @@ -598,6 +601,38 @@ async function doRequestTask(req: OffscreenRequestTaskRequest) { // not open two popups for the same change. const activeConsentDigests = new Set(); +// ─── Enrolled executors ─── +// +// Every approval request this wallet renders must be a Trust-Task document +// signed by an executor the wallet is *enrolled with* — proof verification +// alone only says who signed, not that the signer is entitled to ask this +// device's human anything. The enrolled set is: +// +// - the onboarded VTA DIDs (threaded in via OFFSCREEN_START_INBOUND — the +// same connection-store source the per-session vtaDid comes from); +// - the operator-configured default step-up VTA (settings), when set; +// - any operator-enrolled executor DIDs from settings — this is how a +// did:webvh DID-hosting control plane (which signs task-consent requests +// and step-up approve-requests) gets enrolled. +// +// The wallet stores no delegated-consent grants of its own that could name +// executor DIDs (grants live VTA-side), so operator config is the source for +// non-VTA executors. Unknown signer → reject("untrusted_issuer"), log, and +// never prompt. +let knownVtaDids: string[] = []; + +/** The enrolled-executor set, always including `vtaDid` when given. */ +async function enrolledExecutorDids(vtaDid?: string): Promise { + const settings = await getSettings(); + const set = new Set([ + ...(vtaDid ? [vtaDid] : []), + ...knownVtaDids, + ...(settings.defaultStepUpVtaDid ? [settings.defaultStepUpVtaDid] : []), + ...(settings.enrolledExecutorDids ?? []), + ]); + return [...set]; +} + /** * Hand a `requireConsent` rejection to a co-located approver identity. * @@ -633,15 +668,17 @@ async function maybeRelayConsentLocally( if (activeConsentDigests.has(outcome.payloadDigest)) return; - // Verify it is genuinely from this VTA before showing a human anything — the - // same gate the mediator-push path applies in `parseTaskConsentRequest`. + // Verify it is genuinely from an enrolled executor before showing a human + // anything — the same gate the mediator-push path applies in + // `parseTaskConsentRequest`. (On this path the signer is expected to be the + // VTA the rejection came from; the enrolled set always contains it.) const parsed = await parseTaskConsentRequest( { type: TRUST_TASK_ENVELOPE_TYPE, body: raw, id: (raw as { id?: unknown }).id, } as Record, - { expectedVtaDid: vtaDid, holderDid: myApproverDid }, + { enrolledExecutorDids: await enrolledExecutorDids(vtaDid), holderDid: myApproverDid }, ); if (!parsed.ok) { console.warn( @@ -1660,8 +1697,8 @@ async function drainPendingInbound(vtaDids: readonly string[]): Promise { */ async function onInboundMessage( conn: MediatorConnection, - identity: Parameters[0]["holder"], - signing: Parameters[0]["signing"], + identity: Identity, + signing: SigningIdentity, vtaDid: string, message: Record, isApprover = false, @@ -1702,8 +1739,8 @@ async function onInboundMessage( */ async function handleInbound( conn: MediatorConnection, - identity: Parameters[0]["holder"], - signing: Parameters[0]["signing"], + identity: Identity, + signing: SigningIdentity, vtaDid: string, message: Record, isApprover = false, @@ -1725,8 +1762,8 @@ async function handleInbound( async function dispatchInbound( conn: MediatorConnection, - identity: Parameters[0]["holder"], - signing: Parameters[0]["signing"], + identity: Identity, + signing: SigningIdentity, vtaDid: string, message: Record, // True when this is the approver's own inbox session: the decision is signed @@ -1753,21 +1790,22 @@ async function dispatchInbound( return; } - // Task-execution consent, first — it is the one inbound the *VTA itself* + // Task-execution consent, first — it is the one inbound an *executor itself* // sends, and it is the one whose content a human will act on. // // `parseTaskConsentRequest` verifies the Data-Integrity proof and that the - // signer is this device's own VTA *before* returning anything. Nothing is - // shown to a user on the strength of the transport alone: a mediator delivers - // what it is given, and the effects a person reads are the basis of an - // authorization, so they must be attributable to the executor that authored - // them. + // signer is an executor this device is enrolled with (its own VTA(s), plus + // any operator-enrolled executors such as a DID-hosting control plane) + // *before* returning anything. Nothing is shown to a user on the strength of + // the transport alone: a mediator delivers what it is given, and the effects + // a person reads are the basis of an authorization, so they must be + // attributable to the executor that authored them. const consent = await parseTaskConsentRequest(message, { - expectedVtaDid: vtaDid, + enrolledExecutorDids: await enrolledExecutorDids(vtaDid), holderDid: signing.did, }); if (consent.ok) { - await handleTaskConsent(conn, identity, signing, vtaDid, consent.parsed, message, isApprover); + await handleTaskConsent(conn, identity, signing, consent.parsed, message, isApprover); return; } if (consent.reason !== "not-a-task-consent-request") { @@ -1781,65 +1819,21 @@ async function dispatchInbound( return; } - const parsed = parseConfirmRequest(message); - if (!parsed) return; // not a confirm/request/0.1 — ignore other traffic - - // De-dup: the mediator replays un-acked messages on every reconnect, and - // the MV3 worker respawns the offscreen session often. Skip a confirm we've - // already handled so a replay doesn't pop a second consent prompt. Marked - // before prompting so a replay during the consent window is also skipped. - // Persisted (survives respawns — exactly when replays arrive). - // - // The drain path bypasses this. A drained message is one we persisted and - // very likely already prompted for — so its id IS in the handled set — but - // the user never got to answer before the worker died. Treating that as a - // duplicate would discard exactly the interrupted interaction the pending - // store exists to finish. - const messageId = typeof message.id === "string" ? message.id : undefined; - if (messageId && !fromDrain) { - const isNew = await markInboundHandled(new IndexedDBKVStore(), messageId); - if (!isNew) { - console.info("[pnm inbound] skipping replayed confirm:", messageId); - return; - } - } - try { - // Ask the background to prompt the user (consent UI is a background API). - // The spec `reason` maps to the generic consent-prompt `action` label. - const consent = (await chrome.runtime.sendMessage({ - type: RUNTIME_INBOUND_CONSENT, - rpDid: parsed.rpDid, - action: parsed.request.reason, - })) as { approved?: boolean } | undefined; - const approved = consent?.approved === true; - - const rp = await resolveKeyAgreement(parsed.rpDid); - const outer = await buildConfirmResponse({ - holder: identity, - signing, - rp, - mediator: conn.mediator, - approved, - subject: parsed.request.subject, - challenge: parsed.request.challenge, - thid: parsed.thid, - }); - conn.send(outer); - console.info("[pnm inbound] confirm responded:", approved ? "approved" : "denied"); - } catch (e) { - console.error("[pnm inbound] confirm handling failed:", e); - } + // Anything else is ignored. This used to fall through to the + // `confirm/request/0.1` family; that fallback was removed deliberately when + // the family was retired ecosystem-wide (the registry marks it supersededBy + // task-consent) — a retired, RP-authored prompt path is exactly the thing an + // attacker would reach for once the strict path shuts them out. } /** - * A verified `task-consent/request` from this device's VTA: ask the human, sign - * their answer, send it back. + * A verified `task-consent/request` from an enrolled executor: ask the human, + * sign their answer, send it back to the executor that asked. */ async function handleTaskConsent( conn: MediatorConnection, identity: Parameters[0]["holder"], signing: Parameters[0]["signing"], - vtaDid: string, parsed: ParsedTaskConsentRequest, message: Record, isApprover = false, @@ -1880,7 +1874,11 @@ async function handleTaskConsent( // mistaken for it. const decision = result?.approved === true ? "approve" : "deny"; - const vta = await resolveKeyAgreement(vtaDid); + // The decision goes back to the executor whose proof we verified — for the + // classic flow that is this device's VTA; for an enrolled control plane it + // is the control plane itself. `parsed.executorDid` is the proven signer, + // never a value the transport claimed. + const vta = await resolveKeyAgreement(parsed.executorDid); const outer = await buildTaskConsentDecision({ holder: identity, signing, @@ -1967,16 +1965,50 @@ async function doStepUpVta( const { signing } = await loadHolder(req.params.vtaDid); sw.mark("load holder"); - // 1. RP start (REST) → approve-request {subject, sessionId, challenge}. - const request = await stepUpVtaStart(req.params.baseUrl, req.params.accessToken); + // 1. RP start (REST) → the signed `auth/step-up/approve-request/0.2` + // Trust-Task document (plus legacy top-level fields for cross-checking). + const start = await stepUpVtaStart(req.params.baseUrl, req.params.accessToken); sw.mark("rp start (challenge)"); + // 1b. Verify BEFORE acting on anything in it. The document's proof must + // verify (eddsa-jcs-2022, assertionMethod), its issuer must be the proven + // signer, and that signer must be an executor this wallet is enrolled + // with — the spec's "consumers MUST verify the proof BEFORE surfacing the + // reason" rule, applied to everything the wallet echoes into the signed + // approve-response, not just the reason. A start response with no + // `document` is refused: the proofless legacy `{subject, sessionId, + // challenge, reason}` path was removed deliberately once the control + // plane began signing approve-requests, so its absence means an + // out-of-date (or lying) server and never a prompt. + const verified = await verifyStepUpApproveRequest(start, { + enrolledExecutorDids: await enrolledExecutorDids(req.params.vtaDid), + }); + if (!verified.ok) { + console.warn("[pnm step-up] refusing approve-request:", verified.reason); + throw new Error(`step-up approve-request refused: ${verified.reason}`); + } + // The RP the page named is the audience the approve-response will be bound + // to (`recipient: rpDid`); the approve-request's proven issuer must be that + // same party, or the wallet would be answering a question nobody it trusts + // asked. + if (verified.issuer !== req.params.rpDid) { + console.warn( + "[pnm step-up] approve-request issuer", + verified.issuer, + "does not match the page-supplied rpDid", + req.params.rpDid, + ); + throw new Error("step-up approve-request refused: issuer does not match rpDid"); + } + sw.mark("verify approve-request"); + // 2. Wallet signs the approve-response/0.2 locally (holder-self-signs — no - // VTA round-trip). The DI proof over the subject key is the elevation gate. + // VTA round-trip). The DI proof over the subject key is the elevation + // gate. Every echoed field comes from the *verified* document's payload. const approval = await buildStepUpApproval({ signing, rpDid: req.params.rpDid, - request, + request: verified.request, approved: true, }); sw.mark("sign approval"); diff --git a/packages/extension/src/options.tsx b/packages/extension/src/options.tsx index ffd217f..16f07f7 100644 --- a/packages/extension/src/options.tsx +++ b/packages/extension/src/options.tsx @@ -40,6 +40,10 @@ function Options() { const [mediatorDid, setMediatorDid] = useState(""); const [vtaDid, setVtaDid] = useState(""); const [vtaMediatorDid, setVtaMediatorDid] = useState(""); + // Additional enrolled executor DIDs (one per line) — executors beyond the + // onboarded VTA(s) whose signed approval requests this wallet will render + // (e.g. a did:webvh DID-hosting control plane). + const [enrolledExecutors, setEnrolledExecutors] = useState(""); const [pushGatewayUrl, setPushGatewayUrl] = useState(""); const [pushGatewayVapidPublicKey, setPushGatewayVapidPublicKey] = useState(""); const [holderDid, setHolderDid] = useState(""); @@ -73,6 +77,7 @@ function Options() { setMediatorDid(s.mediatorDid); setVtaDid(s.defaultStepUpVtaDid ?? ""); setVtaMediatorDid(s.defaultStepUpVtaMediatorDid ?? ""); + setEnrolledExecutors((s.enrolledExecutorDids ?? []).join("\n")); setPushGatewayUrl(s.pushGatewayUrl ?? ""); setPushGatewayVapidPublicKey(s.pushGatewayVapidPublicKey ?? ""); setEncryptOn(Boolean(s.encryptHolderSecret)); @@ -253,6 +258,12 @@ function Options() { mediatorDid: trimmedMediator, ...(vtaDid.trim() ? { defaultStepUpVtaDid: vtaDid.trim() } : {}), ...(vtaMediatorDid.trim() ? { defaultStepUpVtaMediatorDid: vtaMediatorDid.trim() } : {}), + // Always written (an empty list is a valid state): un-enrolling an + // executor must actually revoke it, not linger as a stale merge. + enrolledExecutorDids: enrolledExecutors + .split("\n") + .map((d) => d.trim()) + .filter((d) => d.length > 0), ...(pushGatewayUrl.trim() ? { pushGatewayUrl: pushGatewayUrl.trim() } : {}), ...(pushGatewayVapidPublicKey.trim() ? { pushGatewayVapidPublicKey: pushGatewayVapidPublicKey.trim() } @@ -308,6 +319,19 @@ function Options() { onChange={(e) => setVtaMediatorDid(e.target.value)} /> + +