Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

vti-didcomm-js

Browser-side DIDComm v2 for the Verifiable Trust Infrastructure — a focused, dependency-light JavaScript implementation of the subset our auth flows need. ESM-only, runs in browsers and Node 20+.

Byte-compatible on the wire with affinidi-messaging-didcomm 0.13 (the same crate the VTA and the ATM mediator use) — every layer is verified by round-tripping through the Rust crate's unpack in CI.

Part of the wider Verifiable Trust Community project at github.com/OpenVTC, which builds open infrastructure for verifiable trust.

What it does

  • Authcrypt pack/unpack — ECDH-1PU + A256KW + A256CBC-HS512 (DIDComm v2's required-to-implement content encryption). Sender-bound. Key agreement over X25519 or P-256 (curve taken from the recipient's key).
  • Anoncrypt pack/unpack — ECDH-ES + A256KW + A256CBC-HS512. No sender identity; used for the routing/2.0/forward envelope to a mediator. X25519 or P-256.
  • DID resolution — did:key (Ed25519/X25519/P-256/secp256k1, in-tree), did:peer (numalgo 2, in-tree) and did:webvh (via didwebvh-ts, full hash-chain + Data-Integrity verification), did:scid:vh:1 through the same did:webvh log, and did:webs (in-tree: KERI key event log verification and the document derived from it). Pluggable dispatcher for adding methods.
  • Forward routing — https://didcomm.org/routing/2.0/forward wrapping.
  • VTA REST auth — DIDComm-packed /auth/ challenge-response + JWT refresh (RFC 6749 §10.4 rotation).
  • Mediator transport — ATM challenge-response auth, browser WebSocket with subprotocol-bearer auth, message-pickup 3.0 live delivery, and sendAndWait request/response correlation.

Crypto comes from Web Crypto where possible (AES-CBC, HMAC, AES-KW, SHA-256), @noble/curves for X25519/Ed25519/P-256/secp256k1 and @noble/hashes for the digests KERI names (Blake3, Blake2, SHA-2, SHA-3). No hand-rolled symmetric crypto or curve math — only protocol-level orchestration.

Not implemented (additive if a flow needs them)

Multi-recipient JWEs, secp256k1/P-384/P-521 ECDH (key agreement is X25519 and P-256; secp256k1 is resolved as a signing key only), XChaCha20-Poly1305, did:peer numalgo 0/1/4 (only numalgo 2), signed-only (JWS) mode, BBS+, attachments beyond the forward envelope.

Install

npm install @openvtc/vti-didcomm-js

Usage

import { pack, unpack, packAnoncrypt, resolve } from "@openvtc/vti-didcomm-js";
import * as jwk from "@openvtc/vti-didcomm-js/jwk";

// Resolve a recipient and pack an authcrypt message to its keyAgreement.
const { didDocument } = await resolve("did:webvh:…:vta");
// … extract the keyAgreement X25519 key (see resolveX25519KeyAgreement) …

const jwe = await pack({
  message: { id, type, from: senderDid, to: [recipientDid], body },
  sender:    { kid: senderKid,    privateJwk },
  recipient: { kid: recipientKid, publicJwk  },
});

const { message, senderDid, senderKid, authenticated } = await unpack(jwe, {
  kid: recipientKid,
  privateJwk: recipientPrivateJwk,
}, { publicJwk: senderPublicJwk });

Who sent it: senderDid, not from

Authorise on senderDid — the DID of the key (skid) the authcrypt envelope was authenticated with, given that senderPublicJwk was resolved from that DID. message.from is a value the sender writes into the plaintext and is not an identity:

  • authcrypt — unpack refuses (SenderMismatchError, code: "E_SENDER_MISMATCH") any message whose from is missing or is not exactly senderDid, so a sender can only speak as the DID whose key it holds.
  • anoncrypt — nothing is authenticated; senderDid and senderKid are null whatever from claims.

MediatorSession hands the same proven identity to its consumers as a VerifiedSender ({ did, kid }): onMessage(message, thid, sender), onMediatorMessage(message, thid, sender), and waitFor(thid, timeoutMs, { from }), which resolves { message, sender } and — given from — only accepts a reply authenticated as that DID (or one of those DIDs). A thread id is not a secret, so pass from whenever you know who should answer.

Higher-level helpers:

  • @openvtc/vti-didcomm-js/vta-rest-auth — authenticate / refresh against a VTA's REST /auth/ surface.
  • @openvtc/vti-didcomm-js/vta-didcomm — connectVtaViaMediator → client.sendAndWait(type, body) over a mediator WebSocket.

Each module is also a subpath export (e.g. @openvtc/vti-didcomm-js/pack, @openvtc/vti-didcomm-js/resolver, @openvtc/vti-didcomm-js/mediator-transport).

Network policy for endpoints you did not choose

