Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
88 changes: 82 additions & 6 deletions packages/core/src/didcomm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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<Record<string, unknown>> {
const resolution = (await vtiResolve(did, {})) as unknown as {
didDocument?: Record<string, unknown>;
};
if (!resolution.didDocument) {
throw new Error(`could not resolve DID document for ${did}`);
}
return resolution.didDocument;
}

export async function resolveVtaServices(did: string): Promise<VtaServices> {
const resolution = (await vtiResolve(did, {})) as {
didDocument?: { service?: Array<{ id?: string; type?: string; serviceEndpoint?: unknown }> };
Expand All @@ -445,16 +470,19 @@ export async function resolveVtaServices(did: string): Promise<VtaServices> {
if (fragment === "vta-didcomm" || svc.type === "DIDCommMessaging") {
// `#vta-didcomm` serviceEndpoint is `[{ uri: <mediator-did>, ... }]`;
// 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;
}
Expand Down Expand Up @@ -504,6 +532,16 @@ export type WebSocketCtor = new (
export interface MediatorConnection {
send(jwe: string): void;
waitFor(thid: string, timeoutMs: number): Promise<Record<string, unknown>>;
/** 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<Uint8Array>;
close(): void;
/** True while the underlying WebSocket is open (live delivery active). A
* warm-session holder checks this before reusing a cached connection. */
Expand Down Expand Up @@ -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<typeof setTimeout>;
}> = [];
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,
Expand All @@ -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 } : {}),
});
Expand All @@ -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<Record<string, unknown>>,
close: () => session.close(),
sendBinary: (bytes: Uint8Array) => session.sendBinary(bytes),
awaitTspFrame: (timeoutMs: number) =>
new Promise<Uint8Array>((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;
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/vta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 23 additions & 0 deletions packages/core/src/vta/tsp-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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. */
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/vta/tsp-mediator-transport.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>;
}

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<Uint8Array> {
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}`);
}
}
}
12 changes: 11 additions & 1 deletion packages/core/src/vta/tsp-vid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<TspRemoteEndpoint> {
return resolveTspEndpoint(vtaDid, resolveDidDocument);
}
Loading