diff --git a/packages/core/src/provision/index.ts b/packages/core/src/provision/index.ts index 49c156d..75aa79f 100644 --- a/packages/core/src/provision/index.ts +++ b/packages/core/src/provision/index.ts @@ -19,9 +19,9 @@ export { } from "./request.js"; export { sendProvisionIntegration, - ProvisionProblemReportError, + provisionRefusalOf, PROVISION_CONTEXT_REQUIRED, - type ProblemReportPayload, + type ProvisionRefusal, type ProvisionIntegrationRequestBody, type ProvisionIntegrationResponseBody, type ProvisionSummary, diff --git a/packages/core/src/provision/run.ts b/packages/core/src/provision/run.ts index 4655526..454b164 100644 --- a/packages/core/src/provision/run.ts +++ b/packages/core/src/provision/run.ts @@ -2,19 +2,19 @@ // // One call drives the full onboarding round-trip: // 1. Build + sign a BootstrapRequest VP for the AdminRotation ask. -// 2. Ship it via DIDComm (authcrypt → forward via VTA's mediator). +// 2. Ship it as a Trust Task over whichever transport the VTA advertises. // 3. Open the HPKE-sealed reply with the wallet's ephemeral Ed25519 seed. // 4. Cross-check that the bundle's `bundleIdHex` matches the request nonce. // 5. Return the minimal admin reply (DID + private keys) the wallet adopts // as its long-term holder identity. // -// The wallet's existing onboarding code (`offscreen.ts`) currently calls -// `swapAclDidcomm` here — M2C-C swaps that call site to `runProvisionIntegration`. +// Step 2 is a `TrustTaskSender`, not a DIDComm bridge — see `send.ts` for why it +// moved. Every other step is transport-independent and always was: the VP, the +// HPKE open, the nonce cross-check and the key extraction are the same work +// whichever channel carried the bytes. -import type { Identity } from "../didcomm/index.js"; import type { SigningIdentity } from "../siop/self-issued.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; -import type { DidcommMessageBridge } from "../vta/transport.js"; +import type { TrustTaskSender } from "../vta/channel.js"; import { openAdminRotationBundle } from "./open.js"; import { buildBootstrapRequest, type BootstrapAsk } from "./request.js"; @@ -22,20 +22,16 @@ import { sendProvisionIntegration, type ProvisionSummary } from "./send.js"; import type { AdminRotationPayload } from "./types.js"; export interface RunProvisionIntegrationOptions { - /** Mediator-backed DIDComm bridge for the round-trip. */ - bridge: DidcommMessageBridge; - /** Authcrypt sender X25519 identity for the ephemeral did:key. */ - ephemeral: Identity; - /** Signing identity for the SAME ephemeral did:key — used to sign the - * BootstrapRequest VP. The Ed25519 seed in `signing.privateKey` is also - * the recipient secret the wallet uses to open the sealed bundle. */ + /** Any transport that can carry a Trust Task, whose identity is the + * operator-granted ephemeral did:key. A `VtaSession` gives the full + * TSP > DIDComm > REST chain; a single channel works too. */ + sender: TrustTaskSender; + /** Signing identity for the ephemeral did:key — signs the BootstrapRequest + * VP, and its DID is the envelope `issuer`. The Ed25519 seed in + * `signing.privateKey` is also the recipient secret the wallet uses to open + * the sealed bundle. */ ephemeralSigning: SigningIdentity; - /** The VTA's DID + keyAgreement key (inner authcrypt recipient and - * expected `from` on the reply). */ - service: RemoteDidcommEndpoint; - /** The VTA's mediator (forward target). Omit for direct DIDComm. */ - mediator?: RemoteDidcommEndpoint; - /** The VTA's DID — passed to send.ts for the reply sender assertion. */ + /** The VTA's DID — the envelope `recipient`. */ vtaDid: string; /** The maintainer context to provision the admin DID into. **Optional** * per the canonical Trust Task spec — when omitted, the VTA infers @@ -112,27 +108,24 @@ export async function runProvisionIntegration( ...(opts.note ? { label: opts.note } : {}), }); - // 2. DIDComm round-trip. Body field names are snake_case to match the - // existing FPN protocol the VTA speaks today (see send.ts header for - // the migration plan). + // 2. Trust-Task round-trip over whatever `sender` carries. const reply = await sendProvisionIntegration({ - bridge: opts.bridge, - ephemeral: opts.ephemeral, - service: opts.service, - ...(opts.mediator ? { mediator: opts.mediator } : {}), + sender: opts.sender, + ephemeralDid: opts.ephemeralSigning.did, vtaDid: opts.vtaDid, body: { request: vp, // context is optional on the wire. When omitted, the VTA's // inference rules pick the target context (single-context grant - // → use it; super-admin + single-context VTA → use it; - // ambiguous → e.p.msg.context_required). Wallet-class callers + // → use it; super-admin + single-context VTA → use it; ambiguous → + // `provision/integration:contextRequired`, whose candidates + // `provisionRefusalOf` reads off the error). Wallet-class callers // typically omit; integration-class callers send explicitly. ...(opts.context ? { context: opts.context } : {}), - // create_context defaults to false on the wire; only emit when the - // caller actually asked for inline create so older VTAs that - // pre-date the field don't have to deal with an unexpected key. - ...(opts.createContext ? { create_context: true } : {}), + // `createContext` defaults to false on the wire; only emit it when the + // caller actually asked for an inline create, so the common request + // stays the minimal document. + ...(opts.createContext ? { createContext: true } : {}), }, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), }); diff --git a/packages/core/src/provision/send.ts b/packages/core/src/provision/send.ts index 68e9f25..11b49ac 100644 --- a/packages/core/src/provision/send.ts +++ b/packages/core/src/provision/send.ts @@ -1,46 +1,52 @@ -// DIDComm round-trip for the provision-integration flow. +// The provision-integration round-trip, as an ordinary Trust Task. // -// Mirrors `packages/core/src/onboarding/swap.ts` — same authcrypt-inner + -// authcrypt-forward-outer + send-and-await-reply shape. The only thing -// that differs is the message type URI and the body wire shape. +// This used to be a bespoke DIDComm protocol message: authcrypt-inner + +// authcrypt-forward-outer packed here, correlated by `thid`, with a +// problem-report as its error channel. It is now `buildTrustTask` + +// `sender.send`, exactly like every other VTA operation — which means it runs +// over whichever transport the VTA advertises, priority TSP > DIDComm > REST, +// rather than requiring a reachable DIDComm mediator. // -// Wire URIs come from the generated bindings, at version 0.3. `result_uri_for` -// echoes the request version into the `#response`, so a 0.3 request gets a 0.3 -// reply. The legacy firstperson.network type was retired VTA-side; this client -// is fully off it. +// **Why it moved.** The VTA has served provision-integration through the shared +// Trust-Task dispatcher for some time (`TASK_PROVISION_INTEGRATION_0_3` → +// `trust_tasks/provision_integration.rs`), taking the same request body and +// returning the same response body as the bespoke DIDComm handler beside it. +// The wallet was using the bespoke one, and paid for it twice: // -// **0.3 replaces the response's hex `digest` with `digestMultibase`** — see -// {@link ProvisionIntegrationResponseBody}. VTI adopted it in #1147; the -// service keeps a hex digest internally for what an operator reads and -// `--expect-digest` takes, and puts the multibase form on the wire. +// 1. Provisioning was the one VTA operation with no transport chain — a +// wallet connected over TSP still had to open a DIDComm mediator session +// to onboard, and a VTA that advertised no DIDComm could not be onboarded +// at all. +// 2. The bespoke handler labels its reply from a hand-written version→URI +// map, and that map was not moved when the router cut over to 0.3. It +// answered `provision/integration/0.1#response` carrying a 0.3 body, so a +// provisioning that had fully succeeded — bundle sealed, admin rolled +// over — was rejected here as an unexpected reply type (VTI #1202). The +// dispatcher has no such map: it sets the `#response` fragment on the +// request URI it just parsed, so that class of bug cannot arise there. // -// Request body field naming on the wire is `snake_case`: the VTA deserializes -// a `ProvisionIntegrationRequest` (snake_case) struct. **The response summary -// is camelCase** — `vta-sdk`'s `ProvisionSummary` carries -// `#[serde(rename_all = "camelCase")]` with snake_case aliases for reading a -// legacy producer. This header used to claim the summary was snake_case "on -// both", which contradicted the type declared below it; the type was right. -// -// The 0.2 delta was the camelCase `ask.type` discriminator inside the SIGNED -// BootstrapRequest VP (`adminRotation` vs 0.1 `AdminRotation`) — see -// `request.ts`. The VTA verifies the VP proof over the received bytes and -// accepts the camelCase tag via a serde alias. - -import { packAuthcrypt, packAuthcryptJson, wrapForward, type Identity } from "../didcomm/index.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; -import type { DidcommMessageBridge } from "../vta/transport.js"; +// **Casing.** The option fields are lowerCamelCase (`createContext`, not +// `create_context`) — the canonical 0.2+ wire form the registry declares, and +// what the generated payload type below enforces. The Rust struct still carries +// snake_case `alias`es, so the old spelling would also be accepted; that is a +// fold on the VTA's side, not a reason to keep sending the legacy form. The +// signed `request` VP is passed through untouched: it carries a proof over its +// own bytes, and re-casing anything inside it breaks that. + +import type { TrustTaskSender } from "../vta/channel.js"; +import { VtaClientError } from "../vta/errors.js"; +import { buildTrustTask } from "../vta/trust-task.js"; import type { BootstrapRequestVp } from "./request.js"; import { TYPE_URI as PROVISION_INTEGRATION, RESPONSE_TYPE_URI as PROVISION_INTEGRATION_RESULT, + type ProvisionIntegrationPayload, type ProvisionIntegrationResponsePayload, type ProvisionSummary, } from "@openvtc/trust-tasks/provision/integration/0.3/payload"; -const PROBLEM_REPORT_TYPE = "https://didcomm.org/report-problem/2.0/problem-report"; - const DEFAULT_TIMEOUT_MS = 60_000; /** @@ -54,152 +60,117 @@ const DEFAULT_TIMEOUT_MS = 60_000; */ export const PROVISION_CONTEXT_REQUIRED = "provision/integration:contextRequired"; -/** Parsed DIDComm problem-report body. Mirrors the wire shape - * emitted by the VTA's `app_err_to_response`. */ -export interface ProblemReportPayload { +/** + * A refusal the VTA described with a code its own specification declares, + * rather than one of the framework's standard ones. + * + * The only such code on this task today is {@link PROVISION_CONTEXT_REQUIRED}, + * which the wallet has a recovery UX for: it shows `candidates` as a picker so + * the operator chooses a context and retries inside the ephemeral grant's TTL. + */ +export interface ProvisionRefusal { + /** The specification-extended code, verbatim. */ code: string; - comment: string; - /** Structured arguments — task-specific. For - * {@link PROVISION_CONTEXT_REQUIRED} this carries the candidates list. */ - args: string[]; + /** The VTA's human-readable explanation. */ + message: string; + /** For {@link PROVISION_CONTEXT_REQUIRED}, the contexts to choose between. + * Empty for any other code, and possibly empty even for this one — a refusal + * with no candidates is still a refusal. */ + candidates: string[]; } -/** Thrown by `sendProvisionIntegration` when the VTA replies with a - * DIDComm problem-report rather than a success result. Carries the - * structured fields so callers can branch on the code (e.g. the - * popup's context-required recovery picker that reads - * `report.args` as the candidates list). */ -export class ProvisionProblemReportError extends Error { - readonly report: ProblemReportPayload; - constructor(report: ProblemReportPayload) { - super(`provision-integration: ${report.code} — ${report.comment}`); - this.name = "ProvisionProblemReportError"; - this.report = report; - } +/** + * Read the structured refusal out of a thrown error, or `undefined` when the + * error is not one. + * + * Reads fields, never the message: `VtaClientError.details` carries the + * framework's error payload verbatim, so the code and the candidates are both + * machine-readable (guide rule R3.7). Recovering a candidate list by parsing it + * back out of a rendered sentence is exactly what the extended code exists to + * make unnecessary. + */ +export function provisionRefusalOf(e: unknown): ProvisionRefusal | undefined { + if (!(e instanceof VtaClientError)) return undefined; + const payload = e.details as + | { code?: unknown; message?: unknown; details?: { candidates?: unknown } } + | undefined; + if (!payload || typeof payload.code !== "string") return undefined; + const raw = payload.details?.candidates; + return { + code: payload.code, + message: typeof payload.message === "string" ? payload.message : e.message, + candidates: Array.isArray(raw) ? raw.filter((c): c is string => typeof c === "string") : [], + }; } -/** Body of the inbound `provision-integration` message. Mirrors - * `vta_sdk::provision_integration::http::ProvisionIntegrationRequest`. */ -export interface ProvisionIntegrationRequestBody { +/** + * Body of the `provision/integration/0.3` request. + * + * Taken from the generated binding so the option spellings cannot drift from + * the registry, with `request` re-typed: the generated `BootstrapRequest` is the + * schema's view of the VP, and this package builds its own + * ({@link BootstrapRequestVp}) because the document is signed and travels + * byte-for-byte. + */ +export type ProvisionIntegrationRequestBody = Omit & { request: BootstrapRequestVp; - /** Optional per the canonical Trust Task spec. When omitted, the VTA - * infers the target context from the relayer's ACL grant + its own - * contexts state. See vta-sdk's `ProvisionIntegrationRequest::context` - * doc-comment for the full inference rules. */ - context?: string; - assertion?: "did-signed" | "pinned-only"; - /** Caller-preferred VC validity in seconds. Capped server-side. */ - vc_validity_seconds?: number; - /** Super-admin only: create the context inline if missing. */ - create_context?: boolean; -} +}; /** - * Body of the outbound `provision-integration-result` reply. + * Body of the `provision/integration/0.3#response` reply. * * From the generated bindings rather than transcribed. **The bundle digest is * `digestMultibase`, not the `digest` hex string `0.2` carried** — a - * self-describing multibase multihash, the same type - * `task-consent`'s `payloadDigest` uses, so decode it with - * `../trust-tasks/digest.ts` rather than comparing the encoded strings. It is - * OPTIONAL: it exists for holders that pinned the bundle out-of-band, and its - * absence is not a failure. It is taken over the **armored bytes exactly as - * carried** in `bundle`, not over a canonicalization — re-armoring the same - * ciphertext need not reproduce the same bytes, so re-deriving it from a - * round-tripped bundle can legitimately disagree. + * self-describing multibase multihash, the same type `task-consent`'s + * `payloadDigest` uses, so decode it with `../trust-tasks/digest.ts` rather than + * comparing the encoded strings. It is OPTIONAL: it exists for holders that + * pinned the bundle out-of-band, and its absence is not a failure. It is taken + * over the **armored bytes exactly as carried** in `bundle`, not over a + * canonicalization — re-armoring the same ciphertext need not reproduce the same + * bytes, so re-deriving it from a round-tripped bundle can legitimately disagree. */ export type ProvisionIntegrationResponseBody = ProvisionIntegrationResponsePayload; export type { ProvisionSummary }; export interface SendProvisionIntegrationOptions { - /** Mediator-backed bridge — ships the JWE and surfaces the decrypted, - * sender-authenticated reply (keyed by `thid`). */ - bridge: DidcommMessageBridge; - /** Authcrypt sender = the operator-granted ephemeral did:key (X25519 - * identity matching the BootstrapRequest's `holder`). */ - ephemeral: Identity; - /** The VTA's DID + keyAgreement key (inner authcrypt recipient). */ - service: RemoteDidcommEndpoint; - /** The VTA's mediator (forward target). Omit for direct, non-mediated send. */ - mediator?: RemoteDidcommEndpoint; - /** The VTA's DID — the expected reply `from`. */ + /** Any transport that can carry a Trust Task, whose identity is the + * **ephemeral** — the operator-granted did:key. The VTA authenticates the + * sender the same way on all three transports (`auth_from_did` against its + * ACL), so the grant the operator just made is what authorises this call + * whichever channel carries it. */ + sender: TrustTaskSender; + /** The operator-granted ephemeral did:key — the envelope `issuer`, and the + * `holder` the BootstrapRequest VP is signed by. */ + ephemeralDid: string; + /** The VTA's DID — the envelope `recipient`. */ vtaDid: string; /** The request body to ship. */ body: ProvisionIntegrationRequestBody; - /** Request-side timeout. Default 60s (matches the Rust SDK constant) — - * the handler renders templates, mints keys, writes the webvh log, and - * seals the bundle synchronously inside one handler call, so it needs - * more headroom than a typical authenticate. */ + /** Request-side timeout. Default 60s (matches the Rust SDK constant) — the + * handler renders templates, mints keys, writes the webvh log, and seals the + * bundle synchronously inside one handler call, so it needs more headroom + * than a typical task. */ timeoutMs?: number; } -/** Pack + send the provision-integration request and return the reply - * body. Throws on timeout, wrong reply type, sender mismatch, or any - * problem-report from the VTA. */ +/** + * Send the provision-integration request and return the reply body. + * + * Throws a `VtaClientError` on refusal; pass it to {@link provisionRefusalOf} to + * recover a specification-declared code such as + * {@link PROVISION_CONTEXT_REQUIRED}. + */ export async function sendProvisionIntegration( opts: SendProvisionIntegrationOptions, ): Promise { - const { bridge, ephemeral, service, mediator, vtaDid, body } = opts; - const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - - const requestId = globalThis.crypto.randomUUID(); - const message = { - id: requestId, - type: PROVISION_INTEGRATION, - from: ephemeral.did, - to: [service.did], - body, - }; - - const inner = await packAuthcrypt(message, ephemeral, [ - { kid: service.keyAgreementKid, jwk: service.keyAgreementPublicJwk }, - ]); - - let outer = inner; - if (mediator) { - const forwardJson = wrapForward(service.did, ephemeral.did, mediator.did, inner); - outer = await packAuthcryptJson(forwardJson, ephemeral, [ - { kid: mediator.keyAgreementKid, jwk: mediator.keyAgreementPublicJwk }, - ]); - } - - const reply = await bridge.sendAndAwaitReply(outer, requestId, { timeoutMs }); - - if (reply.thid !== requestId) { - throw new Error( - `provision-integration: reply thid ${reply.thid ?? "(none)"} != request ${requestId}`, - ); - } - if (reply.from !== vtaDid) { - throw new Error( - `provision-integration: reply from ${reply.from ?? "(none)"} != VTA ${vtaDid}`, - ); - } - if (reply.type === PROBLEM_REPORT_TYPE) { - // Throw a typed error so callers can branch on the code without - // re-parsing the message string. The canonical case we surface a - // UX for is `PROVISION_CONTEXT_REQUIRED` — the wallet's popup - // catches the typed shape and shows the candidates (in - // `report.args`) as a picker so the operator can choose. The code - // is passed through verbatim, in whatever spelling the agent used; - // folding it here would hide from the caller which side of the - // §4.10 re-casing its peer is on. - const body = (reply.body ?? {}) as Partial; - throw new ProvisionProblemReportError({ - code: typeof body.code === "string" ? body.code : "(no code)", - comment: typeof body.comment === "string" ? body.comment : "", - args: Array.isArray(body.args) ? body.args.filter((a) => typeof a === "string") : [], - }); - } - if (reply.type !== PROVISION_INTEGRATION_RESULT) { - // Unexpected reply type — not a problem-report and not the - // expected result. Could happen if a future VTA version - // introduces a new reply type the wallet doesn't know about. - throw new Error( - `provision-integration: ${reply.type ?? "(no type)"} — ${JSON.stringify(reply.body ?? {})}`, - ); - } - - return (reply.body ?? {}) as ProvisionIntegrationResponseBody; + const envelope = buildTrustTask(PROVISION_INTEGRATION, opts.body, { + issuer: opts.ephemeralDid, + recipient: opts.vtaDid, + }); + return opts.sender.send(envelope, { + expectedResponseType: PROVISION_INTEGRATION_RESULT, + operationLabel: "provision/integration/0.3", + timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }); } diff --git a/packages/core/tests/provision.trust-task.mjs b/packages/core/tests/provision.trust-task.mjs new file mode 100644 index 0000000..27bb797 --- /dev/null +++ b/packages/core/tests/provision.trust-task.mjs @@ -0,0 +1,106 @@ +// Provisioning is an ordinary Trust Task, and its one specification-declared +// refusal survives as structured data. +// +// Both halves used to be true only of the bespoke DIDComm path: the envelope was +// hand-packed in send.ts, and `contextRequired` arrived as a problem-report with +// its own error class. What is pinned below is the pair of properties that made +// the migration worth doing — the document is addressed the way every other VTA +// operation's is, and the wallet's context picker still gets a list rather than +// a sentence to parse. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { sendProvisionIntegration, provisionRefusalOf } from "../dist/provision/index.js"; +import { VtaClientError } from "../dist/vta/index.js"; + +const EPHEMERAL = "did:key:z6MkephemeralExampleExampleExampleExampleExample"; +const VTA = "did:webvh:QmExample:example.test:agent"; +const TASK = "https://trusttasks.org/spec/provision/integration/0.3"; + +/** Captures the envelope instead of sending it. */ +function captor(reply = { bundle: "-----BEGIN VTA SEALED BUNDLE-----", summary: {} }) { + const sent = []; + return { + sent, + async send(envelope, opts) { + sent.push({ envelope, opts }); + return reply; + }, + }; +} + +test("the request goes out as a Trust Task addressed from the ephemeral to the VTA", async () => { + const sender = captor(); + await sendProvisionIntegration({ + sender, + ephemeralDid: EPHEMERAL, + vtaDid: VTA, + body: { request: { proof: {} }, createContext: true }, + }); + + assert.equal(sender.sent.length, 1); + const { envelope, opts } = sender.sent[0]; + assert.equal(envelope.type, TASK); + // The ephemeral issues: the operator granted *it*, and the VTA authenticates + // the sender against that grant identically on all three transports. + assert.equal(envelope.issuer, EPHEMERAL); + assert.equal(envelope.recipient, VTA); + // A reply under any other type is a protocol error rather than something to + // guess at — this is the check that caught VTI #1202. + assert.equal(opts.expectedResponseType, `${TASK}#response`); +}); + +test("the option fields go out lowerCamelCase, and the signed VP is untouched", async () => { + const sender = captor(); + // A stand-in for the signed BootstrapRequest: snake_case members that must + // survive verbatim, because the holder's proof covers these exact bytes. + const vp = { proof: { proofValue: "z…" }, credential_subject: { admin_template: "vta-admin" } }; + await sendProvisionIntegration({ + sender, + ephemeralDid: EPHEMERAL, + vtaDid: VTA, + body: { request: vp, context: "acme", createContext: true }, + }); + + const { payload } = sender.sent[0].envelope; + assert.equal(payload.createContext, true, "the 0.2+ canonical spelling"); + assert.ok(!("create_context" in payload), "the legacy 0.1 spelling must not be sent"); + assert.equal(payload.context, "acme"); + assert.deepEqual(payload.request, vp, "the signed VP is relayed byte-for-byte"); +}); + +test("contextRequired comes back as a code and a list, not a sentence", () => { + // The shape a Trust-Task error document carries: `details` on the client + // error is the framework payload verbatim. + const e = new VtaClientError("e.p.msg.task_failed", "cannot infer the context", { + details: { + code: "provision/integration:contextRequired", + message: "several contexts are plausible", + details: { candidates: ["acme", "globex"] }, + }, + }); + + const refusal = provisionRefusalOf(e); + assert.ok(refusal); + assert.equal(refusal.code, "provision/integration:contextRequired"); + assert.equal(refusal.message, "several contexts are plausible"); + assert.deepEqual(refusal.candidates, ["acme", "globex"]); +}); + +test("a refusal with no candidates is still a refusal", () => { + const e = new VtaClientError("e.p.msg.task_failed", "nope", { + details: { code: "provision/integration:someOtherCode", message: "nope" }, + }); + const refusal = provisionRefusalOf(e); + assert.ok(refusal); + assert.deepEqual(refusal.candidates, []); +}); + +test("an error that is not a VTA client error yields nothing to branch on", () => { + assert.equal(provisionRefusalOf(new Error("socket closed")), undefined); + assert.equal(provisionRefusalOf(undefined), undefined); + // A client error with no framework payload behind it: there is no code to + // report, and inventing one would be worse than saying so. + assert.equal(provisionRefusalOf(new VtaClientError("e.p.msg.network", "timed out")), undefined); +}); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 45a61bc..a98fe11 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -37,6 +37,7 @@ import { type ApproverIdentityResult, type ParsedTaskConsentRequest, resolveVtaServices, + type VtaServices, resolveVtaTspEndpoint, RestChannel, TspChannel, @@ -51,7 +52,7 @@ import { holderIdentityState, holderInputsFromAdminReply, installVtaMintedHolder, - ProvisionProblemReportError, + provisionRefusalOf, runProvisionIntegration, type TrustTaskChannel, vaultDelete, @@ -253,12 +254,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // parsed, which is fragile. Forwarded verbatim — rewriting a // code in transit would mean this hop deciding what it means // on behalf of the surface that acts on it. - if (e instanceof ProvisionProblemReportError) { + const refusal = provisionRefusalOf(e); + if (refusal) { sendResponse({ ok: false, - error: e.message, - code: e.report.code, - candidates: e.report.args, + error: refusal.message, + code: refusal.code, + candidates: refusal.candidates, }); return; } @@ -499,19 +501,48 @@ interface VtaSessionHandle { didcommConn?: MediatorConnection; } +/** The identity a session speaks as. Almost always the wallet's holder — the + * exception is onboarding, which speaks as the operator-granted ephemeral + * because the holder it is about to mint does not exist yet. */ +interface SessionIdentity { + holder: Identity; + signing: SigningIdentity; +} + +/** How an identity reaches a mediator. + * + * Injected rather than assumed, because the warm pool ({@link getWarmSession}) + * authenticates as the **holder** — it calls `loadHolder` itself. Handing the + * onboarding ephemeral a pooled connection would send its provisioning request + * under a DID the operator never granted. */ +type MediatorConnector = (mediatorDid: string) => Promise; + // Build a VtaSession for `vtaDid` honouring the advertised transports // (TSP > DIDComm > REST). `restBaseUrl` (from the popup's connection state) is // used when present; otherwise we fall back to the VTA's advertised #vta-rest. // A VTA that advertises only one transport yields a single-channel session; a // VTA advertising several prefers TSP, then DIDComm, then REST, with safe // fallback to the next when a higher-priority channel can't carry the task. -async function getVtaSession( +async function buildVtaSession( vtaDid: string, - restBaseUrl?: string, + who: SessionIdentity, + connect: MediatorConnector, + opts: { + restBaseUrl?: string; + /** Order DIDComm ahead of TSP. Set by onboarding — see that call site for + * why the ephemeral does not lead with TSP. */ + didcommFirst?: boolean; + /** Transports to build channels for. Defaults to what the VTA's document + * advertises; onboarding passes its own so an operator-supplied mediator + * can stand in for a DIDComm service a bare `did:peer` VTA has no document + * to declare. */ + services?: VtaServices; + } = {}, ): Promise { - const { identity: holder, signing } = await loadHolder(vtaDid); + const { holder, signing } = who; + const restBaseUrl = opts.restBaseUrl; const service = await resolveKeyAgreement(vtaDid); - const services = await resolveVtaServices(vtaDid); + const services = opts.services ?? (await resolveVtaServices(vtaDid)); const channels: TrustTaskChannel[] = []; // TSP is the highest-priority transport. It rides the SAME warm mediator @@ -530,7 +561,7 @@ async function getVtaSession( const vtaTsp = await resolveVtaTspEndpoint(vtaDid); // Same mediator as DIDComm in practice; getWarmSession is pooled by // (mediator, vtaDid) so this shares the one socket. - const conn = await getWarmSession(services.tsp.mediatorDid, vtaDid); + const conn = await connect(services.tsp.mediatorDid); channels.push( new TspChannel({ transport: new MediatorSessionTspTransport({ connection: conn }), @@ -552,41 +583,71 @@ async function getVtaSession( } let didcommConn: MediatorConnection | undefined; if (services.didcomm) { - const conn = await getWarmSession(services.didcomm.mediatorDid, vtaDid); - didcommConn = conn; - const bridge = new MediatorSessionBridge(conn); - // Encrypt/route to the REAL VTA (`service`), NOT `conn.vta`: the warm - // session seeds `conn.vta` with the holder's own DID as a harmless - // placeholder (it's a shared session with no fixed peer), so each op must - // supply its own VTA target. Using `conn.vta` here would authcrypt+forward - // the request back to the holder. - channels.push( - new DidcommVtaTransport({ - bridge, - holder, - signing, - vta: service, - mediator: conn.mediator, - }), - ); + // A mediator we cannot reach skips its channel rather than failing the + // whole session, mirroring the TSP branch above. Safe for the same reason: + // this is *pre-send*, so nothing has been dispatched and nothing can have + // been applied twice — the distinction `VtaSession` draws when it falls + // back on `e.client.unsupported` but never on a post-send failure. + const conn = await connect(services.didcomm.mediatorDid).catch((err: unknown) => { + console.warn("[pnm didcomm] skipping DIDComm channel:", (err as Error).message); + return undefined; + }); + if (conn) { + didcommConn = conn; + const bridge = new MediatorSessionBridge(conn); + // Encrypt/route to the REAL VTA (`service`), NOT `conn.vta`: the warm + // session seeds `conn.vta` with the holder's own DID as a harmless + // placeholder (it's a shared session with no fixed peer), so each op must + // supply its own VTA target. Using `conn.vta` here would authcrypt+forward + // the request back to the holder. + channels.push( + new DidcommVtaTransport({ + bridge, + holder, + signing, + vta: service, + mediator: conn.mediator, + }), + ); + } } const rest = restBaseUrl || services.rest?.baseUrl; if (rest) { channels.push(new RestChannel({ baseUrl: rest, holder, signing, service })); } if (channels.length === 0) { - throw new Error( - `${vtaDid} advertises no usable transport (#vta-didcomm or #vta-rest)`, - ); - } + throw new Error(`${vtaDid} advertises no usable transport (#tsp, #vta-didcomm or #vta-rest)`); + } + // `channels` is already in priority order; onboarding asks for DIDComm to + // lead instead. A stable partition, so everything else keeps its order. + const ordered = opts.didcommFirst + ? [ + ...channels.filter((c) => c.kind === "didcomm"), + ...channels.filter((c) => c.kind !== "didcomm"), + ] + : channels; return { - session: new VtaSession(channels), + session: new VtaSession(ordered), holder, service, ...(didcommConn ? { didcommConn } : {}), }; } +/** The wallet holder's session for `vtaDid`, over the warm mediator pool. */ +async function getVtaSession( + vtaDid: string, + restBaseUrl?: string, +): Promise { + const { identity: holder, signing } = await loadHolder(vtaDid); + return buildVtaSession( + vtaDid, + { holder, signing }, + (mediatorDid) => getWarmSession(mediatorDid, vtaDid), + { ...(restBaseUrl ? { restBaseUrl } : {}) }, + ); +} + // Vault — list. Runs vault/list/0.2 over the VTA's preferred transport // (DIDComm > REST). The holder's X25519 is the authcrypt sender / envelope // issuer. @@ -1185,18 +1246,20 @@ async function doOnboardConnect(params: OnboardConnectParams): Promise(ONBOARD_KEY); if (!pending) throw new Error("no pending onboarding — prepare first"); - // provision-integration is DIDComm-only in this port, so a mediator to route - // through is mandatory. Prefer the one the VTA published: a discovered value - // is authoritative, and letting a typed-in override win could silently - // redirect a connection that would otherwise have reached the right place. + // Prefer the mediator the VTA published: a discovered value is authoritative, + // and letting a typed-in override win could silently redirect a connection + // that would otherwise have reached the right place. Only when the VTA + // published nothing do we fall back to the operator's answer — not exotic, + // since a bare `did:peer` VTA has no document to resolve. // - // Only when the VTA published nothing do we fall back to the operator's - // answer. That case is not exotic — a bare `did:peer` VTA has no document to - // resolve, so it is the guaranteed outcome for a supported topology. This - // used to throw outright, which made those VTAs impossible to onboard at - // all. + // A mediator is no longer *mandatory*. Provisioning is an ordinary Trust Task + // now, so a VTA advertising REST or TSP can be onboarded with no DIDComm + // mediator in the picture at all. The prompt below therefore fires only when + // a mediator is the sole remaining possibility and we have not been given + // one; `buildVtaSession` raises if nothing usable is left. const mediatorDid = pending.mediatorDid ?? params.mediatorDid; - if (!mediatorDid) { + const advertised = await resolveVtaServices(pending.vtaDid).catch(() => ({}) as VtaServices); + if (!mediatorDid && !advertised.rest && !advertised.didcomm && !advertised.tsp) { // Carries a stable code so the caller can render a prompt and retry with // `mediatorDid`, rather than regex-matching this sentence (R3.7). throw new MediatorRequiredError(pending.vtaDid); @@ -1217,34 +1280,61 @@ async function doOnboardConnect(params: OnboardConnectParams): Promise>(); + const connect: MediatorConnector = (m) => { + let c = conns.get(m); + if (!c) { + c = connectMediatorSession({ holder: ephemeral, mediatorDid: m, vtaDid: pending.vtaDid }); + conns.set(m, c); + } + return c; + }; + // An operator-supplied mediator is the answer to "the VTA published none", so + // it stands in for the DIDComm service the document does not carry. + const services: VtaServices = { + ...advertised, + ...(advertised.didcomm || !mediatorDid ? {} : { didcomm: { mediatorDid } }), + }; let adminReply; try { reportStage("provisioning"); - const bridge = new MediatorSessionBridge(conn); + const { session } = await buildVtaSession( + pending.vtaDid, + { holder: ephemeral, signing: ephSigning }, + connect, + { didcommFirst: true, services }, + ); adminReply = await runProvisionIntegration({ - bridge, - ephemeral, + sender: session, ephemeralSigning: ephSigning, - service, - mediator: conn.mediator, vtaDid: pending.vtaDid, ...(params.context ? { context: params.context } : {}), ...(params.createIfMissing ? { createContext: true } : {}), note: "browser-plugin onboarding", }); } finally { - conn.close(); + for (const c of conns.values()) await c.then((x) => x.close()).catch(() => {}); } // Adopt the VTA-minted identity as the wallet's holder. The adopter