Mediator endpoints come from the mediator's DID document, and a VTA baseUrl usually comes from a QR code or config. The SDK checks both before dialing (@openvtc/vti-didcomm-js/net-guard). By default an endpoint must:

  • use https: or wss:;
  • carry no userinfo;
  • name a public host. Loopback, private, link-local, CGNAT and other non-public IP literals are refused, including their IPv4-mapped and NAT64 IPv6 forms, as are localhost, *.localhost, *.local, *.internal and *.home.arpa.

A refused endpoint throws BlockedEndpointError (code: "E_BLOCKED_ENDPOINT", with a reason). Auth requests never follow redirects.

Pass netPolicy to narrow or relax the policy:

// Production: accept only the hosts you expect.
await connectVtaViaMediator({
  ...args,
  netPolicy: { allowHosts: ["mediator.example.com", "*.vta.example.com"] },
});

// Local development against http://localhost.
await connectVtaViaMediator({
  ...args,
  netPolicy: { allowInsecure: true, allowPrivate: true },
});

allowInsecure only permits http: / ws:; it does not admit private hosts. allowHosts narrows the policy and never re-admits a blocked address. The same netPolicy option exists on authenticateToMediator, resolveMediator, MediatorSession, VTA REST authenticate / refresh, and DID resolution.

did:webvh resolution is on the same policy, because a webvh identifier names the host its log is fetched from — and identifiers arrive from elsewhere: an inbound frame's skid is resolved before the frame is authenticated, so a mediator-routed sender picks a host this client would GET. The log URL is checked before did.jsonl (and did-witness.json) is fetched, and so is the host of any nested did:webvh verification method inside the log:

// Refused before any request: BlockedEndpointError, reason private_name.
await resolve(`did:webvh:${scid}:localhost%3A8000`);

// A local webvh server, for development.
await resolve(did, { netPolicy: { allowInsecure: true, allowPrivate: true } });

A did:webvh host that merely contains localhost (say localhost.example.com) is refused too, unless allowPrivate is set: didwebvh-ts treats any such identifier as local and fetches it over plaintext http.

As of a time

did:webvh and did:scid keep their history, so either resolves as it stood at an instant with versionTime (a Date, or an RFC 3339 date-time with an offset): the state of the last log entry whose versionTime is at or before it. The whole log is verified first, exactly as for a current resolution — hash chain, every entry's proof, pre-rotation and witness thresholds — so a log that would not resolve now does not resolve as of any time. An instant before the DID's first entry is refused with DidVersionNotFoundError (code: "E_DID_VERSION_NOT_FOUND").

const { didDocument } = await resolve(did, { versionTime: "2026-10-01T12:00:00Z" });

A resolution as of a time bypasses the resolver's cache both ways, and does not move a did:scid source book. did:key and did:peer ignore it: their document never changes.

did:scid

A did:scid:vh:1 identity is the DID did:scid:vh:1:<scid>; its src query parameter only says where its did:webvh log is published, so the log can move and the identity stays the same. Resolving a DID URL with src verifies that log (the host guard applies to the src host) and returns the did:scid's own document: id is the bare DID, its keys are under it, and alsoKnownAs names the did:webvh DID and the src URL.

const { didDocument } = await resolve(`did:scid:vh:1:${scid}?src=dids.example.org/alice`);
didDocument.id; // "did:scid:vh:1:<scid>"

// The bare DID resolves through the source this resolver last verified for
// its SCID — or one you kept and seed back after a restart:
addScidSource(`did:scid:vh:1:${scid}?src=dids.example.org/alice`);
await resolve(`did:scid:vh:1:${scid}`);

A resolver refuses a log older than one it has already verified for the same SCID: any host can serve a copy of a log it once fetched, and the SCID makes a copy unforgeable, not current. Compare did:scid references with sameReference (or stripScidSrc), never ===: a reference may carry the src its document does not. pack adds a did:scid from's src to the skid, so a recipient that has never seen the sender can resolve it, and unpack returns the sender's identity — the bare DID — as senderDid.

did:webs

A did:webs DID (did:webs:<host>[%3A<port>][:<path>…]:<AID>) is did:web discovery with KERI-verified key state. Resolution fetches <host>/<path>/<AID>/keri.cesr and then did.json — both required, both through the egress guard, no redirects, each capped at 4 MiB while it is read — verifies the whole key event stream (SAIDs, field lists, signatures against the signing threshold, simple or weighted; pre-rotation; witness receipts; delegation; superseding recovery), requires the AID to have designated this DID in an attestation anchored in its own log, derives the document from that state, and refuses a did.json that disagrees with it.

const { didDocument, didDocumentMetadata } = await resolve(`did:webs:example.com:${aid}`);

// As of a key event: `versionId` is a sequence number.
await resolve(`did:webs:example.com:${aid}?versionId=2`);

// A local host: allowPrivate admits it, and a loopback host is then fetched
// over http. allowInsecure fetches any host over http.
await resolve(`did:webs:localhost%3A7676:${aid}`, { netPolicy: { allowPrivate: true } });

