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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openvtc/vti-didcomm-js",
"version": "0.5.0",
"version": "0.6.0",
"description": "Browser-side DIDComm v2 implementation for the Verifiable Trust Infrastructure. Focused subset: authcrypt/anoncrypt (ECDH-1PU/ECDH-ES + A256CBC-HS512) over X25519 and P-256, did:key + did:peer + did:webvh resolution, VTA REST auth, and ATM mediator transport. Byte-compatible with affinidi-messaging-didcomm.",
"type": "module",
"main": "src/index.js",
Expand Down
47 changes: 46 additions & 1 deletion src/mediator-transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,15 @@ export class MediatorSession {
* inbound, e.g. a server-initiated request). Fired in addition to the
* internal buffering, so request/reply via `waitFor` is unaffected;
* handlers should filter by the message `type`.
* @param {(bytes: Uint8Array) => void} [args.onTspFrame] - called for each
* inbound TSP frame the mediator multiplexes onto this socket (raw qb2
* bytes, first byte 0xF8). Without it, TSP frames are dropped rather than
* run through the DIDComm unpacker (which can't read them).
* @param {() => void} [args.onClose] - called once if the socket drops
* unexpectedly (after a successful open, not via `close()`). Lets a
* warm-session holder evict + reconnect.
*/
constructor({ mediator, mediatorJwt, client, senderKeys, resolveSender, WebSocketImpl, onMessage, onClose, onError, connectTimeoutMs }) {
constructor({ mediator, mediatorJwt, client, senderKeys, resolveSender, WebSocketImpl, onMessage, onTspFrame, onClose, onError, connectTimeoutMs }) {
if (!mediator?.wsEndpoint) {
throw new Error("MediatorSession: mediator.wsEndpoint required (mediator advertises no wss endpoint)");
}
Expand All @@ -259,6 +263,12 @@ export class MediatorSession {
this.senderKeys = senderKeys ?? new Map();
this.resolveSender = resolveSender;
this.onMessage = onMessage;
// Fired for each inbound TSP frame (a non-DIDComm message the mediator
// multiplexes onto this same socket — CESR qb2, first byte 0xF8, delivered
// as base64url(qb2) text). Receives the raw qb2 bytes; a TSP consumer
// unpacks them. Without a handler, TSP frames are dropped (not run through
// the DIDComm unpacker, which can't read them).
this.onTspFrame = onTspFrame;
// Fired once when the socket drops *unexpectedly* (after a successful
// open, not via close()). Lets a caller holding a warm session evict +
// reconnect. Not fired on an intentional close().
Expand Down Expand Up @@ -476,6 +486,17 @@ export class MediatorSession {
this.ws.send(jweString);
}

/**
* Send a raw TSP message as a WS binary frame. The mediator sniffs the
* leading 0xF8 magic byte and routes it to its TSP inbound handler (the same
* socket carries DIDComm text frames and TSP binary frames).
* @param {Uint8Array} bytes
*/
sendBinary(bytes) {
if (!this.ws) throw new Error("mediator-transport: not connected");
this.ws.send(bytes);
}

async _onFrame(data) {
// Every inbound frame is processed independently and defensively: a
// single bad message (undecryptable, malformed, unknown sender, or a
Expand All @@ -490,6 +511,30 @@ export class MediatorSession {
return;
}

// TSP demux: the mediator multiplexes TSP messages onto this same socket.
// A stored TSP message is delivered as base64url(qb2) text, which starts
// with "-E" (the CESR `-E` count code, whose first decoded byte is the
// 0xF8 TSP magic). DIDComm frames are JSON (`{`) or compact JWS (`ey…`), so
// a leading "-E" is an unambiguous TSP marker. Route the raw qb2 bytes to
// the TSP consumer instead of the DIDComm unpacker (which throws on them).
if (text.startsWith("-E")) {
let qb2;
try {
qb2 = b64u.decode(text);
} catch (err) {
this._reportFrameError("decode inbound TSP frame", err, text);
return;
}
if (this.onTspFrame) {
try {
this.onTspFrame(qb2);
} catch (err) {
this._reportFrameError("dispatch inbound TSP frame", err, text);
}
}
return;
}

let result;
try {
result = await unpackInbound(text, {
Expand Down
48 changes: 48 additions & 0 deletions test/mediator-transport.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { generateEphemeralClient } from "../src/vta-rest-auth.js";
import * as x25519 from "../src/x25519.js";
import * as multibase from "../src/multibase.js";
import * as jwk from "../src/jwk.js";
import * as base64url from "../src/base64url.js";

function keypairDid() {
const kp = x25519.generateKeyPair();
Expand Down Expand Up @@ -512,3 +513,50 @@ test("inbound: a poison frame is logged via onError and the next good frame stil
assert.ok(errors.length >= 2, `both poison frames were logged (got ${errors.length})`);
assert.match(errors[0].message, /failed to (unpack|dispatch) inbound/);
});

// ── TSP demux (single-socket multiplexing) ─────────────────────────────────

function tspSession(onTspFrame, onError) {
return new MediatorSession({
mediator: { wsEndpoint: "wss://m/ws", did: "did:key:zM", kid: "did:key:zM#zM", x25519Pub: new Uint8Array(32) },
mediatorJwt: "jwt",
client: { did: "did:key:zC", kid: "did:key:zC#zC", privateKey: new Uint8Array(32), publicKey: new Uint8Array(32) },
WebSocketImpl: FakeWebSocket,
onTspFrame,
onError,
});
}

test("_onFrame routes a '-E' TSP frame (base64url qb2) to onTspFrame as raw bytes", async () => {
const frames = [];
const session = tspSession((bytes) => frames.push(bytes));
// qb2 whose first byte is the 0xF8 TSP magic; base64url of it starts with "-E".
const qb2 = new Uint8Array([0xf8, 0x41, 0x42, 0x43, 0x44, 0x45]);
const text = base64url.encode(qb2);
assert.ok(text.startsWith("-E"), `expected "-E" prefix, got "${text.slice(0, 4)}"`);
await session._onFrame(text);
assert.equal(frames.length, 1);
assert.deepEqual(frames[0], qb2);
});

test("_onFrame does NOT route a DIDComm (JSON) frame to onTspFrame", async () => {
const frames = [];
const errors = [];
const session = tspSession((bytes) => frames.push(bytes), (e) => errors.push(e));
// A JSON DIDComm frame — must go to the DIDComm unpack path (which fails here
// with no skid), never to the TSP consumer.
await session._onFrame(JSON.stringify({ protected: "x", ciphertext: "y" }));
assert.equal(frames.length, 0, "TSP consumer must not see a DIDComm frame");
});

test("a TSP frame with no onTspFrame handler is dropped (not run through DIDComm unpack)", async () => {
const errors = [];
const session = tspSession(undefined, (e) => errors.push(e));
// A realistic TSP frame: 0xF8 0x4X → base64url "-E…" (the CESR `-E` count code).
const qb2 = new Uint8Array([0xf8, 0x41, 1, 2, 3]);
const text = base64url.encode(qb2);
assert.ok(text.startsWith("-E"));
await session._onFrame(text);
// No handler → silently dropped; must NOT be reported as a DIDComm unpack error.
assert.equal(errors.length, 0);
});
Loading