Browser-side bridge between WebAuthn passkeys and VTA-managed DIDs. Lets an operator prove control of a DID hosted in a remote Verifiable Trust Agent by performing a passkey ceremony in the browser β no DID private keys ever leave the VTA, and no long-lived bearer token sits in browser storage. Speaks both REST and full DIDComm v2 to the VTA, including when the VTA is private-network and only reachable via a mediator.
Passkeys solve local user authentication beautifully (synced keychains, biometric unlock, phishing-resistant). DIDs solve global identity (self-sovereign, portable, signable). Neither alone solves "log into a third-party site as the controller of my VTA DID."
This project glues the two together: a passkey is enrolled as a
verificationMethod (purpose: authentication) in the DID document
the VTA publishes via WebVH. Any verifier that resolves the DID can
then validate a WebAuthn assertion against the embedded public key
without ever talking to the VTA β the DID document is the trust
anchor.
ββββββββββββββββ WebAuthn ββββββββββββββββββ
β Browser β ββββββββββββββββΆ β Authenticator β (Touch ID,
β (PWA / ext) β ββββββββββββββββ β / Passkey β Windows Hello,
ββββββββ¬ββββββββ pubkey + sig ββββββββββββββββββ YubiKey, β¦)
β
β enroll(passkey_pubkey) verify(assertion)
βΌ β²
ββββββββββββββββ βββββββββ΄βββββββββ
β VTA β ββ WebVH update ββββββΆβ Public DID doc β
β (remote) β β (resolvable by β
ββββββββββββββββ β any verifier) β
βββββββββ¬βββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PWA (Vite + React 19) MV3 Extension (Vite + React) β
β packages/pwa packages/extension β
ββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β @openvtc/pnm-core β
β β
β WalletSession ββββββββββββββββββββββββββββββββββββββ β
β β β β
β ββ generateOrLoadHolderIdentity (KVStore) β β
β β ββ IndexedDBKVStore (browser) β β
β β ββ InMemoryKVStore (tests) β β
β β β β
β ββ MediatorClient (coordinate-mediation/2.0) β β
β β ββ requestMediation β β
β β ββ updateKeylist β β
β β ββ setLiveDelivery β β
β β ββ acknowledgeMessages β β
β β β β
β ββ DidcommVtaTransport (passkey-management/1.0) β β
β (implements VtaTransport) β β
β β β
β VtaClient (REST, implements VtaTransport) ββββββββ β
β β
β DidcommMessageBridge interface β β
β ββ WebSocketDidcommBridge β multi-sender registry β
β β ββ RawDispatcher β (skid-based JWE peek) β
β β ββ Pickup3Dispatcher β (live-mode unwrap) β
β ββ InMemoryDidcommBridge β (test simulator) β
β β β
β webauthn/ did/ vta/ store/ didcomm/ β
ββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β @openvtc/vti-didcomm-js (npm dependency) β
β WebCrypto-backed DIDComm v2 primitives β
β Identity { generate, fromSecretJwk, publicJwk, secretJwk } β
β packAuthcrypt / packAnoncrypt / wrapForward / unpack β
β packAnoncryptJson / packAuthcryptJson (preserves extras) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Every layer has matching ownership:
@openvtc/vti-didcomm-jsowns crypto: HPKE-style authcrypt (ECDH-1PU + A256CBC-HS512), anoncrypt (ECDH-ES + A256CBC-HS512), Ed25519 signatures, Routing 2.0 forward envelopes. Pulled in as an npm dependency β no Rust toolchain required to build this workspace.- @openvtc/pnm-core owns the wire protocols + transport abstraction.
REST (
VtaClient) and DIDComm (DidcommVtaTransport) implement the sameVtaTransportinterface; pick at boot. - Bridge implementations own network I/O.
WebSocketDidcommBridgeis the production bridge (mediator over WSS, Pickup 3.0 live mode viaPickup3Dispatcher, concurrent thid demuxing).InMemoryDidcommBridgesimulates both mediator and VTA for tests. WalletSessionis the only thing the UI talks to. One call βbootstrap()β does identity load/mint, mediator enrollment (if needed), and constructs a readyVtaTransport.- PWA / extension are thin shells over
@openvtc/pnm-core.
| Package | Role |
|---|---|
@openvtc/pnm-core |
Wire types, WebAuthn ceremony helpers, COSEβMultikey conversion, DID verificationMethod builder, REST + DIDComm transports, mediator client, bridges, wallet orchestration. |
@openvtc/pnm-pwa |
Installable web app (Vite + React 19). Operator-facing wallet for connecting to a VTA and managing passkeys. Has a /smokes diagnostic page that runs the in-browser smoke suite. |
@openvtc/pnm-extension |
Manifest v3 browser extension. Same flows, plus future ability to intercept RP login pages and inject SIOPv2/OpenID4VP responses. |
- Operator points the app at a running VTA and authenticates with an
existing admin credential (the standard
pnm bootstrap connectflow already produces one) β or the wallet bootstraps over DIDComm viaWalletSession. - App triggers
navigator.credentials.create(...). The authenticator produces a credential whose public key is COSE-encoded. - App parses the COSE_Key, converts it to W3C Multikey form
(multicodec
0x1200for P-256 / ES256,0xed01for Ed25519, multibase-base58btc with thezprefix). - App POSTs
{ credential_id, multikey_pubkey, attestation_object }to the VTA (POST /did/verification-methods/passkeyβ new endpoint, see docs/passkey-did-binding.md for the contract). - VTA appends a WebVH LogEntry adding the VM with
id = <did>#passkey-<sha256(credential_id)>and purposeauthentication. - From then on, any RP can verify a WebAuthn assertion against the VM by resolving the DID β no shared secret, no callback to the VTA.
When the VTA has services rest disable'd and is only reachable via
its DIDComm mediator, the wallet stack handles it transparently:
const session = new WalletSession({
store: new IndexedDBKVStore(),
mediator: {
websocketUrl: "wss://mediator.example.com/ws",
did: "did:key:zMediatorβ¦",
keyAgreementKid: "did:key:zMediatorβ¦#key-agreement-1",
keyAgreementPublicJwk: { kty: "OKP", crv: "X25519", x: "β¦" },
},
vta: {
did: "did:webvh:vta.example.com:abc",
keyAgreementKid: "did:webvh:β¦#key-agreement-1",
keyAgreementPublicJwk: { kty: "OKP", crv: "X25519", x: "β¦" },
},
});
await session.bootstrap(); // mint or load holder, enroll w/ mediator
await session.setLiveDelivery(true);// pickup/3.0 live-mode push
const challenge = await session
.transport()
.requestEnrollmentChallenge(holder.did); // passkey-management/1.0On first run: mints a did:key holder identity, registers with the mediator (coordinate-mediation/2.0), persists state.
On subsequent runs: loads the holder identity, detects the existing mediator relationship, skips re-enrollment.
All messages travel as:
- inner:
authcrypt(holder β VTA, passkey-management/1.0/...) - wrapped:
routing/2.0/forwardenvelope addressed to the VTA - outer:
anoncrypt(forward β mediator)
The mediator delivers the inner JWE to the VTA. Replies travel back
via the wallet's mediator inbox, decrypted by Pickup3Dispatcher,
and demuxed by thid to the waiting Promise.
The wallet runs in the browser, so the mediator's WebSocket
(wss://β¦/ws) and REST (/inbound, /authenticate, β¦) endpoints must
allow the origin the wallet page is served from β either by echoing
that exact origin in Access-Control-Allow-Origin, or with *. This is
a mediator-side configuration; the wallet cannot work around it.
Symptom of a missing/incorrect CORS allow-list: the REST auth handshake succeeds (or appears to), but opening the live-delivery socket fails with
mediator-transport: WebSocket failed to open (close code 1006)
A browser rejects a cross-origin WebSocket upgrade before the socket
opens, which surfaces as an abnormal 1006 close with no HTTP status β
indistinguishable, from the client side, from a refused upgrade or a
proxy that strips the Upgrade header. If REST auth works from the same
page but the WS gives 1006, check the mediator's CORS allow-list for
your wallet origin first.
For a self-hosted affinidi-messaging-mediator, set the allowed origins
in its config (e.g. cors_allow_origin) to include your wallet's origin
(http://localhost:5173 in dev, your extension/PWA origin in prod), or
* for a permissive dev setup. Restart the mediator after changing it.
Six smokes cover the main DIDComm + wallet links. Run from a browser
at /smokes or invoke directly via @openvtc/pnm-core:
| Smoke | What it proves |
|---|---|
smokeAuthcryptRoundtrip |
authcrypt + unpack round-trips intact |
smokeBuildDidcommEnrollChallenge |
Full forward+anoncrypt envelope assembly |
smokeDidcommVtaTransportRoundtrip |
DIDComm enrollment exchange via in-memory bridge |
smokeMediatorEnrollment |
mediate-request β grant β keylist-update |
smokeMediatorNotifications |
live-delivery-change + messages-received notifications |
smokeWalletBoot |
Full WalletSession bootstrap on first run + resume on second run |
Scaffold + core enrollment ceremony + REST client + complete DIDComm v2 stack (authcrypt, anoncrypt, forward, coordinate-mediation/2.0, pickup/3.0 live mode) + WalletSession orchestrator.
The VTA-side endpoint is documented in
docs/passkey-did-binding.md but not
yet implemented in vta-service; the browser code targets the
documented contract and can be exercised against a mock today.
pnm-browser-plugin/
βββ package.json (npm workspaces root)
βββ tsconfig.base.json (shared compiler options)
βββ tsconfig.json (solution-style references)
βββ docs/
β βββ passkey-did-binding.md
βββ packages/
βββ core/ @openvtc/pnm-core
β βββ src/
β βββ webauthn/ passkey ceremony helpers, COSEβMultikey
β βββ did/ verificationMethod builder
β βββ didcomm/ TS facade over @openvtc/vti-didcomm-js
β βββ store/ KVStore + holder identity persistence
β βββ vta/ transports, bridges, MediatorClient,
β WalletSession, smokes
βββ pwa/ @openvtc/pnm-pwa (Vite + React 19)
βββ extension/ @openvtc/pnm-extension (Manifest v3)
Prereqs: Node 24+. No Rust toolchain needed β DIDComm crypto comes
from the @openvtc/vti-didcomm-js npm package.
npm install
npm run build # tsc -b + vite build across TS workspaces
npm run dev:pwa # http://localhost:5173
npm run dev:extension # vite watch into packages/extension/distAfter npm run build (or while npm run dev:extension is running),
packages/extension/dist/ contains a complete unpacked Manifest v3
extension. Side-load it like this:
Chrome / Edge / Brave / Arc
-
Open
chrome://extensions(oredge://extensions,brave://extensions,arc://extensions). -
Toggle Developer mode on (top-right).
-
Click Load unpacked and select
packages/extension/dist/from this checkout. -
Pin VTA Wallet to the toolbar (Chrome's puzzle-piece icon β pin) so it's one click away.
-
Setup opens automatically on install. Work through it top to bottom β the order matters. The agent's address comes first because its mediator is resolved from it; you're only asked for a mediator if the agent publishes none. The passkey lock and your inbox follow. You can reopen it any time at Options β Setup, or from the popup's Open setup button.
The agent field takes either form: an agent name (
webvh.storm.ws/@glenn-vta) or a fulldid:webvh:β¦. A name is resolved in three stages β an HTTPS request that answers with a DID, DID resolution, then the document must claim the name back viaalsoKnownAs. The third stage is mandatory: a redirect alone proves nothing, since whoever controls a domain can point a name at somebody else's DID. The DID is what gets stored; names are re-claimable.Stage 1 needs the name's server to answer in a form a browser can read. The reference (Rust) resolver reads a
did:β¦straight out of aLocationheader; no browser can, so the wallet sends a browser-shapedAcceptand follows whatever it is given β the webvh hosting service answers that with a redirect to the DID's log. A name server that only ever answers with a baredid:redirect cannot be resolved from an extension at all; paste the fulldid:webvh:β¦instead.Onboarding deliberately runs in a tab rather than the toolbar popup: Chrome tears the popup down whenever a native dialog takes focus (crbug 40721470), and this flow raises two β the host-permission grant and the passkey ceremony.
-
Optional extras β a separate approver identity, background wake-up, step-up defaults β live under Advanced and can wait.
Firefox
Not supported out of the box: Firefox's MV3 service-worker support diverges from Chromium and the manifest would need adjusting. Not in scope today.
Reloading after a rebuild
npm run dev:extension rebuilds into dist/ on change, but Chrome
does not auto-pick up the new bundle for a side-loaded extension.
After each rebuild, click the reload icon on the VTA Wallet card
in chrome://extensions.
Debugging
chrome://extensions β VTA Wallet β service worker opens
DevTools for the background script. The popup, options page, and
offscreen document each have their own DevTools β right-click β
Inspect on the popup, or use the Inspect views links on the
extension card.
The extension requests no host permissions at install time and declares
no static content scripts. The manifest carries optional_host_permissions
only, and the page provider is registered at runtime for granted origins
(src/content-registration.ts) β so the install prompt no longer asks to read
and change your data on all websites.
A grant means two things at once: the wallet may talk to that host, and
window.vtaWallet exists on its pages. Turning a site off in Sites stops
both.
The wallet asks for one origin at a time, at the moment it needs it:
- Connecting to a VTA β the host is read out of the
did:webvhbefore any request is made, and Chrome prompts on the Prepare click. Needed because vta-service applies an origin-allowlist CORS layer. - Visiting a site for the first time β the popup shows "Enable on ". Until then the site cannot see the wallet at all, so its sign-in button does nothing; enabling registers the provider and reloads the page.
Resolving DIDs needs no grant: the did:webvh hosting service serves public
resolution with Access-Control-Allow-Origin: *. A did:webvh host behind a
restrictive CORS policy is the known gap β it surfaces in the consent prompt
as an unresolved DID, which fails closed.
A sign-in produces a signed id_token the site verifies for itself by
resolving your published DID document; a DIDComm login never touches browser
state either. The extension holds no cookies permission and calls no cookie
API β chrome.cookies.set appears nowhere in the source.
It did once. A relying party that was an ordinary web app with a server-side
session and no notion of DIDs could have its login performed by the VTA, which
returned a SessionBlob whose cookie jar the wallet wrote into the browser
(guarded by scope checks in what was src/cookie-scope.ts). That legacy path
is gone: the cookies permission is off the manifest, the vault's password
entries are browser-fill only, and any cookie jar a VTA still returns is
dropped in the offscreen document before it can cross into the popup
(doVaultProxyLogin, src/offscreen.ts).
What remains of vault/proxy-login is the modern half β the VTA mints a SIOP
id_token on your behalf and the long-term key never leaves it. The wallet
displays that session; it never installs it. Keep it that way when changing
this code: "the wallet writes nothing into your browser" is a claim a reviewer
can check in one grep, and it is worth more than any scoping argument.
npm run package --workspace @openvtc/pnm-extension
# β packages/extension/release/vta-wallet-<version>.zipThe zip holds the contents of dist/ (manifest at the archive root,
as the Store requires). CI builds and validates it on every run and
uploads it as the vta-wallet-extension artifact, so a submission is
never the first time the packaging path runs.
Version. packages/extension/package.json is the single source of
truth; packages/extension/manifest.json is a template and carries no
version field. The build injects it and rejects anything the Store
would reject β npm prerelease spellings like 0.3.0-rc.1 are not valid
Chrome versions. Bump the package version before each upload: the Store
refuses a version that is not greater than the last one it accepted.
Extension ID. chrome.runtime.id is load-bearing β src/holder.ts
uses it as the WebAuthn PRF rpId, so if it changes, every
passkey-wrapped secret becomes unopenable and any
chrome-extension://<id> allowlist (VTA CORS) breaks. It is pinned by
extension-key.txt, whose public key the build injects into dist/'s
manifest as key.
The Store zip deliberately omits key: the Store issues its own key on
the first upload of a new item and rejects a package that carries one.
So the local ID matches the published one only after cutover β once the
item exists in the Developer Dashboard, replace extension-key.txt
with the dashboard's Package β View public key value and delete
extension-key.pem. Confirm the result:
npm run generate:key --workspace @openvtc/pnm-extension -- --show
# prints the derived ID; it must equal the dashboard's Item IDUntil then the pinned key is a locally generated one (npm run generate:key), which holds the ID still across machines and checkouts
but is not the published ID. Cutover re-keys every dev passkey once.
npm run dev:pwa
# open http://localhost:5173/smokes β click "Run all"All smokes should pass. The diagnostics page exercises every layer of the stack including the WebSocket bridge's thid demuxer and the full WalletSession boot.