diff --git a/package.json b/package.json index d1cc90d..b882ad0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/mediator-transport.js b/src/mediator-transport.js index 8a45d77..0725538 100644 --- a/src/mediator-transport.js +++ b/src/mediator-transport.js @@ -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)"); } @@ -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(). @@ -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 @@ -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, { diff --git a/test/mediator-transport.test.js b/test/mediator-transport.test.js index 01c17e0..ec7a255 100644 --- a/test/mediator-transport.test.js +++ b/test/mediator-transport.test.js @@ -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(); @@ -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); +});