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.0 unpack requires an authcrypt message’s from to be the DID of its skid (SenderMismatchError, E_SENDER_MISMATCH) and returns senderDid beside senderKid — null for anoncrypt; the sender key is selected by the exact skid, not by the DID alone
  • DID resolution: did:key (Ed25519 / X25519 / P-256 / secp256k1), did:peer numalgo 2, did:webvh (via didwebvh-ts 2.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 / onMediatorMessage receive a frozen VerifiedSender { 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 its pthid so a refusal reaches its waiter, and onMediatorMessage keeps monitor batches off a consumer’s persist-before-ack path
  • ./net-guard (0.8.0): assertSafeEndpoint, guardedFetch, BlockedEndpointError (code: "E_BLOCKED_ENDPOINT", typed reason) and a netPolicy { allowInsecure, allowPrivate, allowHosts } threaded through mediator resolution, auth, MediatorSession, VTA REST auth and did:webvh resolution; ./net-guard/node adds a dns.lookup replacement for server-side consumers
  • Not implemented: multi-recipient JWE, XChaCha20, JWS-only, BBS+, other peer numalgos

Versions at the coordinated releases

CypressVTI-DogwoodVTI-Dogwood-R1VTI-Eucalyptus (2026-10-07)
0.6.20.7.00.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 by pthid, which waitFor never matched, so permissionDenied surfaced 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, threadOf keys a problem report by pthid (the mediator threads every refusal since affinidi-messaging-mediator 0.28.26), and onMediatorMessage takes the unclaimed mediator-originated frames. Published the same day; the plugin took it in #273.
    • 0.12.0 (09-25, #27 — breaking). Why: unpack already pinned the sender key (apu == skid), but nothing tied that key to the from the plaintext carries — and consumers read from. An authcrypt message whose from is missing, is a DID URL, or names any DID but the skid’s is now refused; unpack returns senderDid; MediatorSession passes a VerifiedSender to every consumer and waitFor takes a { from } filter so a reply is accepted only from the peer it was asked of; VtaMediatorClient.sendAndWait applies the same rule; and the sender key is chosen by exact kid === skid (an entry with no kid matches nothing). A frame that decrypts and is then refused for good — a from mismatch, or an anoncrypt frame carrying a skid (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-didcomm 0.15.9, unpacking through unpack_bound with the sender key bound to a key id; its response no longer carries legacy_kek_used. No further commits through the 10-07 tag.
  • 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 -E frame to onTspFrame and 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.1 or 169.254.169.254 was 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, and allowInsecure / allowPrivate become independent flags.
    • 0.9.0 / 0.9.1 (09-12, 15; the sec-4045 review). did:webvh resolution checks the host before fetching the log — unpackInbound resolves an inbound frame’s skid before the frame is authenticated — and takes over the fetch from didwebvh-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 real fetch and 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.5.0 → 0.6.2 (06-01 → 07-16) — spec-correct ECDH-1PU cc_tag length prefix (breaking authcrypt wire change, pairs with affinidi-messaging-didcomm ≥ 0.14); TSP frame multiplexing (0.6.0); REST auth realigned to canonical trusttasks.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 with sha256(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 allowHosts with a pinned list the wallet does not yet hold. With VerifiedSender in every consumer’s hands, the next question is the wallet’s: which of its remaining message.from reads are still load-bearing.

See also: vta-browser-plugin, didcomm, trust-spanning-protocol, trust-tasks, affinidi-tdk, verifiable-trust-infrastructure, coordinated-releases