vti-didcomm-js — @openvtc/vti-didcomm-js
Repo: github.com/OpenVTC/vti-didcomm-js
A focused, dependency-light, ESM-only implementation of exactly the subset of DIDComm v2 that the VTI’s browser flows need — so the VTA Wallet can talk to a VTA through a mediator without a Rust/WASM toolchain. It is byte-compatible on the wire with affinidi-messaging-didcomm (the crate the VTA and the TDK mediator use; the CI round-trip helper pins 0.15.9 and unpacks through unpack_bound, matching both). Runs in browsers and Node 20+; crypto from WebCrypto + @noble; 150+ tests including RFC 7518 vectors. Since September 2026 it is also where two of the wallet’s security decisions live. The network egress policy: because a browser extension has no DNS API and cannot inspect a redirect, “which hosts may this client ever dial?” has to be decided by literal before the fetch, and this library is the one place every mediator, VTA and did:webvh URL passes through. And, since 0.12.0, who sent this?: an authcrypt message’s plaintext from is sender-asserted, so the library now hands every consumer the DID it proved — the skid’s — and the wallet authorizes on that, never on from.
It was built inside the VTI workspace (design note docs/05-design-notes/didcomm-js-implementation.md) and extracted into its own repository on 2026-05-21; its primary consumer is @openvtc/pnm-core, which swapped onto it the same day, replacing a WASM scaffold.
The Subset
- Authcrypt (ECDH-1PU + A256KW + A256CBC-HS512, sender-bound) and anoncrypt (ECDH-ES) over X25519 or P-256;
routing/2.0/forward. Since 0.12.0unpackrequires an authcrypt message’sfromto be the DID of itsskid(SenderMismatchError,E_SENDER_MISMATCH) and returnssenderDidbesidesenderKid—nullfor anoncrypt; the sender key is selected by the exactskid, not by the DID alone - DID resolution: did:key (Ed25519 / X25519 / P-256 / secp256k1), did:peer numalgo 2, did:webvh (via
didwebvh-ts2.8, full hash-chain + Data Integrity verification — but since 0.9.0 the library fetches the log itself through the guard, so no host is contacted unvetted), pluggable dispatcher with a bounded LRU cache (500 entries; did:key / did:peer never cached) - VTA REST
/auth/challenge-response + refresh; mediator transport (ATM challenge auth, WebSocket with subprotocol bearer, message-pickup 3.0 live delivery,sendAndWait);MediatorSession.waitFor(thid, ms, { from })resolves{ message, sender }and only for an authenticated sender the caller named;onMessage/onMediatorMessagereceive a frozenVerifiedSender { did, kid }; TSP frame multiplexing on the same socket — key-blind, acked after the consumer finishes, recognizing both short (-E) and Rev 3 long (--E) framing (./tsp-frame) - The mediator as a Trust-Task counterparty (0.11.0): a threaded Trust Tasks envelope from the mediator is acked like any other frame (
isStoredMediatorReply), a problem report is keyed by itspthidso a refusal reaches its waiter, andonMediatorMessagekeeps monitor batches off a consumer’s persist-before-ack path ./net-guard(0.8.0):assertSafeEndpoint,guardedFetch,BlockedEndpointError(code: "E_BLOCKED_ENDPOINT", typedreason) and anetPolicy { allowInsecure, allowPrivate, allowHosts }threaded through mediator resolution, auth,MediatorSession, VTA REST auth and did:webvh resolution;./net-guard/nodeadds adns.lookupreplacement for server-side consumers- Not implemented: multi-recipient JWE, XChaCha20, JWS-only, BBS+, other peer numalgos
Versions at the coordinated releases
| Cypress | VTI-Dogwood | VTI-Dogwood-R1 | VTI-Eucalyptus (2026-10-07) |
|---|---|---|---|
| 0.6.2 | 0.7.0 | 0.7.0 (same commit) | 0.12.0 (tag at afd11e1, 09-25 — the repo’s HEAD) |
The browser plugin pins four floors as correctness constraints: ^0.6.2 (hand-off-then-ack), ^0.10.0 (long-framed TSP replies), ^0.11.0 (a mediator’s own Trust-Task replies acked, refusals delivered) and ^0.12.0 (the authenticated sender it authorizes task-consent outcomes on). 0.12.0 is on npm; the plugin’s lockfile resolves it.
Recent Development
- 0.10.1 → 0.12.0 (2026-09-22 → 09-25; PRs #26–#28) — the Eucalyptus cut. Seven commits in three days, two of them the kind that change what a consumer may believe.
- 0.11.0 (09-23, #26). Why: the wallet’s new Mediator Lens runs a mediator’s
messaging/*operations surface as Trust Tasks addressed to the mediator itself, over the session the wallet already holds — and 0.10 handled none of the three frame kinds that come back. The mediator stores its reply in the caller’s queue and pushes it live, and this transport never acked a frame whose sender is the mediator (to avoid status ping-pong), so every reply stayed in the caller’s receive queue — the one carrying its mail — and replayed on every reconnect. A refusal is a problem report threaded bypthid, whichwaitFornever matched, sopermissionDeniedsurfaced as a timeout. And monitor batches are live-only and unthreaded, which under R1.6 meant writing traffic telemetry to storage once a second. Threaded mediator envelopes are now acked,threadOfkeys a problem report bypthid(the mediator threads every refusal since affinidi-messaging-mediator 0.28.26), andonMediatorMessagetakes the unclaimed mediator-originated frames. Published the same day; the plugin took it in #273. - 0.12.0 (09-25, #27 — breaking). Why:
unpackalready pinned the sender key (apu == skid), but nothing tied that key to thefromthe plaintext carries — and consumers readfrom. An authcrypt message whosefromis missing, is a DID URL, or names any DID but theskid’s is now refused;unpackreturnssenderDid;MediatorSessionpasses aVerifiedSenderto every consumer andwaitFortakes a{ from }filter so a reply is accepted only from the peer it was asked of;VtaMediatorClient.sendAndWaitapplies the same rule; and the sender key is chosen by exactkid === skid(an entry with nokidmatches nothing). A frame that decrypts and is then refused for good — afrommismatch, or an anoncrypt frame carrying askid(E_UNAUTHENTICATED_FRAME) — is acked and dropped so it does not occupy the queue; frames that fail for possibly transient reasons stay queued. The plugin took it in #278 and re-keyed its task-consent handling on the proven sender. - #28 (09-25, unreleased). The Rust round-trip helper on
affinidi-messaging-didcomm0.15.9, unpacking throughunpack_boundwith the sender key bound to a key id; its response no longer carrieslegacy_kek_used. No further commits through the 10-07 tag.
- 0.11.0 (09-23, #26). Why: the wallet’s new Mediator Lens runs a mediator’s
- 0.7.0 → 0.10.1 (2026-08-28 → 09-22; PRs #10–#25) — from a stable floor to an actively hardened one.
- 0.7.0 (08-29, #11 — in
VTI-Dogwood). The 0.6.0 TSP demux routed a-Eframe toonTspFrameand returned before the ack, so every TSP message a client ever received stayed queued and was redelivered on every reconnect; inbound TSP frames are now handed off, awaited, then acked (R1.6 for TSP). Dogwood and Dogwood-R1 are the same commit here. - 0.8.0 (09-11, #13). The egress guard: a mediator’s URLs come out of its DID document and a VTA’s base URL is written from one at onboarding, so a document naming
https://127.0.0.1or169.254.169.254was a request from inside the user’s network with the wallet’s credentials attached. Every such endpoint is vetted, no auth request follows a redirect, response bodies no longer leak into errors, andallowInsecure/allowPrivatebecome independent flags. - 0.9.0 / 0.9.1 (09-12, 15; the
sec-4045review).did:webvhresolution checks the host before fetching the log —unpackInboundresolves an inbound frame’sskidbefore the frame is authenticated — and takes over the fetch fromdidwebvh-ts; 0.9.1 bounds the resolver cache. CI actions SHA-pinned (#16), Dependabot, TypeScript 7.0.2, @noble/curves 2.4, didwebvh-ts 2.8 (#18–#22), a regression gate driving the SEC-4045 bypass vectors through realfetchand socket paths (#23). - 0.10.0 (09-15, #24). Rev 3 long frames (
--E) were classified as DIDComm and thrown before the ack — a poison frame redelivered forever. 0.10.1 (#25; committed 09-18, CHANGELOG-dated 09-22). The net-guard refuses bare single-label hosts and decimal/hex IP spellings (SEC #15).
- 0.7.0 (08-29, #11 — in
- 0.5.0 → 0.6.2 (06-01 → 07-16) — spec-correct ECDH-1PU
cc_taglength prefix (breaking authcrypt wire change, pairs withaffinidi-messaging-didcomm≥ 0.14); TSP frame multiplexing (0.6.0); REST auth realigned to canonicaltrusttasks.org/spec/auth/*types (0.6.1); hand inbound off before acking (0.6.2, R1.6) — the plugin’s first correctness floor. - 0.1.0 → 0.4.2 (2026-05-21 → 05-31) — initial release; did:peer + P-256;
onMessage; ack withsha256(JWE)and skip mediator frames; WebSocket close-code diagnostics. - Direction: the guard’s remaining browser gap — a public name that resolves to a private address — can only be closed by
allowHostswith a pinned list the wallet does not yet hold. WithVerifiedSenderin every consumer’s hands, the next question is the wallet’s: which of its remainingmessage.fromreads are still load-bearing.
See also: vta-browser-plugin, didcomm, trust-spanning-protocol, trust-tasks, affinidi-tdk, verifiable-trust-infrastructure, coordinated-releases