diff --git a/packages/core/package.json b/packages/core/package.json index 4285275..78d71e0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ "@hpke/chacha20poly1305": "^1.8.0", "@hpke/core": "^1.9.0", "@noble/curves": "^2.2.0", - "@openvtc/vti-didcomm-js": "^0.5.0", + "@openvtc/vti-didcomm-js": "^0.6.0", "@openvtc/vti-tsp-js": "*", "@scure/base": "^2.2.0", "cbor-x": "^1.6.4" diff --git a/packages/core/src/didcomm/index.ts b/packages/core/src/didcomm/index.ts index 067fb9a..744e00a 100644 --- a/packages/core/src/didcomm/index.ts +++ b/packages/core/src/didcomm/index.ts @@ -416,6 +416,18 @@ export interface VtaServices { rest?: { baseUrl: string }; /** Mediator DID from the `#vta-didcomm` service (`type: "DIDCommMessaging"`). */ didcomm?: { mediatorDid: string }; + /** Mediator DID from the `#tsp` service (`type: "TSPTransport"`) — the + * mediator the VTA is a local TSP account on. Highest-priority transport. */ + tsp?: { mediatorDid: string }; +} + +/** Pull a mediator DID from a service endpoint, tolerating the + * `[{ uri }]` / `{ uri }` / bare-string encodings. */ +function mediatorDidFromEndpoint(ep: unknown): string | undefined { + if (Array.isArray(ep)) return (ep[0] as { uri?: string } | undefined)?.uri; + if (ep && typeof ep === "object") return (ep as { uri?: string }).uri; + if (typeof ep === "string") return ep; + return undefined; } /** @@ -425,6 +437,19 @@ export interface VtaServices { * `#vta-rest` / `#vta-didcomm` the document carries (possibly both, possibly * one). */ +/** Resolve a DID to its raw DID document (the `didDocument` field of the + * resolution result). Used by TSP VID resolution to read the peer's + * verification methods. Throws if the DID does not resolve. */ +export async function resolveDidDocument(did: string): Promise> { + const resolution = (await vtiResolve(did, {})) as unknown as { + didDocument?: Record; + }; + if (!resolution.didDocument) { + throw new Error(`could not resolve DID document for ${did}`); + } + return resolution.didDocument; +} + export async function resolveVtaServices(did: string): Promise { const resolution = (await vtiResolve(did, {})) as { didDocument?: { service?: Array<{ id?: string; type?: string; serviceEndpoint?: unknown }> }; @@ -445,16 +470,19 @@ export async function resolveVtaServices(did: string): Promise { if (fragment === "vta-didcomm" || svc.type === "DIDCommMessaging") { // `#vta-didcomm` serviceEndpoint is `[{ uri: , ... }]`; // tolerate the object and bare-string encodings too. - const ep = svc.serviceEndpoint; - let mediatorDid: string | undefined; - if (Array.isArray(ep)) mediatorDid = (ep[0] as { uri?: string } | undefined)?.uri; - else if (ep && typeof ep === "object") mediatorDid = (ep as { uri?: string }).uri; - else if (typeof ep === "string") mediatorDid = ep; + const mediatorDid = mediatorDidFromEndpoint(svc.serviceEndpoint); // Prefer the VTA-specific fragment over a generic DIDCommMessaging entry. if (mediatorDid && (fragment === "vta-didcomm" || !out.didcomm)) { out.didcomm = { mediatorDid }; } } + + // TSP is matched on `type` alone — the `#key-id` fragment is fungible. The + // endpoint is the mediator DID the VTA is a local TSP account on. + if (svc.type === "TSPTransport") { + const mediatorDid = mediatorDidFromEndpoint(svc.serviceEndpoint); + if (mediatorDid && !out.tsp) out.tsp = { mediatorDid }; + } } return out; } @@ -504,6 +532,16 @@ export type WebSocketCtor = new ( export interface MediatorConnection { send(jwe: string): void; waitFor(thid: string, timeoutMs: number): Promise>; + /** Send a raw TSP message (qb2 bytes) over the SAME socket as DIDComm. The + * mediator sniffs the 0xF8 magic and routes it to its TSP handler — so TSP + * and DIDComm share one socket per holder DID (no second socket, so no + * one-socket-per-DID conflict; the reply arrives back on this socket). */ + sendBinary(bytes: Uint8Array): void; + /** Await the next inbound TSP frame. FIFO — TSP carries no thread id, and a + * VtaSession drives one request at a time. Call this to register the waiter, + * then `sendBinary` (both synchronous, so no frame can arrive between them). + * Rejects on timeout. Frames arriving with no waiter are discarded. */ + awaitTspFrame(timeoutMs: number): Promise; close(): void; /** True while the underlying WebSocket is open (live delivery active). A * warm-session holder checks this before reusing a cached connection. */ @@ -573,6 +611,23 @@ export async function connectMediatorSession( [opts.vtaDid, { publicJwk: vta.keyAgreementPublicJwk }], ]); + // FIFO queue of TSP-reply waiters. A TSP frame the mediator multiplexes onto + // this socket resolves the oldest waiter; frames with no waiter (flush-on- + // connect stragglers) are discarded. TspChannel validates sender/envelope, so + // a mis-delivered frame fails the op rather than being silently accepted. + const tspWaiters: Array<{ + resolve: (b: Uint8Array) => void; + reject: (e: Error) => void; + timer: ReturnType; + }> = []; + const rejectTspWaiters = (err: Error) => { + while (tspWaiters.length) { + const w = tspWaiters.shift()!; + clearTimeout(w.timer); + w.reject(err); + } + }; + const session = new VtiMediatorSession({ mediator: auth.mediator, mediatorJwt: auth.accessToken, @@ -587,6 +642,13 @@ export async function connectMediatorSession( const r = await vtiResolveKeyAgreement(did); return { publicJwk: x25519PublicJwk(r.x25519Pub) }; }, + onTspFrame: (bytes: Uint8Array) => { + const w = tspWaiters.shift(); + if (w) { + clearTimeout(w.timer); + w.resolve(bytes); + } // else: straggler with no outstanding request — discard. + }, ...(opts.onClose ? { onClose: opts.onClose } : {}), ...(opts.webSocketImpl ? { WebSocketImpl: opts.webSocketImpl } : {}), }); @@ -597,7 +659,21 @@ export async function connectMediatorSession( send: (jwe: string) => session.send(jwe), waitFor: (thid: string, timeoutMs: number) => session.waitFor(thid, timeoutMs) as Promise>, - close: () => session.close(), + sendBinary: (bytes: Uint8Array) => session.sendBinary(bytes), + awaitTspFrame: (timeoutMs: number) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const i = tspWaiters.indexOf(waiter); + if (i >= 0) tspWaiters.splice(i, 1); + reject(new Error("timed out awaiting reply frame")); + }, timeoutMs); + const waiter = { resolve, reject, timer }; + tspWaiters.push(waiter); + }), + close: () => { + rejectTspWaiters(new Error("mediator session closed")); + session.close(); + }, get isOpen() { return liveSession.isOpen; }, diff --git a/packages/core/src/vta/index.ts b/packages/core/src/vta/index.ts index 80b558c..34ddd51 100644 --- a/packages/core/src/vta/index.ts +++ b/packages/core/src/vta/index.ts @@ -16,6 +16,7 @@ export * from "./smoke.js"; export * from "./transport.js"; export * from "./trust-task.js"; export * from "./tsp-channel.js"; +export * from "./tsp-mediator-transport.js"; export * from "./tsp-vid.js"; export * from "./types.js"; export * from "./wallet-session.js"; diff --git a/packages/core/src/vta/tsp-channel.ts b/packages/core/src/vta/tsp-channel.ts index 9b36e94..4dbb0f0 100644 --- a/packages/core/src/vta/tsp-channel.ts +++ b/packages/core/src/vta/tsp-channel.ts @@ -18,6 +18,7 @@ // simulator in tests). import { pack, unpack } from "@openvtc/vti-tsp-js"; +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; import type { SendOpts, TrustTaskChannel } from "./channel.js"; import { VtaClientError } from "./errors.js"; @@ -39,6 +40,28 @@ export interface TspHolderIdentity { encryptionPublicKey: Uint8Array; } +/** + * Derive the holder's {@link TspHolderIdentity} from its Ed25519 root secret — + * the single key material `loadHolder` unwraps. The X25519 encryption keys are + * the Montgomery form of the Ed25519 secret, exactly as the holder's + * `did:peer:2` keyAgreement key is minted (see `store/holder-identity.ts` + * `buildHolder`), so the VTA verifies our TSP sender-auth against the same key + * it resolves from our DID. + * + * @param did The holder's VID (its `did:peer`). + * @param edSecret The raw 32-byte Ed25519 private key (`SigningIdentity.privateKey`). + */ +export function tspHolderIdentityFromSecret(did: string, edSecret: Uint8Array): TspHolderIdentity { + const encryptionPrivateKey = ed25519.utils.toMontgomerySecret(edSecret); + const encryptionPublicKey = x25519.getPublicKey(encryptionPrivateKey); + return { + vid: did, + signingPrivateKey: edSecret, + encryptionPrivateKey, + encryptionPublicKey, + }; +} + /** The VTA's TSP endpoint — its VID plus the public keys to seal to / verify. */ export interface TspRemoteEndpoint { /** The VTA's VID (a DID). The TSP `receiver`, and the expected reply sender. */ diff --git a/packages/core/src/vta/tsp-mediator-transport.ts b/packages/core/src/vta/tsp-mediator-transport.ts new file mode 100644 index 0000000..232811d --- /dev/null +++ b/packages/core/src/vta/tsp-mediator-transport.ts @@ -0,0 +1,101 @@ +// Production TspTransport — rides the shared mediator WebSocket. +// +// This is the network plumbing behind `TspChannel` (which owns the trust-task +// binding + pack/unpack). It does NOT open its own socket: the mediator +// multiplexes TSP and DIDComm onto ONE socket per holder DID (it sniffs the +// 0xF8 magic on a binary frame → TSP, else DIDComm), so a TSP message is sent +// as a binary frame over the existing DIDComm mediator session and the sealed +// reply arrives back on that same session as a TSP frame. +// +// This mirrors the mediator's own single-socket design and sidesteps the +// one-socket-per-DID rule (ADR 0005): there is no second socket to conflict +// with the wallet's DIDComm inbox. It replaced an earlier dedicated raw-TSP +// socket that could send but never received replies — the mediator delivers a +// holder's inbound over its single live-delivery socket (the DIDComm one), so +// the dedicated socket's flush-on-connect never saw the live reply. +// +// The socket, mediator auth, TSP-frame demux (base64url(qb2) → 0xF8 bytes), and +// FIFO reply correlation all live in the shared `MediatorConnection` +// (`connectMediatorSession`). This class is just the `TspTransport` adapter: +// send binary, await the reply frame, with the transport-phase error codes +// `TspChannel`/`VtaSession` expect. + +import { VtaClientError } from "./errors.js"; +import type { TspTransport } from "./tsp-channel.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** The subset of a mediator connection the TSP transport rides — the TSP + * send/receive surface of `MediatorConnection`. */ +export interface TspCapableConnection { + /** Send a raw TSP message (qb2 bytes) as a binary frame over the socket. */ + sendBinary(bytes: Uint8Array): void; + /** Await the next inbound TSP frame (FIFO). Rejects on timeout. */ + awaitTspFrame(timeoutMs: number): Promise; +} + +export interface MediatorSessionTspTransportOptions { + /** The shared, already-connected mediator session (the warm DIDComm session + * for this holder DID). Its socket carries both DIDComm and TSP. */ + connection: TspCapableConnection; + /** Per-request reply timeout (default 30s). */ + timeoutMs?: number; +} + +/** + * {@link TspTransport} over a shared {@link MediatorConnection}. Sends the + * packed TSP envelope as a binary frame and awaits the sealed reply frame off + * the same socket. + * + * Failure surface, by phase, is deliberate: + * - **Send failure** (pre-send — the socket write threw, nothing reached the + * VTA) raises `e.client.unsupported`, so a `VtaSession` cleanly falls back to + * its next channel (DIDComm) without risk. + * - **Reply timeout / socket drop** (post-send — the request may already have + * been applied) raises `e.client.network` and does NOT fall back: retrying a + * possibly-applied mutation on another transport would be unsafe. + * + * The socket lifecycle is owned by the warm-session pool, so this has no + * `close()` — closing the shared session is the pool's job, not a per-op TSP + * transport's. + */ +export class MediatorSessionTspTransport implements TspTransport { + private readonly conn: TspCapableConnection; + private readonly timeoutMs: number; + + constructor(opts: MediatorSessionTspTransportOptions) { + this.conn = opts.connection; + this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + async sendAndAwaitReply( + packed: Uint8Array, + options: { timeoutMs?: number } = {}, + ): Promise { + const timeoutMs = options.timeoutMs ?? this.timeoutMs; + + // Register the reply waiter BEFORE sending (both synchronous — no frame can + // arrive between them), per the MediatorConnection contract. + const replyPromise = this.conn.awaitTspFrame(timeoutMs); + + try { + this.conn.sendBinary(packed); + } catch (err) { + // Pre-send: the socket write failed, so nothing reached the VTA. Swallow + // the now-orphaned waiter's eventual timeout, and signal a safe fallback. + replyPromise.catch(() => {}); + throw new VtaClientError( + "e.client.unsupported", + `tsp: send failed (${(err as Error).message}) — falling back`, + ); + } + + try { + return await replyPromise; + } catch (err) { + // Post-send: timeout or socket drop. The request may already have applied + // — hard-fail (no VtaSession fallback for a possible mutation). + throw new VtaClientError("e.client.network", `tsp: ${(err as Error).message}`); + } + } +} diff --git a/packages/core/src/vta/tsp-vid.ts b/packages/core/src/vta/tsp-vid.ts index 3c8c1bf..f5caaef 100644 --- a/packages/core/src/vta/tsp-vid.ts +++ b/packages/core/src/vta/tsp-vid.ts @@ -12,7 +12,7 @@ import { base58 } from "@scure/base"; -import { resolveKeyAgreement } from "../didcomm/index.js"; +import { resolveDidDocument, resolveKeyAgreement } from "../didcomm/index.js"; import { base64urlToBytes } from "../webauthn/base64url.js"; import { VtaClientError } from "./errors.js"; import type { TspRemoteEndpoint } from "./tsp-channel.js"; @@ -166,3 +166,13 @@ export async function resolveTspEndpoint( const doc = await resolveDidDocument(did); return tspEndpointFromResolved(did, ka.keyAgreementPublicJwk, doc); } + +/** + * Resolve a VTA's DID into its {@link TspRemoteEndpoint} using the plugin's + * built-in DID resolver. The zero-dependency convenience form of + * {@link resolveTspEndpoint} — callers that don't need to inject a resolver + * (i.e. everything outside tests) use this. + */ +export function resolveVtaTspEndpoint(vtaDid: string): Promise { + return resolveTspEndpoint(vtaDid, resolveDidDocument); +} diff --git a/packages/core/tests/tsp.mediator-transport.mjs b/packages/core/tests/tsp.mediator-transport.mjs new file mode 100644 index 0000000..f56145d --- /dev/null +++ b/packages/core/tests/tsp.mediator-transport.mjs @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { MediatorSessionTspTransport } from "../dist/index.js"; + +/** A fake MediatorConnection TSP surface: records binary sends and lets the test + * resolve/reject the awaited reply frame. */ +function fakeConn() { + const sent = []; + let pending = null; + return { + sent, + sendBinary(bytes) { + sent.push(bytes); + }, + awaitTspFrame(timeoutMs) { + return new Promise((resolve, reject) => { + pending = { resolve, reject, timeoutMs }; + }); + }, + // test helpers + deliver(bytes) { + pending.resolve(bytes); + }, + fail(err) { + pending.reject(err); + }, + pendingTimeout() { + return pending.timeoutMs; + }, + }; +} + +test("sends the packed bytes as a binary frame and resolves the awaited reply", async () => { + const conn = fakeConn(); + const transport = new MediatorSessionTspTransport({ connection: conn, timeoutMs: 1234 }); + const packed = new Uint8Array([0xf8, 1, 2, 3]); + const reply = new Uint8Array([0xf8, 9, 8, 7]); + + const p = transport.sendAndAwaitReply(packed); + assert.equal(conn.sent.length, 1); + assert.deepEqual(conn.sent[0], packed); + assert.equal(conn.pendingTimeout(), 1234); // default timeout used + + conn.deliver(reply); + assert.deepEqual(await p, reply); +}); + +test("per-call timeout overrides the default", async () => { + const conn = fakeConn(); + const transport = new MediatorSessionTspTransport({ connection: conn, timeoutMs: 1234 }); + const p = transport.sendAndAwaitReply(new Uint8Array([0xf8]), { timeoutMs: 50 }); + assert.equal(conn.pendingTimeout(), 50); + conn.deliver(new Uint8Array([0xf8, 1])); + await p; +}); + +test("a send failure surfaces e.client.unsupported (safe fallback, pre-send)", async () => { + const conn = { + sendBinary() { + throw new Error("socket not connected"); + }, + awaitTspFrame() { + return new Promise(() => {}); // never resolves; must be swallowed + }, + }; + const transport = new MediatorSessionTspTransport({ connection: conn }); + await assert.rejects(transport.sendAndAwaitReply(new Uint8Array([0xf8])), (err) => { + assert.equal(err.code, "e.client.unsupported"); + return true; + }); +}); + +test("a reply timeout surfaces e.client.network (no retry, post-send)", async () => { + const conn = fakeConn(); + const transport = new MediatorSessionTspTransport({ connection: conn }); + const p = transport.sendAndAwaitReply(new Uint8Array([0xf8, 5])); + conn.fail(new Error("timed out awaiting reply frame")); + await assert.rejects(p, (err) => { + assert.equal(err.code, "e.client.network"); + return true; + }); +}); diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 6aeb31e..fc99194 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -594,6 +594,9 @@ export interface VtaTransportsView { /** Mediator DID, present iff the VTA's DID doc carries a * `#vta-didcomm` (or generic `DIDCommMessaging`) service entry. */ mediatorDid?: string; + /** Mediator DID, present iff the VTA's DID doc carries a `#tsp` + * (`TSPTransport`) service entry. */ + tspMediatorDid?: string; } export type RuntimeRefreshVtaTransportsResponse = diff --git a/packages/extension/src/config.ts b/packages/extension/src/config.ts index eb09712..176f40e 100644 --- a/packages/extension/src/config.ts +++ b/packages/extension/src/config.ts @@ -71,6 +71,20 @@ export interface WalletSettings { /** The gateway's VAPID *public* key (base64url, uncompressed P-256 point) — * the `applicationServerKey` subscribers register. */ pushGatewayVapidPublicKey?: string; + + /** + * Prefer TSP as the top-priority transport when a VTA advertises it + * (`TSPTransport` service). **Default: `true`.** + * + * TSP rides the same warm mediator socket as DIDComm (the mediator + * multiplexes both), so there is no extra socket. The offscreen routes over + * TSP first, falling back to DIDComm on a *connect* failure (nothing reached + * the VTA, so the fallback is safe). A TSP *reply* timeout is a hard failure + * by design — a possibly-applied mutation must not be retried on another + * transport. Set to `false` to pin a VTA to DIDComm/REST (e.g. if a + * particular mediator's TSP delivery misbehaves). + */ + preferTsp?: boolean; } const SETTINGS_KEY = "pnm/settings/v1"; @@ -92,6 +106,7 @@ export async function getSettings(): Promise { ? { defaultStepUpVtaMediatorDid: s.defaultStepUpVtaMediatorDid } : {}), encryptHolderSecret, + preferTsp: typeof s?.preferTsp === "boolean" ? s.preferTsp : true, ...(s?.pushGatewayUrl ? { pushGatewayUrl: s.pushGatewayUrl } : {}), ...(s?.pushGatewayVapidPublicKey ? { pushGatewayVapidPublicKey: s.pushGatewayVapidPublicKey } diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index c16aa62..f766db3 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -24,7 +24,11 @@ import { requestVtaApproval, resolveKeyAgreement, resolveVtaServices, + resolveVtaTspEndpoint, RestChannel, + TspChannel, + MediatorSessionTspTransport, + tspHolderIdentityFromSecret, setDeviceWake, signingIdentityFromSecret, stepUpVtaFinish, @@ -51,6 +55,7 @@ import { verifyDid, } from "@openvtc/pnm-core"; import { base64url } from "@openvtc/vti-didcomm-js"; +import { getSettings } from "./config.js"; import { getWalletMediatorDid, loadHolder } from "./holder.js"; import { WebAuthnPrfSecretWrap } from "./webauthn-prf-wrap.js"; import { @@ -412,19 +417,50 @@ interface VtaSessionHandle { } // Build a VtaSession for `vtaDid` honouring the advertised transports -// (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 -// DIDComm-only VTA yields a DIDComm-only session; a REST-only VTA yields the -// same REST path as before. A VTA advertising both now PREFERS DIDComm. +// (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( vtaDid: string, restBaseUrl?: string, ): Promise { - const { identity: holder } = await loadHolder(vtaDid); + const { identity: holder, signing } = await loadHolder(vtaDid); const service = await resolveKeyAgreement(vtaDid); const services = await resolveVtaServices(vtaDid); const channels: TrustTaskChannel[] = []; + // TSP is the highest-priority transport. It rides the SAME warm mediator + // socket as DIDComm (the mediator multiplexes both — binary 0xF8 → TSP, text + // → DIDComm), so there is no second socket to conflict with the wallet's + // DIDComm inbox. The Trust-Task envelope is sealed end-to-end to the VTA and + // sent as a binary frame; the reply arrives back on the same session. + // + // Gated behind the `preferTsp` setting (default ON). A TSP reply timeout is a + // hard failure (a mutation may already have applied) rather than a fall-back, + // so an operator can turn TSP off to pin a VTA to DIDComm/REST if a given + // mediator's TSP delivery misbehaves. + const { preferTsp } = await getSettings(); + if (preferTsp && services.tsp) { + try { + 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); + channels.push( + new TspChannel({ + transport: new MediatorSessionTspTransport({ connection: conn }), + holder: tspHolderIdentityFromSecret(holder.did, signing.privateKey), + vta: vtaTsp, + }), + ); + } catch (err) { + // Resolution failure (e.g. the VTA advertises #tsp but its keys don't + // resolve) shouldn't kill the session — DIDComm/REST still work. + console.warn("[pnm tsp] skipping TSP channel:", (err as Error).message); + } + } if (services.didcomm) { const conn = await getWarmSession(services.didcomm.mediatorDid, vtaDid); const bridge = new MediatorSessionBridge(conn); @@ -844,11 +880,12 @@ async function doForgetHolderRecord(vtaDid: string): Promise { * the matching `#vta-rest` / `#vta-didcomm` service entry. */ async function doRefreshVtaTransports( vtaDid: string, -): Promise<{ restBaseUrl?: string; mediatorDid?: string }> { +): Promise<{ restBaseUrl?: string; mediatorDid?: string; tspMediatorDid?: string }> { const services = await resolveVtaServices(vtaDid); return { ...(services.rest ? { restBaseUrl: services.rest.baseUrl } : {}), ...(services.didcomm ? { mediatorDid: services.didcomm.mediatorDid } : {}), + ...(services.tsp ? { tspMediatorDid: services.tsp.mediatorDid } : {}), }; } diff --git a/packages/extension/src/options.tsx b/packages/extension/src/options.tsx index 7ae3bdc..9a13142 100644 --- a/packages/extension/src/options.tsx +++ b/packages/extension/src/options.tsx @@ -35,6 +35,7 @@ function Options() { // is one toggle = one tap = persisted state). const [encryptOn, setEncryptOn] = useState(false); const [encryptBusy, setEncryptBusy] = useState(false); + const [preferTspOn, setPreferTspOn] = useState(false); const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); const [trustedSites, setTrustedSites] = useState([]); @@ -58,6 +59,7 @@ function Options() { setPushGatewayUrl(s.pushGatewayUrl ?? ""); setPushGatewayVapidPublicKey(s.pushGatewayVapidPublicKey ?? ""); setEncryptOn(Boolean(s.encryptHolderSecret)); + setPreferTspOn(Boolean(s.preferTsp)); // Multi-VTA: show the active VTA's holder DID. Read straight // from the persisted connection — no decryption needed for a // display string, and options runs in a context with no PRF @@ -268,6 +270,39 @@ function Options() { +
+
+ { + const on = e.target.checked; + setPreferTspOn(on); + void setSettings({ preferTsp: on }); + }} + style={{ transform: "scale(1.2)" }} + /> + +
+
+ When a VTA advertises a TSPTransport service, route trust tasks over TSP + first (TSP > DIDComm > REST). TSP shares the same mediator socket as DIDComm and + falls back to DIDComm if it can't connect. On by default. Turn off + to pin a VTA to DIDComm/REST if a particular mediator's TSP delivery misbehaves. +
+
+
DIDComm > REST) — the same + // order the offscreen VtaSession prefers. const transports = [ + connection.tspMediatorDid ? "TSP" : null, connection.mediatorDid ? "DIDComm" : null, connection.restBaseUrl ? "REST" : null, ] @@ -2374,7 +2377,8 @@ function Popup() { // changed, to avoid spurious re-renders + storage writes. const restChanged = (fresh.restBaseUrl ?? null) !== (current.restBaseUrl ?? null); const medChanged = (fresh.mediatorDid ?? null) !== (current.mediatorDid ?? null); - if (!restChanged && !medChanged) return; + const tspChanged = (fresh.tspMediatorDid ?? null) !== (current.tspMediatorDid ?? null); + if (!restChanged && !medChanged && !tspChanged) return; // Rebuild the connection without unset transports — JS spread keeps // the old value if the new field is absent; building fresh lets us @@ -2386,11 +2390,12 @@ function Popup() { connectedAt: current.connectedAt, ...(fresh.restBaseUrl ? { restBaseUrl: fresh.restBaseUrl } : {}), ...(fresh.mediatorDid ? { mediatorDid: fresh.mediatorDid } : {}), + ...(fresh.tspMediatorDid ? { tspMediatorDid: fresh.tspMediatorDid } : {}), }; console.info( "[pnm] VTA transports refreshed:", - { rest: !!fresh.restBaseUrl, didcomm: !!fresh.mediatorDid }, - "(was: rest=" + !!current.restBaseUrl + ", didcomm=" + !!current.mediatorDid + ")", + { tsp: !!fresh.tspMediatorDid, rest: !!fresh.restBaseUrl, didcomm: !!fresh.mediatorDid }, + "(was: tsp=" + !!current.tspMediatorDid + ", rest=" + !!current.restBaseUrl + ", didcomm=" + !!current.mediatorDid + ")", ); setConnection(updated); } diff --git a/packages/extension/src/store.ts b/packages/extension/src/store.ts index 403b46e..b4c65ea 100644 --- a/packages/extension/src/store.ts +++ b/packages/extension/src/store.ts @@ -15,6 +15,10 @@ export interface Connection { restBaseUrl?: string; /** Mediator DID from `#vta-didcomm`, if advertised at onboarding time. */ mediatorDid?: string; + /** Mediator DID from the `#tsp` (`TSPTransport`) service, if advertised. + * Backfilled by the transport refresh, so existing connections gain it + * without re-onboarding. */ + tspMediatorDid?: string; /** When the connection was established (ms epoch). */ connectedAt: number; }