did-hosting-service (formerly Affinidi WebVH Service)
Repo: github.com/affinidi/affinidi-webvh-service
Production infrastructure for hosting, resolving, and managing did:webvh (since v0.7.0 also did:web, and since August 2026 did:webs — KERI-anchored — behind an off-by-default method-webs feature) identifiers at scale. It’s the operational backbone that makes self-hosted DIDs practical — handling the hosting, witnessing, and monitoring that individual users shouldn’t have to manage themselves.
The repository was renamed from affinidi-webvh-service to did-hosting-service in v0.7.0 (May 2026). The rename reflects two architectural shifts: the service is no longer single-method (it now hosts both did:webvh and did:web by default, with a DidMethod trait abstraction and compile-time feature gating), and the binaries are now named for the capability they expose rather than a specific underlying method (did-host-http, did-host-didcomm, did-host-http-didcomm).
Components
The service is a Rust workspace. Since the v0.7.0 rename the crates are named for the capability they expose, and since 0.8.0 (July 2026) they are versioned per crate — so a snapshot lists several numbers. The versions below were set at the coordinated Cypress release (coordinated-releases, 2026-08-17) and are unchanged at VTI-Dogwood (08-30), VTI-Dogwood-R1 (same commit), VTI-Eucalyptus-RC-0 (09-17, #194) and VTI-Eucalyptus (tag 2026-10-07 on #252 of 10-01) — seven weeks of work, including the removal of every REST management route, accumulate under “Unreleased” in the CHANGELOG, and the coordinated tags are the only reference points:
| Crate | Port | Role | Cypress … VTI-Eucalyptus |
|---|---|---|---|
| did-hosting-server | 8530 | The edge: public resolution — did.jsonl (webvh), did.json (web), keri.cesr + derived did.json (webs), /@name agent-name redirects — plus /api/health and POST /api/trust-tasks, and nothing else since #225. Written to only by its control plane’s signed Trust Tasks (webvh/sync/*, did-management/replica/domain/*); a standalone edge without a control plane is no longer supported | 0.8.3 |
| webvh-witness | 8531 | Generate cryptographic witness proofs for DID integrity. Trust Tasks only since #230 (webvh/witness/key/{create,list,delete}/0.1, webvh/witness/sign/0.1, acl/*) on TSP, DIDComm and POST /api/trust-tasks; witness/sign verifies the log before it signs | 0.8.3 |
| did-hosting-control | 8532 | The control plane: DID management, domains, service registry, passkey enrolment and step-up, ACL, agent-name registry (source of record), stats, identity generations, an outbox of signed directives to edges and watchers — all served only as Trust Tasks since #238 (the REST management surface, the reverse proxy and /metrics are gone); keeps /api/health, the browser SIOPv2 sign-in and the console’s static files | 0.8.8 |
| webvh-watcher | 8533 | Read-only DID mirror for redundancy. Since late September it has its own DID and one listener (webvh/sync/{update,delete}/0.2, webvh/sync/batch/0.1), applies only documents signed by a configured source_dids member, and verifies logs as an edge does | 0.8.3 |
| did-hosting-daemon | 8534 | All-in-one binary (server + witness + watcher + control) for simple deployments; the embedded server reads the store its in-process control plane writes | 0.8.3 |
did-hosting-ui (webvh-ui) | — | Web UI bundle (packaged for cargo publish since #157; Node ≥ 24.3 to build since #182); calls the control plane as Trust Tasks since #219 and signs every envelope with a per-session key | 1.1.0 |
| did-hosting-common | — | Shared library: DID operations, the Trust Task dispatch core and its three transport bindings, the sender-bound proof verifier (TransportBoundVerifier), vti-secrets integration, storage, config | 0.8.6 |
| — | Deleted in #238 (REST-only, unpublished, no caller once the console moved to Trust Tasks). WebVHClient in did-hosting-common signs Trust Tasks instead | — |
Every node is a three-transport, document-driven endpoint: HTTPS (POST /api/trust-tasks), DIDComm v2, and TSP all deliver the same Trust Task documents to one dispatch core (literally one router since #183), and a node’s DID document is the authority for which transports it offers — since #228 a node can also send over HTTPS when the peer advertises TrustTaskHTTPS, in the precedence TSP > DIDComm > HTTPS. Since #213 the transport establishes nothing: every message that changes state or discloses more than public data carries a W3C Data Integrity proof in the document itself, bound to an in-band issuer that must be the expected peer, addressed to the receiving service, fresh, and replay-checked on (issuer, document id); operational proofs use proofPurpose: authentication, human approvals assertionMethod, and the two are refused under each other. Replies are signed too, and a control plane that cannot sign refuses to start. TSP is the Rev 3 line (affinidi-tsp 0.2) since Eucalyptus-RC-0, with relationships persisted per node so a restart does not silently drop the control plane’s pushes.
How It Works
- A user creates a did:webvh via the VTA
- The VTA registers the DID with the control plane (
did/register, a signed Trust Task over TSP, DIDComm or HTTPS), which verifies the log — signatures, pre-rotation, and that any witness threshold is satisfied for the versions a witness could already have signed - The control plane replicates the record to its edges as
webvh/sync/update/0.2through a signed outbox; an edge applies it only if the proof chain verifies and it strictly extends the log the edge already holds, then servesdid.jsonlat the DID’s well-known URL - Witnesses attest to DID updates by answering
webvh/witness/sign/0.1— refusing an entry whose chain does not verify, that is not the last, of a deactivated DID, or whose witness parameter does not name them - Watchers mirror the DID for redundancy, applying only syncs signed by their configured source DIDs and verifying the chain as an edge would
- The control plane manages domains, service registration, passkeys and access control, and reconciles edges periodically so replication staleness is bounded
VTA Integration
The WebVH service integrates deeply with the VTA:
- Each service component authenticates to a VTA context during bootstrap, and (since #237) keeps its keys and context credential in the same
vti-secretsbackends the VTA and VTC use — keyring, AWS, GCP, Azure, Vault, Kubernetes; plaintext is tests-only and a host with no OS keychain refuses to start - Signed Trust Task documents replace bearer tokens between services; a VTA-provisioned daemon auto-trusts its provisioning VTA
- The service uses the VTA’s signing oracle for DID operations; the VTA’s own DID-management client must sign each document with the VTA DID as
issuer(VTI side) - Browser sign-in is a SIOPv2
id_tokenminted by the holder’s VTA (auth/authenticate/0.3for the proxied form), after which the console signs with a non-extractable per-session key so the wallet is asked once, at sign-in
The cold-start bootstrap flow (import-secrets CLI) can bring up an entire environment from scratch: VTA → mediator → webvh-server.
Recent Development
The 2026-09-18 → 10-07 cycle (48 commits, PRs #202–#252; VTI-Eucalyptus tagged on #252) is the one in which the service stopped being a REST API. Again no crate version moved — did-hosting-server stays 0.8.3, control 0.8.8, common 0.8.6 — but the surface area changed more than in any release since the v0.7.0 rename. Three moves in sequence: first (#213, 09-25) every privileged message had to carry a Data Integrity proof bound to its sender, with the transport’s word about who sent it reduced to a consistency check; then (#217–#238, 09-27/28) the control plane, the edge, the witness and the watcher were each rebuilt as one table of generated Trust Task types served identically on TSP, DIDComm and HTTPS, and the REST management routes — /api/dids*, /api/acl*, /api/witness/*, /api/stats*, the proxy, /metrics, the passkey REST — were deleted outright rather than deprecated, taking the did-hosting-client crate with them; and finally (#239–#252, 09-29 → 10-01) a hardening pass under the SEC-4045 review series fixed what the cutover exposed — a DoS amplifier hiding behind a size check, a witnessed DID that could never be registered, a deleted DID that kept resolving for a minute, a shared loopback address that collapsed every client into one rate-limit bucket. Alongside: passkey enrolment as Trust Tasks with hashed two-secret invites and a separate step-up credential class, vti-secrets replacing the local secret backends, and TSP Rev 3 invites answered correctly after the SDK started ordering a peer’s frames. Two corrections to what the wiki said last cycle: the legacy /api/acl surface with its Sunset: 2026-12-01 header is gone (control plane #238, edge #225, witness #230), two months ahead of the date it advertised; and the ConsumeChecks::not_consequential() caveat about an acl/grant executing twice is retired — replay protection is now keyed on (proven issuer, document id) on every transport.
Eucalyptus tag — 2026-10-01 (#251, #252)
- #251 — the VTA / Trust Tasks / messaging set moves together:
vta-sdk0.56 → 0.58.1,vti-common0.29 → 0.31,vti-secrets0.4.8 → 0.5, the sixtrust-tasks-*crates 0.24 → 0.25 (the release that conforms the vetting, member and endorsement specs to the DTG v1 statement and authority credentials — see dtg-credentials 0.12.0),affinidi-tdk0.20 → 0.21,affinidi-messaging-sdk0.30 → 0.31,affinidi-messaging-didcomm-service0.15 → 0.16, didwebvh-rs 0.6 → 0.7. With didwebvh-rs 0.7 the verifier’sdid:webvhdeactivation check resolves public hosts only, so a local stack must say so to the verifier too (TransportBoundVerifier::with_did_cache_and_host_policy,WitnessClient::with_host_policy); production is unchanged because every service’s DID cache was alreadyPublicOnly. Also fixes an unlockedcargo installthat held twovti_common::error::AppErrortypes. Note what the service is not on at the tag: the TDK’s 0.8 wave (data-integrity 0.8, didwebvh-rs 0.8, trust-tasks 0.26/0.27, tdk 0.22/0.23) landed 10-01 → 10-03 in affinidi-tdk, after this repository’s last commit. - #252 — a deactivated did:webvh signer is refused as final again.
affinidi-did-resolver-cache-sdk0.8.39 (in via #251) now refuses to resolve a deactivated did:webvh instead of returning its last document, andSignerKeyResolverreported every resolution failure as an unreachable signer — so an uncached deactivated signer, still refused, became a retryable failure costing a cache eviction and a fresh resolution per proof. On a failed resolution the resolver now asks the DID’s own log (DeactivationCache, didwebvh-rs) and reports a deactivated DID as deactivated. The existing tests had placed documents in the cache, so resolution never failed and could not see this.
Post-cutover hardening — 2026-09-29/30 (#239, #241–#250)
Why. Deleting the REST surface and moving authorization into documents changed where the cheap and expensive work sits — an unauthenticated document now triggers DID resolution and proof verification before anyone knows who sent it — and the daemon’s first real end-to-end smoke test (#240: distributed mode over TSP, DIDComm and HTTPS in turn; daemon mode against the real binary) found bugs the unit tests could not.
- Two real bugs from the smoke test (#241): a witnessed webvh log could never pass
did/register, because the whole chain was validated with no witness content anddidwebvh-rs’svalidate()enforces the witness threshold unconditionally — “no proof can exist for a not-yet-published version”, so every witnessed DID was permanently unregistrable and every republish re-broke it; and a deleted DID kept serving its last resolved content for up to a minute (AppState::did_cachewas never invalidated on delete). Both fixed. - Pre-check before you resolve (#242): the edge, witness and watcher resolved a document’s claimed issuer before checking it against their ACL / allowlist /
control_did, so any address could force unbounded outbound DID resolution by naming a fresh issuer per request. Each listener now runs a per-address rate limiter ahead of verification and a cheap pre-check that refuses an issuer the policy would never accept — with no reply, the same refusal an invalid proof gets. Also: the edge’sverify_historyread-check-write race acrosssync/update,sync/batchandsync/deleteis serialized under one lock; a delete used to fan out to every configured watcher, leaking a mnemonic to watchers that never mirrored it — the control plane now records at publish time which watchers a log actually named. - A raised limit costs a budget (#244): #239 had granted
did/register’s 1 MiB body limit to any document whose in-bandissuernamed a DID with an ACL entry — read off the unparsed body before any proof check — so anyone who had seen an admin’s DID could buy the full 1 MiB of parsing and proof verification repeatedly: “a DoS amplifier hiding behind a check that looks like an authorization gate.” Large documents are now charged against a small per-address budget (5/min) before verification, and a verified issuer that differs from the claimed one doubles that address’s next cost. A document at or under the 64 KiB default is never charged. - Who is the client? (#247, #249): production routers for the edge, watcher and witness baked in a
MockConnectInfo(127.0.0.1)layer that silently substituted a shared loopback address whenConnectInfowas missing — collapsing every client onto one rate-limit bucket, and matching a trusted-proxy CIDR soX-Forwarded-Forcould be spoofed. Removed; a missing address now fails closed. The control plane’s/api/trust-taskslikewise refuses a request with no connection address instead of counting it underip:unknownand skipping the invite-redemption limit. - Sessions (#245, #248, #250):
auth/authenticate/0.3(proxied — the control plane independently verifiesdelegationEvidence, a self-issued mandate or the proxy login’s own SIOPid_token, before honoringprincipal, and records the delegate asSession.actor) andauth/refresh/0.2(a bound session key’s proof checked against the session’s own record; every refresh capped at a configurableabsoluteExpiresAt, default 30 days); the 0.2/0.1 forms stay for the VTI Wallet extension, which this change cannot re-version; the bespoke REST/api/auth/and/api/auth/refreshroutes go.auth/revoke-session/0.2now also deletes the proxied login’s meta row, and the console moves access/refresh tokens out oflocalStorage(“a flat, synchronous, enumerable store any script on the origin can read in bulk”) into an in-memory cache mirrored to IndexedDB. Once a subject holds a step-up passkey, it is the only step-up route — adidSignedapproval made without touching the passkey no longer elevates, checked fresh at decision time; enrolling one drops any session already elevated the old way;acr/amrare re-derived from the session row on every request rather than trusted from the JWT, closing a demotion TOCTOU. - Step-up passkeys approve; passkeys can be listed and revoked (#243):
auth/step-up/approve-response/0.5accepts WebAuthn evidence from a step-up passkey (a separate store the login ceremony never reads);auth/passkey/list,admin-list, andrevoke/{start,finish}(a re-authentication ceremony, not a bare delete); a subject’s last login passkey is guarded against revocation; “My Passkeys” in the console. - Regressions from the REST removal (#239): per-type document limits, and ACL grants by agent name — the console now resolves a name to a DID itself (searching hosted DIDs and confirming with
agent-name/resolve) before sendingacl/grant, because only the deletedPOST /api/aclhad done so. - Replication staleness is bounded (#226): a periodic reconcile on the edge, so an edge that missed a directive converges without waiting for the next registration.
REST management removed — the control plane, edge, witness and watcher are Trust Tasks only — 2026-09-27/28 (#215–#238)
Why. Since July every operation had also been a Trust Task; REST was kept “until its callers move”. With the console (#219), the VTA and the wallet all signing documents, the duplicate surface was only a second place for authorization to be wrong — and #239’s agent-name regression shows it had been quietly doing work the Trust Task path did not. The decision was to delete, not deprecate: “pre-production policy”, as the July canonicalization put it.
- Generated types end to end (#217, #218; trust-tasks 0.23 → 0.23.5, messaging-sdk 0.28, #215): the control plane’s DID-management tasks are one table keyed on the generated
Payload::TYPE_URIs (control_tasks), the hand-builtMSG_*JSON bridge is gone, every reply matches its schema (did/list→{records, total}; record-bearing tasks → the sharedDidRecord; host-specific members under avnd.affinidi.webvhextension), errors aretrust-task-errordocuments with the codes each specification declares, requests are held to their schemas (snake_case aliases refused), and sync moves towebvh/sync/update/0.2/delete/0.2carrying the slot’sdisabledstate. Then every task only REST had served, and every new specification from dtgwg-trust-tasks-tf #661 the control plane owns —did/{set-state,rollback,log},agent-name/resolve, thedomain/*andregistry/*families,stats/*,server/{info,config,metrics},identity/*, step-upapprove-request/0.3/approve-response/0.5, passkey login 0.2, invite{list,update,revoke}— joins the table on all three transports.POST /api/trust-tasksno longer requires a bearer session: the document’s proof is the authorization, as on the other transports. - Wallet logins bind a session key (#227, #229, #231;
auth/authenticate/0.2, dtgwg-trust-tasks-tf #675): an authenticate document may name asessionKey, adid:keythe subject’s own proof covers, which the control plane binds to the session as a passkey login binds its browser key. It signs operational calls, never approves a step-up (adid:keylists its key underassertionMethod, which had let a passkey session’s key approve its own step-up — closed), and never signsauth/authenticateorauth/refresh. This is what ended the proxy-login popup storm: the wallet’ssignTrustTaskasks the human every time by design, and a page load fired several at once. - The edge (#224, #225): consumes the control plane’s
did-management/replica/domain/{upsert,assign,unassign,purge}/0.1directives (apurgeissued before the current assignment is refused asstalePurge; a verifying document from any DID other than the control plane gets a signednotAuthorized; one that does not verify gets no reply), and loses its entire/api/*management router — auth, dids, witness upload, log, raw, disable/enable, services, stats, config, ACL — along with its own sessions, ACL and theadd-acl/list-acl/remove-aclcommands, the watcher push, thewatcherstable andcontrol_url. Stats go as aserver/stats-syncTrust Task. A standalone edge without acontrol_didrefuses to run and points at the daemon. - The witness (#230):
webvh/witness/key/{create,list,delete}/0.1,webvh/witness/sign/0.1andacl/*through one dispatch on TSP, DIDComm andPOST /api/trust-tasks;/api/auth/*,/api/witnesses*,/api/proof/*,/api/acl*, the JWT sessions and[auth]settings are removed. Every request is authorized on its ownauthenticationproof, andwitness/signverifies before it signs (invalidLog,versionNotLast,deactivated,notListed). The same PR adds HTTPS as a send transport (TSP > DIDComm > HTTPS; a verified HTTPS reply settles the outbox entry at once) and a smoke test of replication through a real mediator. - The watcher (CHANGELOG, same wave): provisioned from a VTA context with its own DID like the other services; one listener for
webvh/sync/update/0.2,delete/0.2,batch/0.1; only a document signed by one ofsync.source_didsis applied;/api/sync/*,push_tokensand the REST sync wire types are gone; the control plane fans out to theregistry.watcherstable through the same outbox, retry and signed acknowledgement as edges. - Passkey enrolment is Trust Tasks (#236):
auth/passkey/enroll/invite/0.2,redeem/{start,finish}/0.1,enroll/{start,finish}/0.2(the WebAuthn ceremony data rides inside the payloads). An invite is two secrets stored hashed — a 256-bit URL token (SHA-256 lookup key) and a 60-bit claim code for a second channel (salted Argon2id, constant-time compare); five wrong codes delete the invite, redemption is rate-limited per source, invites expire and are consumed exactly once, atomically with binding the credential, and an unknown token, a wrong code and an expired invite get the same refusal. Step-up-only passkeys live in a separate keyspace the login ceremony never reads. Enrolling a further passkey of a purpose the subject already holds needs a user-verified assertion from one of them. At most eight Argon2 hashes (19 MiB each) run at once service-wide, because a fresh per-source budget for anyone who can mint VIDs did not bound memory. No migration: issue new invites. - Secrets are
vti-secrets(#237): the local backends are deleted in favor of the published crate the VTA and VTC use;secrets.backendnames the backend; no keychain, no start — a headless Linux box without a Secret Service is refused with the list of secure backends, nothing falls back to plaintext, and plaintext is tests-only behindconfirm_plaintext = truein both recipe and config. Existing stored secrets are not read: recreate the deployment. - The control plane (#238): keeps
POST /api/trust-tasks,/api/health, the browser SIOPv2 sign-in (/api/auth/challenge,/api/auth/,/api/auth/refresh— the latter two replaced by Trust Tasks in #245) and the console’s static fallback, which no longer answers any/api/*path. Removed: every REST management route listed above, theTrust-Task:header router and the housetrusttasks.org/did-hosting/*/1.0URIs, the DIDComm-JWS auth dialect,ControlClient, thedid-hosting-clientcrate, the/api/proxy/*pass-through and/api/task-consent/*(no caller), and the Prometheus/metricsendpoint (server/metrics/0.1replaces it) with theprometheusdependency. - Smaller: offline CLI commands to list, reset and delete TSP relationships (#233 — relationship state is per endpoint and persisted, so “making two nodes meet as strangers again means clearing both halves, and until now there was no way to clear this one”); optional Fjall memory settings for pod sizing (#222); the daemon re-mints on a new public URL under
--force-reprovisionand advertisesTrustTaskHTTPSat{origin}/api(#221); messaging-sdk 0.30.1 so an already-deleted message stops logging a warning every 30 s (#234); a witness can witness the entry that deactivates a DID (#235).
Proof-bound control plane — 2026-09-20 → 09-25 (#205, #208, #210–#216)
- #213 — every privileged message carries a proof bound to its sender (breaking: “control plane and edges must be upgraded together”). The principle: a document is acted on only when it carries a
proofwhoseverificationMethodis controlled by its in-bandissuer, theissueris exactly the expected peer,recipientnames the receiving service,issuedAtis fresh, and(issuer, document id)has not been seen. Operational proofs useproofPurpose: authentication— a service’s own messages are not attestations — andassertionMethodis reserved for credentials and human approvals (task-consent/decision,auth/step-up/approve-response), each refused under the other. Control plane → edge sync, domain ops and health pings go as signed Trust Tasks and edges apply them only when signed by the configuredcontrol_didand addressed to themselves; edges verify the webvh log chain of every synced DID and apply only a strict extension of the log already held (a rollback, a fork, or anything after a deactivation is refused), keeping a per-DID and per-slot high-water mark a delete does not clear. Edge → control plane registration, stats, pongs and acks are signed and the signer must hold theServicerole (pongs had no ACL check at all). DID-management, ACL (reads included), typeddid-hosting/1.0and auth Trust Tasks must be signed by their issuer, except discovery andauth/challenge. Removed: the bare-message DIDComm routes, the serialized-MessageTSP form,POST /api/didcommand bareMSG_AUTHENTICATE.trust_tasks.enforce_proofs = falseis refused at startup — “there is no longer a mode in which proofs are ignored.” A proof that fails against a cached DID document is retried once against a fresh one (key rotation opens no rejection window; cache TTL now explicit at 120 s); a signer whose DID cannot be resolved isunavailableand retryable, so the control plane keeps the op queued. Edges answer nothing at all to a document that fails verification. The outbox removes an entry only on the target’s signed acknowledgement of the exact document sent, re-signing on every retry; edges re-register at startup, on every mediator reconnect and every ten minutes. - #216 — the daemon’s embedded control plane checks signer status like the standalone one: it had built its proof verifier over the bare DID cache resolver, so it neither refused a deactivated
did:webvhsigner nor retried against a fresh document after rotation. One shared constructor (build_verifier) now serves every service; a deactivated signer’s refusal is final everywhere. - #214, #215 — the 0.27 messaging line, with the authcrypt sender bound to its key:
affinidi-messaging-didcomm0.15.9 / SDK 0.27.2 / didcomm-service 0.12.1 resolve the sender only from theskidthe envelope names and refuse an envelope whoseapudisagrees (see affinidi-tdk #894);HandlerContext::sender_didis the verified signer or authcrypt sender, never the plaintextfrom. Then trust-tasks 0.23 / messaging-sdk 0.28 (#215). - #212 — stats sync over TSP when the control plane advertises it (the last control↔server exchange hard-coded to DIDComm), and a stats sync must report under its own DID — a foreign
server_didcould advance another server’s replay sequence and have its genuine deltas skipped as stale. - #208 — a signed DIDComm sign-in is bound to its recipient: a JWS proves who wrote it, not who it was for, and
POST /api/auth/on all three services never checkedto— so a service the holder signed in to could relay the message and be issued a session as the holder (the relay of VTI #1638).require_addressed_tonow refuses, before the session is touched, a sign-in that does not name exactly this service’s DID. #210: the console’s session signing key is generated non-extractable. - #205 — an active admin stays signed in: all three login paths discarded the refresh token they were handed, and the refresh endpoint accepted only a DIDComm-JWS envelope the console does not have; a plain Trust Task dialect byte-identical to the VTC’s, single-flight renewal, and
auth.admin_idle_timeout(default 900 s) so a tab cannot stay signed in forever. #206: a stale own-DID fails setup instead of warning. #202–#204, #211: an inbound Trust Task error is terminal and says what it said; dependency refreshes along the vta-sdk 0.43 → 0.50 / messaging-sdk 0.26.10 → 0.26.27 line, picking up the TSP relationship fixes (two endpoints re-establishing at once; a reply-registration race; a send retried when its connection closes under it).
The August–September 2026 cycle (33 commits, PRs #169–#200) shipped no version bump at all — every crate is still at its Cypress number — yet changed a great deal underneath. Its shape is the ecosystem’s: a quiet VTI-Dogwood (2026-08-30, tag-only; VTI-Dogwood-R1 points at the same commit here, #178, unlike the TDK where R1 is a re-cut) that mostly absorbed the trust-tasks-rs / vta-sdk ladder, added did:webs hosting, and finished the migration of authentication onto Trust Tasks; then a run-up to VTI-Eucalyptus-RC-0 (09-17, #194) that fixed a control-plane lockout, unified the transport router, and moved to TSP Rev 3 with persisted relationships; and, after the RC was cut, a six-PR security wave (SEC-4045 R2/W6, #195–#200) closing IDOR, quota, replay, SSRF and authorization gaps. At the time, the legacy /api/acl REST surface was still served with Deprecation/Sunset: Mon, 01 Dec 2026 headers — and gained a last-authority guard in #195 — so its removal looked scheduled, not done; ten days later 238 removed it outright, along with every other REST management route (see above). Nothing in this repository touched Kubernetes, Vault or VTA-Farm deployment in the window; that work lives in the VTI side (vtafarm, vtafarm-k8s).
Post-RC security wave — SEC-4045 R2 / W6 — 2026-09-17/18 (#195–#200)
Why. A structured security review across the VTI estate (the same SEC-4045 series that produced the TDK’s affinidi-net-guard and the mediator’s TSP authorization fixes) reached did-hosting after the RC was cut. The findings are the classic multi-tenant-service set, and each fix names its CWE.
- Control plane (#195): per-DID stats/timeseries scoped to the owner and
?domain=aggregates to the caller’sDomainScope(W2, IDOR);max_did_count/max_total_sizeper account actually enforced on create/register/publish (W3); the replay cache now gates the TSP and HTTPS trust-task transports too, not just DIDComm (W4); registry URL validation default-denies non-routable/metadata literals via net-guard’sis_globally_routableeven with an empty allowlist (W6); last-authority guard on the legacyPUT /api/acl/{did}(W5);DomainScopere-checked at time of use on delete/change-owner/disable/rollback (W7). Edge: witness uploads count against quota (W8); the did.jsonl proof chain is verified on the edge publish path, parity with control (W9); server-wide stats need Admin (W14). - Admin proxy fails closed (#196/#197): with an empty registry allowlist the proxy would forward an Admin’s bearer token to any public registered URL — so a Service-role DID could register an attacker host and harvest it. It now refuses on an empty allowlist and otherwise requires the host to be listed; documented as the fail-closed behavior operators must configure around.
- AuthZ parity (#198): edge and witness gain the last-remaining-admin demotion guard the control plane got in W5;
GET /api/config(backend topology, DIDs, data dir) and server-wide stats/timeseries become admin-only; the UI hides those panels for non-admins. - Robustness (#199): all outbound clients (watcher, witness, control, webvh) share a hardened builder — request + connect timeouts, redirects refused — so a hung peer cannot pin a task and a 3xx cannot bounce a bearer token;
MAX_LOG_ENTRIEScap before per-entry proof verification; proxy response body capped at 10 MB;list_didspage limit anddid_deltasper stats payload capped; the watcher verifies the proof chain on sync push, not just structure. - Freshness (#200):
unpack_signedonly enforced the 5-minute window whencreated_timewas present, and the replay cache TTL equals that window — so a signed DIDComm envelope with nocreated_timenever went stale and could be replayed to re-trigger a state-changing DID op after its cache entry aged out.created_time(inside the JWS-signed payload) is now required; every first-party sender, including the VTA’s webvh client, already sets it.
Eucalyptus-RC-0 run-up — TSP Rev 3, one router, no more lockouts — 2026-09-08 → 09-17 (#179–#194)
- TSP Rev 3 (#191–#194;
affinidi-tsp0.1 → 0.2, TDK 0.14 → 0.16, didcomm-service 0.8 → 0.11, vta-sdk 0.38 → 0.41, trust-tasks 0.20 → 0.21). No workspace source change was needed for the wire break — “this service speaks trust-tasks documents, not TSP envelope internals” — but Rev 3’s §7.2.2 gating exposed two application-level gaps. A receiver drops any application frame from a VID it holds no relationship with, and completing the handshake is the application’s job: neither TSP listener did it, so an ephemeral edge that lost its relationship half logged “no relationship … discarded” and dropped the control plane’s sync/health pushes indefinitely. #193 adds the answering arm (answer_inbound_control, shared byWebvhTspHandlerandServerTspHandler; invites accepted from any framework-authenticated sender, with authorization left to the task layer because §3.6 lets a peer pack its first application message with the invite). #194 adds persistence + re-invite: a backend-agnosticRelationshipKvover anyStorageBackend(newKS_TSP_RELATIONSHIPSkeyspace) injected into both listeners, and the edge callingtsp_ensure_relationshipwith its control plane on every (re)connect. Mirrors the VTA/VTC reference (VTI #1525) so the fleet answers consistently. Seedocs/tsp-transport.md. - One router for every transport (#183): the HTTPS
/api/trust-tasksroute re-implemented dispatch and asked only one of the five questions the shared router asks — so the typed DID-management family, the auth family and the infra family all fell through to the legacyMSG_*bridge and came back “unknown op” (an agent-name update over HTTPS being the concrete casualty). Root cause: the router flattened everything toOption<Value>, losing the framework reject code HTTPS needs for its status line. ARoutedReplyenum (Framework/Document/Suppressed) fixes the cause; the HTTPS route no longer builds its own dispatcher. - Rate-limit refusals are 429s that say who refused (#189): every limiter (
auth-challenge-per-ip,-pending-per-did,-pending-global) answered400, indistinguishable from a malformed request. Now429+x-rate-limit-source: did-host+Retry-After+ a JSON body — the contract the VTA, VTC and mediator emit andvta_sdk::rate_limitreads;did-hosting-clientgainsClientError::RateLimited. - Abandoned auth challenges no longer lock out the control plane (#190, SEC-4045 W1): the pending-challenge tracker released a slot only on successful authentication, so ten abandoned challenges locked a DID out of
/api/auth/challengefor good and ~10,000 across any DIDs locked out every login — an unauthenticated DoS whoseRetry-Afterpromised a retry that could never succeed. Challenges now expire withchallenge_ttl, the tracker is seeded from the session store at boot, andauth/challenge/0.1over DIDComm/TSP/HTTPS is held to the same caps (it had bypassed them). Caps are now operator-configurable. - Dependency ladder (#181, #182, #185): trust-tasks 0.17 → 0.18 → 0.19 (held there deliberately until VTI published a vta-sdk on ^0.20) → 0.20; vta-sdk 0.32 → 0.34 → 0.35 → 0.38; Node 20 → 24.3 (vitest 5).
--lockedeverywhere (#187), becauseaws-smithy-types1.7.0 brokeaws-smithy-jsonin a minor release andcargo installignoresCargo.lockby default (#186, #188); patched rustls for RUSTSEC-2026-0285 (#184). Fuzz and upstream-drift CI pinned and egress-blocked (#179, SEC-3896/97/99).
Dogwood — did:webs hosting, auth as Trust Tasks, wallet-chosen identity — 2026-08-24 → 08-30 (#169–#178)
did:webshosting (#169,docs/did-webs-hosting.md): registration and publish on the control plane, control→edge sync, and resolution of both artifacts, behindmethod-webs(off by default on every binary — it pulls the KERI stack). Verification is the point:affinidi-did-webs(without itscreatefeature — the service never holds KERI keys) checks every event’s SAID, the digest chain, signatures, pre-rotation, delegation seals, witness receipts and the designated-aliases attestationalsoKnownAsderives from; two rules are the service’s own — a stream may only be published to the slot whose final segment is the AID it establishes, and an update may not rewind or fork the hosted log. Two deliberate departures frommulti-method-hosting-spec.md: the CESR stream is stored anddid.jsonis derived on every read (a stored copy “could only ever be right or stale — and stale here means serving a document from before a key rotation”), and a webs slot’s trailing mixed-case AID is exempted from the lowercase path grammar.POST /api/dids/registeracceptsmethod: "webs"with thekeri.cesrstream. Edges verify a synced webs log themselves, unlike webvh (structural only, for the stated reason that an older didwebvh-rs on the edge would reject logs the control plane accepted).- Authentication is a Trust Task on every binding (#171–#175): auth had three shapes — canonical challenge/authenticate over HTTPS, a bespoke
MSG_AUTHENTICATEover DIDComm where the authcrypt sender was the auth, and nothing over TSP.auth/*now has one identity on every wire, and the strict rule is chosen: the document proof establishes the signer, so the challenge is checked on every binding rather than being decorative on two of three. The legacyaffinidi.com/webvh/1.0/authenticateURI was retired from REST (#172) — the dual-accept had already cost a silent browser-wallet login failure — anddid-hosting-client’s refresh, which had been typed as an authenticate-response that no acceptor ever matched, works for the first time (#173). The witness’s DIDCommauthenticatemoved to the canonical URI (#174) and then its whole DIDComm protocol mirror (witness/proof-request,witness/list-request) was removed as implemented-but-unreached (#175): the witness is a REST API; the DIDComm listener stays only for mediator presence and identity-rotation draining. - Wallet chooses the identity (#177/#178, needs vta-browser-plugin #145): proxy sign-in used to enumerate every vault entry pinned to the RP — a disclosure of the operator’s vault, with its own consent prompt, to find one entry.
walletProfile({ target })resolves the entry bound to this origin (or asks once and remembers) and returns only the persona DID; the flow visualisation is unchanged. - trust-tasks 0.9 → 0.17, TDK 0.8 → 0.10, vta-sdk 0.25 → 0.31/0.32, didcomm-service 0.3 → 0.5 (#170, #176): generated payload types are
#[non_exhaustive](builders everywhere) andconsume_inboundtakes a requiredConsumeChecks. The service passesConsumeChecks::not_consequential(), which the CHANGELOG flags as not the right long-term answer foracl/grant/acl/revoke/acl/change-role— a mediator redelivery of one grant would execute twice; a realReplayGuardneeds storage and a decision about which processes share a VID. (The post-RC W4 fix above adds a replay-cache gate on the TSP/HTTPS transports; whether that fully retires this caveat is not stated.)firestore0.53 forced the store backend onto the fluent builders.
After hardening cross-service trust paths in v0.6.0 and pivoting to multi-domain / multi-method in v0.7.0, the July–August 2026 cycle (101 commits, PRs #59–#167) shipped 0.8.0 — “transport as a first-class negotiable property of every node” — then converged the service hard on the ToIP Trust Tasks registry as its wire authority, added agent names as a new product surface, and landed in the coordinated Cypress release (coordinated-releases). Note: the wiki previously reported that 0.8.0 would drop the legacy /api/acl REST surface; it did not — it is still served with Deprecation/Sunset: 2026-12-01 headers pointing at /api/trust-tasks.
Cypress snapshot + Trust-Tasks 0.9 — 2026-08-17 (#167)
The Cypress tag (2026-08-17) sits on #167, which moved the workspace to trust-tasks 0.9 / vta-sdk 0.25 (framework errors now trust-task-error/0.5; payload policy chosen once in run_pipeline). Snapshot versions: server/daemon/watcher/witness 0.8.3, control 0.8.8, common 0.8.6, client 0.1.2, UI 1.1.0. Release candidates VTI-Cypress-RC-0 (#152, 07-30) and VTI-Cypress-RC-1 (#159, 08-10) preceded it. Much of the post-0.8.3 work sits under “Unreleased” in the CHANGELOG — the tag is the cross-project reference point, not a CHANGELOG release.
Security/interop fixes in the run-up: unrouted DIDComm messages now get an e.p.msg.unsupported-task problem-report instead of silence (#152, control 0.8.7); the DIDComm trust-task envelope is gated against replay like the bare path (#155, control 0.8.8); payloadDigest emitted as digestMultibase rather than hex, coordinated with VTI #911 (#159); UI recognises rejections again (#160); one framework_error_type_uri() so unrouted paths can’t emit a different error version (#162). Dependency ladder: trust-tasks 0.2 → 0.4 (#158) → 0.6 (#164) → 0.9 (#167); vta-sdk 0.20 → 0.23 → 0.24 + vti-common 0.12 → 0.25; didwebvh-rs 0.6 (#136).
Canonicalization onto the Trust Tasks registry — 0.8.3 — 2026-07-28 → 07-29
A clean cutover (no dual-accept — pre-production policy) to the consolidated, registry-published task URIs: did-management/agent-name/update/0.1 {state: active|parked} replaces set/enable/disable; did/publish/0.1 retired in favor of did/register; REST Trust-Task headers moved from the service’s own did-hosting/*/1.0 to canonical spec/did-management/* plus new did/set-state / domain/set-state (#144). The VTI side responded the next day (VTI #879 “stop sending Trust-Task URIs did-hosting retired in 0.8.3”). The old confirm/* pair became task-consent/{request,decision}/0.1 with a mandatory verified Data Integrity proof and payloadDigest binding (#145, control 0.8.4); the request leg of task-consent and step-up approve-request is now signed too (eddsa-jcs-2022, via trust-tasks-proof 0.2.2; #149–#151). Also: UI honors retryable/retryAfter (#140), clock-skew-tolerant proofs (#138/#139), idempotent acl/grant retry (#142). Direction signal: the service keeps its own URIs only where the registry has no spec; everything else follows the registry.
Agent names — /@name — 2026-07-21 → 07-23 (#103–#137)
The big new feature of the cycle, and the hosting-side half of the ecosystem’s agent names story (see decentralized-identifiers and openvtc): a human-memorable handle like example.com/@alice that resolves to a DID. The server answers /@name with a 302 to the DID, derived at the edge from the signed DID log’s alsoKnownAs; the control plane keeps the registry — including parked names — and is the source of record: publish reconciles the registry (#110) and enforces reserved/taken preconditions so a name can’t be hijacked via publish (#113). Names are managed as Trust Tasks over DIDComm/TSP (#105, #129) and REST (#106; owner-auth, not step-up, on remove/disable #108), surfaced in UI cards for manage/park/resume (#107, #109) with DIDs resolved to names across the UI (#122, #130), advertised on /api/server-info (#114), exposed in did-hosting-client (#115), default name = the DID’s path segment (#119), and accepted wherever an ACL takes a DID — resolved once at write time (#134). The community form {domain}/@ is bound structurally to .well-known only (#127, #137). A decision was recorded not to advertise the control plane in DID documents (#117).
0.8.0 → 0.8.2 — 2026-07-15 — transport as a negotiable property
0.8.0 (#89) bundled the TSP work below plus: the DID document is authoritative for send transport — resolve_send_binding precedence document → config → fail, no blind-DIDComm default (#86); a standalone server advertises its messaging transports on its own DID (#87); mediator-configured node DIDs omit the WebVHHosting service (#88); identity-rotate-keys for the service’s own key-agreement and signing keys with grace/drain (#82, #84); runtime DID pick-up without restart (#78); edit a DID document through the user’s agent from the UI — the service proposes, the VTA dry-runs and decides whether consent is needed (#77, fixed end-to-end in #79); root-DID .well-known fixes (#72, #80). 0.8.1 added delta-sync on register and self-resolve from the local store (#93); 0.8.2 stopped a metadata-only identity change from rotating (and destroying) key material (#95, safety nets #96). Follow-on hardening: a nightly unlocked-resolve CI canary (#75); the ?domain= cross-tenant check — silently dropped by axum — enforced on publish/delete (#94); batched MSG_SYNC_BATCH (≤50 DIDs / 512 KB) under the mediator rate limit (#97); delegated updates auto-publish on the wallet’s grant event instead of polling (#99–#102).
TSP decoupled from DIDComm — 2026-07-07 → 07-10 (#59–#71)
Follow-through on #58: DID-management ops accepted as trust-task documents over HTTPS too (POST /api/trust-tasks via bridge_did_management, #60; step-up over TSP declared a deliberate non-goal — no session to bind); a typed did-hosting/did/*/1.0 protocol with eight ops — check-name, info, list, delete, publish, register, change-owner, witness-publish — carrying the didLog the upstream record-centric spec had no room for (#61); outbound control→server sync and domain pushes over TSP when the target advertises TSPTransport (#62/#63), closing the “no outbound Trust-Task sender” gap noted for #58; TSP fully decoupled from DIDComm — FEATURES_TSP, Protocols BOTH / TSP_ONLY / DIDCOMM_ONLY, a 3-way TransportSelection wizard/recipe field, TSP→DIDComm send fallback, DID docs advertising only the selected transports; the witness stays DIDComm-only (#64 — later reversed: #175 in August removed the witness’s DIDComm protocol mirror, leaving its REST API as the only proof surface); TSP-only nodes mint the did-host-http-tsp VTA template (#65); DID-document services shown as badges across the controller UI (#67–#69); server registration and health as transport-agnostic trust tasks with a trust_task_capable flag for rolling upgrades (#70); observed control-link transport recorded per service instance (“↓in/↑out”, #71).
TSP transport alongside DIDComm — 2026-07-07 (#58) — “everything is a trust task”
The headline change, and a clean illustration of the architecture: every wire operation in the workspace is a Trust Task — a versioned, JSON, transport-agnostic document — and the dispatch core doesn’t care which transport delivered it. Adding TSP was therefore adding one transport binding, not a new protocol. Three bindings now exist: HTTPS (POST /api/trust-tasks), DIDComm v2 (mediator envelope), and TSP.
- TSP rides the same per-DID mediator websocket as DIDComm (no second socket):
affinidi-messaging-didcomm-serviceunpacks inbound TSP frames, authenticates the sender VID, and routes to a newWebvhTspHandler, which dispatches through the shared core. build_did_documentcan emit a#tspservice of typeTSPTransport, ordered before#vta-didcommto match the VTA templates’ canonical TSP-first order; a newresolve_transporthelper prefers a peer’sTSPTransportand falls back toDIDCommMessaging.- All DID-management ops (check-name, publish, register, delete, change-owner, info, list, witness/publish) became reachable as trust-task documents over both TSP and DIDComm via a
bridge_did_managementfacade; legacyMSG_*messages kept for back-compat. - Scope limit at the time: inbound request/response over TSP was fully supported, but proactive outbound push (control→server sync) still used DIDComm — closed days later by 70 (above).
Trust-flow hardening — June–July 2026
- Step-up converges on holder-self-signs (#57): the wallet signs an
auth/step-up/approve-response/0.2Trust Task with a W3C Data Integrity proof (eddsa-jcs-2022) over its session-subject key, and the RP verifies that proof directly — the VTA is no longer a trusted third party for step-up. Includes a cross-language interop test against the JS wallet (@openvtc/pnm-core) proving both eddsa-jcs-2022 implementations canonicalize byte-identically. - A VTA-provisioned daemon now auto-trusts its provisioning VTA to publish DIDs (idempotent Admin ACL seeding at setup, #55); passkey-authenticated ACL writes fixed under trust-tasks-proof 0.2’s stricter issuer rule via a
TransportBoundVerifier(#44); the UI resolves the SIOP RP DID from the control plane at runtime instead of a build-time env var (#51).
Ops/deployment maturity — June 2026
- Two new SecretStore backends ported from VTI’s
vti-secretsdesign: HashiCorp Vault KV v2 (K8s ServiceAccount JWT / static token / AppRole, background token renewal) and native Kubernetes Secrets (#53); priority chain now AWS → GCP → Azure → Vault → K8s → keyring → plaintext, wired into the wizard and the non-interactive TOML setup recipes. - Fuzzing harness (#48): a detached cargo-fuzz crate with four libFuzzer targets, including a structure-aware one built on didwebvh-rs’s new
arbitraryfeature — a coordinated cross-repo fuzzing push (both landed 2026-06-14). Plus an OpenAPI 3.1 spec for the upload/resolve API with a drift-checked committed snapshot. - Fixes: percent-decode the did:webvh host authority before domain validation — previously every port-bearing host like
localhost%3A8534got a 400 (#56); DynamoDB binary-key prefix-scan fix (#43).
Earlier release history:
v0.7.0 — 2026-05-24 — did-hosting-service rename + multi-domain + multi-method + client crate + Trust Tasks ACL
The single largest release since v0.1.0. Bundles three coordinated specs (docs/multi-domain-spec.md, docs/multi-method-hosting-spec.md, docs/did-hosting-client-crate-spec.md) and a 57-task rollout plan into one cutover.
- Repository + binary rename.
webvh-server/webvh-control/webvh-daemonkeep their service-binary names internally, but the public templates and binaries are renamed to capability names (did-host-http,did-host-didcomm,did-host-http-didcomm). Thewebvh-*template names remain aliased for one release. - First-class domain objects. A single deployment hosts DIDs across multiple domains. ACL
DomainScopesemantics; control-plane-driven server assignment with retain-then-purge unassignment lifecycle; transport over Trust Tasks 0.2;trusted_proxy_cidrsfor safeHost/Forwardedhandling; opt-in/.well-known/did-hosting-domain.jsonfor external discovery. - Multi-method.
DidMethodtrait abstracts resolution + lifecycle differences. Bothdid:webvhanddid:webare default-enabled; storage uses a unifiedDidRecordshape; compile-time feature gating per method. did-hosting-clientcompanion crate. Trust-Tasks URLs only; multi-domain + multi-method aware from v0.1. Intended for downstream consumers like the VTI daemon.- Trust Tasks 0.2 ACL. ACL operations move onto Trust Tasks 0.2 with the dual-accept envelope.
Follow-on fixes in the same line: check-name probe/reserve/auto-assign contract (PR #38); accept canonical camelCase log/owner fields on did-management wire (PR #39); set in-band recipient on every trust-task envelope (PR #41); bump vta-sdk to 0.10 (lockstep with vti-common 0.9.1, PR #42); enable did:webvh + did:web by default in daemon; host:port domains resolve consistently end-to-end.
VTA-proxied SIOP login + visualization (M2B.4) — 2026-05-26
End-to-end demo of the VTA-as-credential-manager model — the user logs in with a did-self-issued vault entry pinned to this RP’s DID; the long-term signing key never leaves the VTA, and the page only ever sees a short-lived SIOPv2 id_token. Three round-trips, each timed and visualised:
- Page
POST /auth/challengewith the entry’s principal DID → RP returns a one-shot nonce bound to that DID. - Wallet asks the VTA via
vault/proxy-login/0.1to mint a SIOPid_tokensigned by the entry’s signing key, embedding the RP’s challenge asnonce. Wallet receives aSessionBlobwith theid_tokenin anAuthorization: Bearer …header. - Page
POST /auth/with theid_token→ server resolves the entry’s DID, verifies the signature, checksnonce == challenge, issues a bearer access token.
UI additions: feature-detect Login via VTA proxy button (hidden when the wallet is too old), candidate picker modal when the user has multiple matching entries, visualisation modal with per-step timeline + decoded id_token claims + SessionBlob summary. Cleanly extends the existing /auth/challenge + /auth/ routes in did-hosting-control; no Rust changes needed there.
Post-v0.6.0 — DID ownership management
DID ownership management (REST + DIDComm + UI). did-hosting accepts canonical webvh/* and did-management spec URIs for info / list / change-owner + me/domains; alias bridge dropped in the end-state cutover (PR #28).
v0.6.0 — 2026-05-05 — web-based ACL invites, VTA template, offline bootstrap
- New
webvh-uicrate joins the workspace - All three refresh handlers (control, server, witness) require a JWS-signed DIDComm envelope and bind the signer to the session DID
- Offline-bootstrap latent bug fixed: previously
BTreeMap::iter().next()picked the wrongDidKeyMaterialentry by alphabetical iteration; now matches bydid_document.id - Refresh-token rotation TOCTOU closed end-to-end via a new
KeyspaceOps::take_raw_atomicprimitive (RedisGETDEL/ DynamoDBDeleteItem ALL_OLD/ fjall mutex / per-keyspace mutex on Firestore + Cosmos DB) - Registry / proxy trust chain hardened in
webvh-control:RegistryConfiggains an optionalurl_allowlist; reqwestPolicy::none()blocks third-party redirects; the proxy strips RFC 7230 §6.1 hop-by-hop headers andSet-Cookiefrom upstream responses - Watcher
/api/sync/didbody limited to 4 MiB;validate_did_jsonlrequiresstate.idto start withdid:webvh: - DIDComm authentication closes an auth-bypass on every REST
/api/auth/endpoint (unpack_signedrejects envelopes whosefromfield disagrees with the JWS-verified signer) - Witness
sign_proofis now Admin-only with audit-log emission on every signed proof
v0.5.0 — 2026-04-13 — DIDComm control-plane integration
- DIDComm control-plane integration
- Daemon parity with the split deployment
- Architecture simplified to consume the published
vta-sdkcrate
Cold-start bootstrap, stats overhaul, VTA integration — 2026-03-31 to 2026-04-09
- Stats overhaul (unified collector, time-series tracking, per-DID resolve counts)
- VTA integration with unified startup and local session caching
import-secretsCLI for self-contained environment bootstrap
v0.1.0 — 2026-03-30 — first production release
- DIDComm auth migration
- Security audit fixes
- Passkey enrollment
- Root DID bootstrap
- Setup wizards
See also: did-webvh, didwebvh-rs, verifiable-trust-agent, affinidi-tdk, trust-spanning-protocol, coordinated-releases