Skip to content

Commit b0e652a

Browse files
authored
feat(extension): TSP transport for trust tasks (TSP > DIDComm > REST) (#79)
Route trust tasks over TSP (Trust Spanning Protocol) when a VTA advertises a `TSPTransport` service, ahead of DIDComm and REST. TSP is sender- authenticated by its envelope (like DIDComm authcrypt), so it carries the same canonical Trust-Task envelope with no bearer. Transport: TSP rides the SAME warm mediator socket as DIDComm — the mediator multiplexes both (binary 0xF8 -> TSP, text -> DIDComm), so there is no second socket and no one-socket-per-DID conflict with the wallet's DIDComm inbox. `MediatorSessionTspTransport` sends the packed envelope as a binary frame and awaits the sealed reply (FIFO; a VtaSession drives one request at a time). Reply frames arrive back on the same session via the new `onTspFrame` demux (requires @openvtc/vti-didcomm-js ^0.6.0). - pnm-core: MediatorConnection gains `sendBinary` + `awaitTspFrame`; `MediatorSessionTspTransport`; `resolveVtaServices` detects the type-based `TSPTransport` service; `resolveVtaTspEndpoint` + `tspHolderIdentityFromSecret` (X25519 derived from the Ed25519 root, matching the holder did:peer keyAgreement key). - offscreen `getVtaSession`: builds the TSP channel from the pooled warm session, first in the priority chain. - Error phasing: a send failure (pre-send, nothing reached the VTA) is `e.client.unsupported` -> safe fallback to DIDComm; a reply timeout (post-send, a mutation may have applied) is `e.client.network` -> hard fail, no retry. - `preferTsp` setting (default ON; options-page toggle) to pin a VTA to DIDComm/REST if a mediator's TSP delivery misbehaves. - Popup transports label shows TSP (backfilled onto existing connections by the transport refresh — no re-onboard). Live-validated against a TSP-enabled VTA + mediator: vault list, proxy- login (sealed session blob), add (mutation + sealed secret), and delete all round-trip over TSP, with the DIDComm inbox coexisting on the one socket. Adds `MediatorSessionTspTransport` unit tests. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
1 parent 54d8a48 commit b0e652a

13 files changed

Lines changed: 410 additions & 17 deletions

File tree

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"@hpke/chacha20poly1305": "^1.8.0",
4747
"@hpke/core": "^1.9.0",
4848
"@noble/curves": "^2.2.0",
49-
"@openvtc/vti-didcomm-js": "^0.5.0",
49+
"@openvtc/vti-didcomm-js": "^0.6.0",
5050
"@openvtc/vti-tsp-js": "*",
5151
"@scure/base": "^2.2.0",
5252
"cbor-x": "^1.6.4"

packages/core/src/didcomm/index.ts

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,18 @@ export interface VtaServices {
416416
rest?: { baseUrl: string };
417417
/** Mediator DID from the `#vta-didcomm` service (`type: "DIDCommMessaging"`). */
418418
didcomm?: { mediatorDid: string };
419+
/** Mediator DID from the `#tsp` service (`type: "TSPTransport"`) — the
420+
* mediator the VTA is a local TSP account on. Highest-priority transport. */
421+
tsp?: { mediatorDid: string };
422+
}
423+
424+
/** Pull a mediator DID from a service endpoint, tolerating the
425+
* `[{ uri }]` / `{ uri }` / bare-string encodings. */
426+
function mediatorDidFromEndpoint(ep: unknown): string | undefined {
427+
if (Array.isArray(ep)) return (ep[0] as { uri?: string } | undefined)?.uri;
428+
if (ep && typeof ep === "object") return (ep as { uri?: string }).uri;
429+
if (typeof ep === "string") return ep;
430+
return undefined;
419431
}
420432

421433
/**
@@ -425,6 +437,19 @@ export interface VtaServices {
425437
* `#vta-rest` / `#vta-didcomm` the document carries (possibly both, possibly
426438
* one).
427439
*/
440+
/** Resolve a DID to its raw DID document (the `didDocument` field of the
441+
* resolution result). Used by TSP VID resolution to read the peer's
442+
* verification methods. Throws if the DID does not resolve. */
443+
export async function resolveDidDocument(did: string): Promise<Record<string, unknown>> {
444+
const resolution = (await vtiResolve(did, {})) as unknown as {
445+
didDocument?: Record<string, unknown>;
446+
};
447+
if (!resolution.didDocument) {
448+
throw new Error(`could not resolve DID document for ${did}`);
449+
}
450+
return resolution.didDocument;
451+
}
452+
428453
export async function resolveVtaServices(did: string): Promise<VtaServices> {
429454
const resolution = (await vtiResolve(did, {})) as {
430455
didDocument?: { service?: Array<{ id?: string; type?: string; serviceEndpoint?: unknown }> };
@@ -445,16 +470,19 @@ export async function resolveVtaServices(did: string): Promise<VtaServices> {
445470
if (fragment === "vta-didcomm" || svc.type === "DIDCommMessaging") {
446471
// `#vta-didcomm` serviceEndpoint is `[{ uri: <mediator-did>, ... }]`;
447472
// tolerate the object and bare-string encodings too.
448-
const ep = svc.serviceEndpoint;
449-
let mediatorDid: string | undefined;
450-
if (Array.isArray(ep)) mediatorDid = (ep[0] as { uri?: string } | undefined)?.uri;
451-
else if (ep && typeof ep === "object") mediatorDid = (ep as { uri?: string }).uri;
452-
else if (typeof ep === "string") mediatorDid = ep;
473+
const mediatorDid = mediatorDidFromEndpoint(svc.serviceEndpoint);
453474
// Prefer the VTA-specific fragment over a generic DIDCommMessaging entry.
454475
if (mediatorDid && (fragment === "vta-didcomm" || !out.didcomm)) {
455476
out.didcomm = { mediatorDid };
456477
}
457478
}
479+
480+
// TSP is matched on `type` alone — the `#key-id` fragment is fungible. The
481+
// endpoint is the mediator DID the VTA is a local TSP account on.
482+
if (svc.type === "TSPTransport") {
483+
const mediatorDid = mediatorDidFromEndpoint(svc.serviceEndpoint);
484+
if (mediatorDid && !out.tsp) out.tsp = { mediatorDid };
485+
}
458486
}
459487
return out;
460488
}
@@ -504,6 +532,16 @@ export type WebSocketCtor = new (
504532
export interface MediatorConnection {
505533
send(jwe: string): void;
506534
waitFor(thid: string, timeoutMs: number): Promise<Record<string, unknown>>;
535+
/** Send a raw TSP message (qb2 bytes) over the SAME socket as DIDComm. The
536+
* mediator sniffs the 0xF8 magic and routes it to its TSP handler — so TSP
537+
* and DIDComm share one socket per holder DID (no second socket, so no
538+
* one-socket-per-DID conflict; the reply arrives back on this socket). */
539+
sendBinary(bytes: Uint8Array): void;
540+
/** Await the next inbound TSP frame. FIFO — TSP carries no thread id, and a
541+
* VtaSession drives one request at a time. Call this to register the waiter,
542+
* then `sendBinary` (both synchronous, so no frame can arrive between them).
543+
* Rejects on timeout. Frames arriving with no waiter are discarded. */
544+
awaitTspFrame(timeoutMs: number): Promise<Uint8Array>;
507545
close(): void;
508546
/** True while the underlying WebSocket is open (live delivery active). A
509547
* warm-session holder checks this before reusing a cached connection. */
@@ -573,6 +611,23 @@ export async function connectMediatorSession(
573611
[opts.vtaDid, { publicJwk: vta.keyAgreementPublicJwk }],
574612
]);
575613

614+
// FIFO queue of TSP-reply waiters. A TSP frame the mediator multiplexes onto
615+
// this socket resolves the oldest waiter; frames with no waiter (flush-on-
616+
// connect stragglers) are discarded. TspChannel validates sender/envelope, so
617+
// a mis-delivered frame fails the op rather than being silently accepted.
618+
const tspWaiters: Array<{
619+
resolve: (b: Uint8Array) => void;
620+
reject: (e: Error) => void;
621+
timer: ReturnType<typeof setTimeout>;
622+
}> = [];
623+
const rejectTspWaiters = (err: Error) => {
624+
while (tspWaiters.length) {
625+
const w = tspWaiters.shift()!;
626+
clearTimeout(w.timer);
627+
w.reject(err);
628+
}
629+
};
630+
576631
const session = new VtiMediatorSession({
577632
mediator: auth.mediator,
578633
mediatorJwt: auth.accessToken,
@@ -587,6 +642,13 @@ export async function connectMediatorSession(
587642
const r = await vtiResolveKeyAgreement(did);
588643
return { publicJwk: x25519PublicJwk(r.x25519Pub) };
589644
},
645+
onTspFrame: (bytes: Uint8Array) => {
646+
const w = tspWaiters.shift();
647+
if (w) {
648+
clearTimeout(w.timer);
649+
w.resolve(bytes);
650+
} // else: straggler with no outstanding request — discard.
651+
},
590652
...(opts.onClose ? { onClose: opts.onClose } : {}),
591653
...(opts.webSocketImpl ? { WebSocketImpl: opts.webSocketImpl } : {}),
592654
});
@@ -597,7 +659,21 @@ export async function connectMediatorSession(
597659
send: (jwe: string) => session.send(jwe),
598660
waitFor: (thid: string, timeoutMs: number) =>
599661
session.waitFor(thid, timeoutMs) as Promise<Record<string, unknown>>,
600-
close: () => session.close(),
662+
sendBinary: (bytes: Uint8Array) => session.sendBinary(bytes),
663+
awaitTspFrame: (timeoutMs: number) =>
664+
new Promise<Uint8Array>((resolve, reject) => {
665+
const timer = setTimeout(() => {
666+
const i = tspWaiters.indexOf(waiter);
667+
if (i >= 0) tspWaiters.splice(i, 1);
668+
reject(new Error("timed out awaiting reply frame"));
669+
}, timeoutMs);
670+
const waiter = { resolve, reject, timer };
671+
tspWaiters.push(waiter);
672+
}),
673+
close: () => {
674+
rejectTspWaiters(new Error("mediator session closed"));
675+
session.close();
676+
},
601677
get isOpen() {
602678
return liveSession.isOpen;
603679
},

packages/core/src/vta/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export * from "./smoke.js";
1616
export * from "./transport.js";
1717
export * from "./trust-task.js";
1818
export * from "./tsp-channel.js";
19+
export * from "./tsp-mediator-transport.js";
1920
export * from "./tsp-vid.js";
2021
export * from "./types.js";
2122
export * from "./wallet-session.js";

packages/core/src/vta/tsp-channel.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
// simulator in tests).
1919

2020
import { pack, unpack } from "@openvtc/vti-tsp-js";
21+
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
2122

2223
import type { SendOpts, TrustTaskChannel } from "./channel.js";
2324
import { VtaClientError } from "./errors.js";
@@ -39,6 +40,28 @@ export interface TspHolderIdentity {
3940
encryptionPublicKey: Uint8Array;
4041
}
4142

43+
/**
44+
* Derive the holder's {@link TspHolderIdentity} from its Ed25519 root secret —
45+
* the single key material `loadHolder` unwraps. The X25519 encryption keys are
46+
* the Montgomery form of the Ed25519 secret, exactly as the holder's
47+
* `did:peer:2` keyAgreement key is minted (see `store/holder-identity.ts`
48+
* `buildHolder`), so the VTA verifies our TSP sender-auth against the same key
49+
* it resolves from our DID.
50+
*
51+
* @param did The holder's VID (its `did:peer`).
52+
* @param edSecret The raw 32-byte Ed25519 private key (`SigningIdentity.privateKey`).
53+
*/
54+
export function tspHolderIdentityFromSecret(did: string, edSecret: Uint8Array): TspHolderIdentity {
55+
const encryptionPrivateKey = ed25519.utils.toMontgomerySecret(edSecret);
56+
const encryptionPublicKey = x25519.getPublicKey(encryptionPrivateKey);
57+
return {
58+
vid: did,
59+
signingPrivateKey: edSecret,
60+
encryptionPrivateKey,
61+
encryptionPublicKey,
62+
};
63+
}
64+
4265
/** The VTA's TSP endpoint — its VID plus the public keys to seal to / verify. */
4366
export interface TspRemoteEndpoint {
4467
/** The VTA's VID (a DID). The TSP `receiver`, and the expected reply sender. */
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Production TspTransport — rides the shared mediator WebSocket.
2+
//
3+
// This is the network plumbing behind `TspChannel` (which owns the trust-task
4+
// binding + pack/unpack). It does NOT open its own socket: the mediator
5+
// multiplexes TSP and DIDComm onto ONE socket per holder DID (it sniffs the
6+
// 0xF8 magic on a binary frame → TSP, else DIDComm), so a TSP message is sent
7+
// as a binary frame over the existing DIDComm mediator session and the sealed
8+
// reply arrives back on that same session as a TSP frame.
9+
//
10+
// This mirrors the mediator's own single-socket design and sidesteps the
11+
// one-socket-per-DID rule (ADR 0005): there is no second socket to conflict
12+
// with the wallet's DIDComm inbox. It replaced an earlier dedicated raw-TSP
13+
// socket that could send but never received replies — the mediator delivers a
14+
// holder's inbound over its single live-delivery socket (the DIDComm one), so
15+
// the dedicated socket's flush-on-connect never saw the live reply.
16+
//
17+
// The socket, mediator auth, TSP-frame demux (base64url(qb2) → 0xF8 bytes), and
18+
// FIFO reply correlation all live in the shared `MediatorConnection`
19+
// (`connectMediatorSession`). This class is just the `TspTransport` adapter:
20+
// send binary, await the reply frame, with the transport-phase error codes
21+
// `TspChannel`/`VtaSession` expect.
22+
23+
import { VtaClientError } from "./errors.js";
24+
import type { TspTransport } from "./tsp-channel.js";
25+
26+
const DEFAULT_TIMEOUT_MS = 30_000;
27+
28+
/** The subset of a mediator connection the TSP transport rides — the TSP
29+
* send/receive surface of `MediatorConnection`. */
30+
export interface TspCapableConnection {
31+
/** Send a raw TSP message (qb2 bytes) as a binary frame over the socket. */
32+
sendBinary(bytes: Uint8Array): void;
33+
/** Await the next inbound TSP frame (FIFO). Rejects on timeout. */
34+
awaitTspFrame(timeoutMs: number): Promise<Uint8Array>;
35+
}
36+
37+
export interface MediatorSessionTspTransportOptions {
38+
/** The shared, already-connected mediator session (the warm DIDComm session
39+
* for this holder DID). Its socket carries both DIDComm and TSP. */
40+
connection: TspCapableConnection;
41+
/** Per-request reply timeout (default 30s). */
42+
timeoutMs?: number;
43+
}
44+
45+
/**
46+
* {@link TspTransport} over a shared {@link MediatorConnection}. Sends the
47+
* packed TSP envelope as a binary frame and awaits the sealed reply frame off
48+
* the same socket.
49+
*
50+
* Failure surface, by phase, is deliberate:
51+
* - **Send failure** (pre-send — the socket write threw, nothing reached the
52+
* VTA) raises `e.client.unsupported`, so a `VtaSession` cleanly falls back to
53+
* its next channel (DIDComm) without risk.
54+
* - **Reply timeout / socket drop** (post-send — the request may already have
55+
* been applied) raises `e.client.network` and does NOT fall back: retrying a
56+
* possibly-applied mutation on another transport would be unsafe.
57+
*
58+
* The socket lifecycle is owned by the warm-session pool, so this has no
59+
* `close()` — closing the shared session is the pool's job, not a per-op TSP
60+
* transport's.
61+
*/
62+
export class MediatorSessionTspTransport implements TspTransport {
63+
private readonly conn: TspCapableConnection;
64+
private readonly timeoutMs: number;
65+
66+
constructor(opts: MediatorSessionTspTransportOptions) {
67+
this.conn = opts.connection;
68+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
69+
}
70+
71+
async sendAndAwaitReply(
72+
packed: Uint8Array,
73+
options: { timeoutMs?: number } = {},
74+
): Promise<Uint8Array> {
75+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
76+
77+
// Register the reply waiter BEFORE sending (both synchronous — no frame can
78+
// arrive between them), per the MediatorConnection contract.
79+
const replyPromise = this.conn.awaitTspFrame(timeoutMs);
80+
81+
try {
82+
this.conn.sendBinary(packed);
83+
} catch (err) {
84+
// Pre-send: the socket write failed, so nothing reached the VTA. Swallow
85+
// the now-orphaned waiter's eventual timeout, and signal a safe fallback.
86+
replyPromise.catch(() => {});
87+
throw new VtaClientError(
88+
"e.client.unsupported",
89+
`tsp: send failed (${(err as Error).message}) — falling back`,
90+
);
91+
}
92+
93+
try {
94+
return await replyPromise;
95+
} catch (err) {
96+
// Post-send: timeout or socket drop. The request may already have applied
97+
// — hard-fail (no VtaSession fallback for a possible mutation).
98+
throw new VtaClientError("e.client.network", `tsp: ${(err as Error).message}`);
99+
}
100+
}
101+
}

packages/core/src/vta/tsp-vid.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import { base58 } from "@scure/base";
1414

15-
import { resolveKeyAgreement } from "../didcomm/index.js";
15+
import { resolveDidDocument, resolveKeyAgreement } from "../didcomm/index.js";
1616
import { base64urlToBytes } from "../webauthn/base64url.js";
1717
import { VtaClientError } from "./errors.js";
1818
import type { TspRemoteEndpoint } from "./tsp-channel.js";
@@ -166,3 +166,13 @@ export async function resolveTspEndpoint(
166166
const doc = await resolveDidDocument(did);
167167
return tspEndpointFromResolved(did, ka.keyAgreementPublicJwk, doc);
168168
}
169+
170+
/**
171+
* Resolve a VTA's DID into its {@link TspRemoteEndpoint} using the plugin's
172+
* built-in DID resolver. The zero-dependency convenience form of
173+
* {@link resolveTspEndpoint} — callers that don't need to inject a resolver
174+
* (i.e. everything outside tests) use this.
175+
*/
176+
export function resolveVtaTspEndpoint(vtaDid: string): Promise<TspRemoteEndpoint> {
177+
return resolveTspEndpoint(vtaDid, resolveDidDocument);
178+
}

0 commit comments

Comments
 (0)