The document is derived the way the Rust reference, affinidi-did-webs, derives it, including its designations: key agreement keys, services of any type, aliases and key roles (VTI-KEY-173), and a critical designation this resolver does not implement fails resolution. @openvtc/vti-didcomm-js/did-webs also exports resolveFromArtifacts(did, keriCesr, didJson) for artifacts already fetched, which reports a deactivated identifier in its metadata; resolve refuses one (kind: "Deactivated"), since its keys would otherwise read as current. Failures throw DidWebsError (code: "E_DID_WEBS", with the Rust error variant as kind). versionTime is refused: did:webs history is by key event. Not read: CBOR and MessagePack message bodies, and non-integer numbers in messages — refused, not skipped.

A did:webs party's keys are JsonWebKey verification methods (publicKeyJwk, ids <did>#<CESR key>), and with key roles each relationship names its own key. Mediator auth, VTA REST auth and the VTA DIDComm client read keys through ./verification-method, which takes publicKeyJwk and publicKeyMultibase alike and chooses a key by its relationship — so they authenticate and encrypt to a did:webs VTA, VTC or mediator exactly as to a did:webvh one, under the key ids its document writes:

import { selectKey, x25519KeyAgreement } from "@openvtc/vti-didcomm-js/verification-method";

const { kid, x25519Pub } = x25519KeyAgreement(didDocument, did);  // the X25519 keyAgreement key
const signer = selectKey(didDocument, did, "assertionMethod");    // { kid, crv, publicKey, publicJwk }

Browsers expose no DNS API, so a public name that resolves to a private address passes the URL check there; allowHosts is the strong control. In Node, guardedLookup also filters resolved addresses at connect time:

import { Agent } from "undici";
import { guardedLookup } from "@openvtc/vti-didcomm-js/net-guard/node";

const dispatcher = new Agent({ connect: { lookup: guardedLookup() } });
const fetch = (url, init) => globalThis.fetch(url, { ...init, dispatcher });
await authenticateToMediator({ ...args, fetch });

Module map

src/
  base64url.js          RFC 4648 §5 (no padding)
  multibase.js          base58btc + multicodec varints
  jwk.js                JWK ↔ raw bytes (OKP X25519/Ed25519, EC P-256)
  verification-method.js  DID document keys by relationship (publicKeyJwk,
                        publicKeyMultibase)
  concat-kdf.js         NIST SP 800-56A Concat KDF (JOSE OtherInfo)
  x25519.js             X25519 key agreement (@noble/curves)
  p256.js               P-256 key agreement (@noble/curves)
  key-agreement.js      curve dispatcher (X25519 + P-256)
  ecdh-1pu.js           ECDH-1PU KEK (authcrypt; tag-bound key-wrap)
  ecdh-es.js            ECDH-ES KEK (anoncrypt)
  aes.js                AES-256-KW (RFC 3394)
  a256cbc-hs512.js      AES-256-CBC + HMAC-SHA-512 AEAD (RFC 7518 §5.2)
  pack.js               authcrypt JWE
  anoncrypt.js          anoncrypt JWE
  unpack.js             dual-mode unpack (authcrypt + anoncrypt)
  did-key.js            did:key resolver
  did-webvh.js          did:webvh resolver (via didwebvh-ts)
  did-scid.js           did:scid:vh:1 resolver, src helpers, source book
  did-webs.js           did:webs resolver: identifier, fetch, document
  did-webs-cesr.js      CESR primitives, counters, KERI/ACDC bodies (JSON, native)
  did-webs-kel.js       key event log verification (thresholds, rotation, witnesses,
                        delegation, recovery)
  did-webs-designations.js  registries and designations (aliases, key agreement,
                        services, key roles)
  did-peer.js           did:peer resolver (numalgo 2, in-tree)
  resolver.js           method dispatcher
  forward.js            routing/2.0/forward wrapping
  vta-rest-auth.js      VTA /auth/ + refresh
  forward.js, mediator-auth.js, mediator-transport.js, vta-didcomm.js
                        mediator transport (auth, WS live delivery, sendAndWait)
  net-guard.js          egress policy for untrusted endpoints (browser + Node)
  net-guard-node.js     guardedLookup: DNS-answer filtering (Node only)
  index.js              public re-exports

Tests

npm test          # node --test; 380+ tests

Includes RFC 7518 §B.3 (A256CBC-HS512) and §C (Concat KDF) known-answer vectors, the W3C did:key spec vectors, the DIF did:peer numalgo-2 example, did:webvh against real didwebvh-rs fixtures, did:webs against vectors recorded from affinidi-did-webs (its own test suite and generated mutation sweeps; test/fixtures/did-webs/), and cross-implementation round-trips through the Rust affinidi-messaging-didcomm unpack (vti-didcomm-roundtrip-helper) for both X25519 and P-256. Some tests reach live infra / the Rust helper and skip cleanly when unavailable.

License

Apache-2.0

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages