From 6bfde4892bafddfa092fe7544fce7a21bcd280e2 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Thu, 17 Sep 2026 13:46:20 +0200 Subject: [PATCH] fix(core): raise vti-didcomm-js floor to ^0.10.0 for long-framed TSP replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through 0.8.x the inbound demux in mediator-transport.js classified TSP by `text.startsWith("-E")` — the qb64 spelling of the short `-E` count code. Spec Rev 3, which this wallet now speaks, widened that count to cover the ciphertext, so any TSP message past ~12 KB is framed with the six-byte long count code and its text starts `--E`. Such a reply was handed to the DIDComm unpacker, thrown out as a poison frame, and — because the throw returns before the ack — never acked, so the mediator redelivered it every reconnect while the waiter timed out. In practice: `keys/list` with no context filter returns a full page of key records, crosses the threshold, and was silently lost. The VTA logged the reply sent (count=50 total=94, 200 OK); the wallet saw no frame and reported `tsp: timed out awaiting reply frame`. Per-context queries (small, short-framed) were unaffected. vti-didcomm-js 0.10.0 moves the test into `isTspFrameText`, matching both `-E` and `--E`. The same release also runs the resolver's own net-guard on did:webvh resolution by default (0.9+), so a bare `vtiResolve(did, {})` is now refused before it dials. That broke the non-vacuity control in tests/did.egress-socket.mjs, which relied on the resolver dialling a loopback listener; the control now relaxes the dependency's policy (allowInsecure + allowPrivate) to keep proving the fixture is reachable. The production path — assertResolvableWebvhHost first, then `vtiResolve(did, {})` — is unchanged, and the two real guard tests still refuse first with this repo's message. Records the ^0.10.0 floor as a correctness constraint in CLAUDE.md, beside the existing ^0.6.2 persist-before-ack floor. Signed-off-by: Glenn Gore --- CLAUDE.md | 20 ++++++++++++++ package-lock.json | 16 +++++------ packages/core/package.json | 2 +- packages/core/tests/did.egress-socket.mjs | 33 ++++++++++++++++------- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a7b4f34..1efc956 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,26 @@ type on a path that skips it), or relaxing the `vti-didcomm-js` floor below outstanding?" A message can be both, which is why the drain path bypasses the dedup check. +**A second `vti-didcomm-js` floor, for an unrelated reason: `^0.10.0` is a +correctness constraint too.** Since the Rev 3 cutover the wallet reads +long-framed TSP replies — spec Rev 3 widened the `-E` count to cover the +ciphertext, so any TSP message past ~12 KB is framed with the six-byte long +count code and its qb64 text starts `--E`, not `-E`. Through 0.8.x the inbound +demux (`_onFrame` in `mediator-transport.js`) classified TSP by +`text.startsWith("-E")`, so a large reply was handed to the DIDComm unpacker, +thrown out as a poison frame, and — because the throw returns before the ack — +**never acked**, so the mediator redelivered it on every reconnect forever while +the waiter timed out. This is not hypothetical: a `keys/list` with no context +filter returns a full page of records, crosses the threshold, and was silently +lost (the VTA logged the reply sent; the wallet saw nothing). 0.10.0 moved the +test into `isTspFrameText`, which matches both `-E` and `--E`. Below it, every +TSP reply over ~12 KB vanishes. `tests/did.egress-socket.mjs`'s control was +rewritten for the same release's *other* change — 0.9+ runs the resolver's own +`net-guard` on `did:webvh` resolution by default, so a bare `vtiResolve(did, {})` +is refused before it dials; the control now relaxes the dependency's policy to +keep proving the fixture is reachable, and the production path (guard first, then +`vtiResolve(did, {})`) is unchanged. + ## Every outbound Trust-Task document is signed (SPEC §7.2 item 7a) The VTA enforces the four checks a Trust Task specification declares for diff --git a/package-lock.json b/package-lock.json index d2429d8..360db4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2310,13 +2310,13 @@ "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.8.0.tgz", - "integrity": "sha512-4L9aBHPYqx6r1wRhomgclK+KcOJj4cTEGrBM0bm/MKlQMc8z9W4w5TjUEXL0NDLUSnEMfvGJaCwO2hS/S0IjTg==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.10.0.tgz", + "integrity": "sha512-k4TtuhBLZRzqqRKxhaQWuWIqwnVsnMD77EQHLsB7haudz8xciqkX1I+i3evpDq53Ay5Da39zGd6o0DswEMKRyA==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^2.2.0", - "didwebvh-ts": "^2.7.4" + "@noble/curves": "^2.4.0", + "didwebvh-ts": "^2.8.0" }, "engines": { "node": ">=20.0.0" @@ -8035,8 +8035,8 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.19.5", - "@openvtc/vti-didcomm-js": "^0.8.0", - "@openvtc/vti-tsp-js": "^0.2.0", + "@openvtc/vti-didcomm-js": "^0.10.0", + "@openvtc/vti-tsp-js": "^0.3.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" }, @@ -8107,7 +8107,7 @@ }, "packages/tsp-js": { "name": "@openvtc/vti-tsp-js", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@noble/ciphers": "^2.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index dd90c30..e2f5c37 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -131,7 +131,7 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.19.5", - "@openvtc/vti-didcomm-js": "^0.8.0", + "@openvtc/vti-didcomm-js": "^0.10.0", "@openvtc/vti-tsp-js": "^0.3.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" diff --git a/packages/core/tests/did.egress-socket.mjs b/packages/core/tests/did.egress-socket.mjs index 5c19182..6882e88 100644 --- a/packages/core/tests/did.egress-socket.mjs +++ b/packages/core/tests/did.egress-socket.mjs @@ -18,8 +18,12 @@ // // ── Why these spellings and not the headline ones ─────────────────────────── // -// The vectors below are the ones where **this repo's guard is the only thing -// that can refuse them**, which is the whole point of the file. +// The vectors below are the ones where **this repo's guard is provably the +// thing exercised at the socket**, which is the whole point of the file. (Since +// `@openvtc/vti-didcomm-js` 0.9 the resolver's own net-guard also refuses them +// by default — defense in depth — so this repo's guard is no longer the *only* +// refusal; the control below relaxes the dependency's policy to keep proving +// the fixture is reachable at all.) // // `didwebvh-ts@2.8.0` — the resolver under `@openvtc/vti-didcomm-js` — has its // own `isIPAddress()` check that throws "IP addresses are not allowed as hosts" @@ -160,13 +164,22 @@ test("deriveSigningKeyId opens no socket either — the second entry point", asy test("the resolver does dial the listener when the guard is not in front of it", async (t) => { // The control the two tests above are worth nothing without. // - // It calls the resolver the way `verifyDid` would — same DID, same default - // resolver, same real `fetch` — but without going through `verifyDid`, so the - // ONE difference between this and the tests above is - // `assertResolvableWebvhHost`. A connection arriving here says the listener - // counts, the port is reachable, and the DID does derive to it; an empty - // count above therefore means refused, and not "this fixture never pointed - // anywhere". + // It calls the resolver with the same DID and the same real `fetch`, but + // without going through `verifyDid`. What it isolates is `verifyDid`'s guard, + // `assertResolvableWebvhHost` — a connection arriving here says the listener + // counts, the port is reachable, and the DID does derive to it, so an empty + // count above means refused and not "this fixture never pointed anywhere". + // + // Since `@openvtc/vti-didcomm-js` 0.9 the resolver applies its OWN net-guard + // to did:webvh resolution by default, so a bare `vtiResolve(did, {})` is now + // refused before it dials — defense in depth with this repo's guard, and the + // reason a no-policy call here would prove nothing but the dependency's + // refusal. To reach the socket the control relaxes *the dependency's* policy + // with `allowInsecure` + `allowPrivate` (both, since 0.8 made them + // independent — `allowPrivate` alone keeps the `https:` requirement and a + // loopback listener speaks neither TLS nor a public scheme). The real tests + // need no such relaxation: their production `vtiResolve(did, {})` is exactly + // what ships, and `assertResolvableWebvhHost` refuses first regardless. t.mock.method(console, "error", () => {}); connections = []; @@ -175,7 +188,7 @@ test("the resolver does dial the listener when the guard is not in front of it", // resolver answers something else. const did = dialsListener("0x7f000001"); await assert.rejects( - () => vtiResolve(did, {}), + () => vtiResolve(did, { netPolicy: { allowInsecure: true, allowPrivate: true } }), // Whatever the reset surfaces as. The claim is about the socket, not this. () => true, );