All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
-
The vetting wire types are the published, generated ones.
vta-sdk0.37 deleted its hand-written copies of the peer-vetting payloads and re-exports the generatedtrust_tasks_rs::specstypes in their place, so what this client puts on the wire is what the specifications state. Every vetting call site moved with them: the generated structs are#[non_exhaustive]and are built throughX::builder(), constrained strings are newtypes, andcheck_shape()arrives as theCheckShapetrait.The generated types validate what the hand-written ones accepted, and three values this client used to send are now refused where they are built rather than by the community:
- a vetter who accepts no documentation sent
acceptsDocumentation: []. The published response requires at least one entry, so the member is omitted instead — a vetter may still accept nothing, which is what V0 intends for someone they already know. - a ticket code went out as it was typed. The published short code is upper-case Crockford base32, so a typed code is normalised into that form before the request is built, and a code that cannot be is refused with the same sentence as before.
- a statement could list
noneamong its document classes. No document is the empty list, sononeis dropped — and a documentary method left with nothing to rely on is refused rather than attested.
The vocabulary is generated per specification, so
VettingMethodexists five times over (the manifest's, the request's, the session's, the profile's and the listing's) with the same wire tokens and no relation between the Rust types. This client keeps the community's copy in its own state and carries a value across each task boundary by the token both spell. - a vetter who accepts no documentation sent
-
Every dependency resolves from crates.io again. The
[patch.crates-io]block carrying twenty VTI crates and two VGI ones is deleted, and with it bothallow-gitentries indeny.toml:vta-sdk0.37,vta-service0.26,vti-common0.18.4 anddid-git-sign0.4.9 are published. One thing the unwind did not buy:did-git-sign0.4.9 requiresvta-sdk ^0.36against this workspace's 0.37, so the graph still carries a second sdk — and no patch can close it, because 0.4.9 is the commit VGImainis on. It closes when VGI releases on 0.37. -
A
did:webvhDID on a non-public host no longer resolves. Resolving adid:webvhDID fetchesdid.jsonlfrom the host the DID itself names, so until now a DID such asdid:webvh:<scid>:localhost%3A9099— or a public name whose A record points at169.254.169.254— was fetched, on this machine's own network, byopenvtc health, by join and by messaging alike. The DID is enough; no config had to be wrong.affinidi-did-resolver-cache-sdk0.8.37 (didwebvh-rs0.7) now refuseslocalhost,*.localhost,*.local,*.internal,home.arpaand single-label names before any request, refuses a name whose resolved addresses include a loopback, private, carrier-grade-NAT or link-local one, connects only to the addresses it checked, follows no redirect and ignoresHTTP_PROXY/HTTPS_PROXY.did:webhas been refused this way since the probe hardening; this is the other half of it. A refused DID reportsBlockedHost.If your VTA, VTC or mediator DIDs genuinely live on loopback or a private network,
openvtc health --allow-privatenow opts resolution in as well as probing — the two run under one policy, so the report can no longer say the endpoints may be dialled while refusing the documents that name them. Everything else, including every default run, contacts public hosts only.openvtc healthalso still distinguishes "we declined to dial this" from "this host did not answer" when the refusal comes from the probe client's DNS guard rather than from the URL. That guard moved into the sharedaffinidi-net-guardcrate and its refusals now readblocked address <addr> (<class>) for host <host>; they are recognised by type rather than by that wording, so the message is free to change again without a refusal being reported as an unreachable host. -
Release builds no longer take their VTA or mediator from the environment.
OPENVTC_VTA_URL,OPENVTC_VTA_DIDandOPENVTC_MEDIATOR_DIDare honoured only by a build compiled with the newdev-overridesfeature (cargo run -p openvtc --features dev-overrides). Any other build ignores them and says so, on stderr before the TUI starts and in the Activity Log. This covers the setup wizard'sOPENVTC_VTA_URLshortcut too.In a
dev-overridesbuild the values are validated (DIDs must parse, the URL must be http(s)). They apply to the running process only, and a redDEV OVERRIDEbadge stays in the bottom bar while they do. Saves and exports keep writing the stored VTA URL, VTA DID and persona mediator. Before this change, a variable set for one launch was written into the profile by the next save and outlived the environment. CI now also runscargo test -p openvtc --features dev-overrides.
- The wipe-profile screen names the context it tells you to delete. It sent
the operator to
pnm contexts deletewith no id — on the one screen that is about to take the config file and the keyring entry holding that id with it. It now printspnm contexts delete <this account's context>on its own row, with the id positional, aspnm-cli'sContextCommandsdefines it. A nested context keeps its whole<parent>/<id>path; an unloaded account still falls back to a placeholder rather than naming the wrong context in a destructive command.
-
openvtc healthprints the DID this install authenticates to the VTA as. A new VTA access section names the agent, the context, the transport that would be opened (the same mediator-vs-REST rulebuild_runtime_vta_clientbranches on), its mediator or REST endpoint, and — in full, untruncated — thedid:keythis install presents.That last one is the point. The VTA keys its ACL entry on it, so it is the DID an operator has to name in
pnm acl get/pnm acl update, and it was reachable from nowhere but the TUI's VTA panel, truncated to fit — while the identity pane's own refusal hint said "openvtc healthprints the DID". The section prints both commands ready to run, and says why--capabilities persona-holdergrants rather than narrows.It is read from the loaded config, so it answers on the run where the network leg is the broken thing.
--jsoncarries it asvta_access, null for a BIP32 profile so a script need not branch on the backend first.
- The identity pane's grant hint is now a command you can run. It named a
--didflag thatpnm acl updatedoes not have (the DID is positional), and it left the DID itself as<this install's DID>— a placeholder pointing atopenvtc health, which did not print it either. The hint now carries the real DID from the config the pane is already rendering from.
-
Facts that are sensitive by what they are stay masked, and say so. The identity pane resolves each fact's claim type against the persona claim-type registry (
specs/persona/_shared/0.1/claim-types.json) and paints anything carrying a mask style reduced —••••••••••••4242for a card,••••••••••56for a mobile number,a•••@example.comfor an email address,••••••••for a date of birth. A type the registry does not list, and anything underx:, resolves to the conservative default: masked entirely; a new token in a registered family (payment.giftCard) inherits the family rather than the floor.sshows the selected fact and only that one; moving the selection, changing tab or re-reading puts it back.A masked row says masked — s to show, because
••••••••and (no value) are the same shape and one of them is a wrong answer about what the holder holds.This is not a security control and the pane does not claim it is. The value has already been fetched, decrypted by the agent and parked in this process; masking it defends against someone reading the terminal over a shoulder and against a screenshot, and against nothing else. The half of the registry that is not cosmetic is
sensitivity: high— withheld from a listing that did not ask for sensitive values — and that is a read-path control which does not exist:persona/attribute/listtakesincludeValuesand nothing finer. The mask is what makes that missing control visible.The table is a vendored copy: the agent serves no claim-type registry, so
openvtc-core/src/persona/claim_types.rsships one and names the file to re-sync it against.
-
The join flow and communities panel speak the persona vocabulary too. The pane was brought over in #280; this is the copy outside it — "Who should this community know you as?", "Already known to:", and the reuse warning in the sentence the table asks every surface to share: "the same person to anyone who sees both."
profileelsewhere in the TUI is left alone on purpose: setup, backup and--profilemean the config profile, a different thing that predates the persona family and has its own meaning to operators.
-
The identity pane speaks the words a person would use. Following
design-docs/persona-vocabulary.md, which fixes one vocabulary across the console,pnm, the mobile agent and this TUI: an attribute is a fact, a profile is a face — the set of facts you show together — and a persona wears a face in a community. The tabs read Personas · Your facts · Faces · Communities · What has left.The spec's words are exact and are not being replaced in code, on the wire or in the audit log; they are kept off the screen.
persona/attribute/putstayspersona/attribute/put— the form says Add a fact.A test renders every tab, both empty and populated, plus each editor, and fails if any word from the table's avoid-list reaches the screen. Each of them is a word this pane's own code uses, so they are one careless
format!away at all times, and the drift is invisible in review.
-
Setup now asks for the grant the identity pane needs. The
pnmcommand on the setup screen ends--admin-holder, which grants thepersona-holdercapability alongside OpenVTC's context-scoped admin entry.Without it the pane's Attributes, Profiles and Disclosures tabs are refused on every install — correctly. Those sit above every trust context, and OpenVTC's credential is scoped to one, so reaching them used to require making OpenVTC an administrator of every context on the agent. The capability grants authority over your own identity without granting that (verifiable-trust-infrastructure#1286).
On an install that already exists, the pane's refusal now carries the command to fix it rather than only the agent's explanation of why it said no.
-
One pane for your own identity — "My Identity" on the main menu. Everything a holder can do with their persona now lives in one place, with five tabs in the order the concepts build on each other: Personas (the persona DIDs), Attributes (the pool of facts behind them), Profiles (named subsets of that pool), Communities (which persona each community sees and what it presents there) and Disclosures (the read-only record of what has actually left).
Before this, minting a persona DID was a menu item that fired an action, the list of personas was a section of the VTA Service panel, and the three layers above them — the attribute pool, the profiles over it, and the bindings that decide what a community is shown — were reachable only from
pnm persona.Four decisions worth knowing:
- Values are opt-in and cost a round-trip. The attribute list is read
without values;
vre-reads with them. It is not a display flag over data already in memory, because a listing that holds values has read the holder's identity whether or not the panel painted it. - "We could not ask" is never drawn as "you hold nothing." Every agent-served tab renders the failure and its reason instead of an empty list. An unreachable agent and an empty pool are one pixel apart, and only one of them is a confident wrong answer about the holder's own data.
- Destructive questions are asked once, correctly. Deleting an attribute a profile uses, or a profile a persona presents, needs a cascade or an unbind — and the pane knows which from data it already holds, so the first prompt names the real consequence rather than being refused and re-asked.
- The editor authors what it can honestly author. Self-asserted attributes
and live profile references. A credential-backed attribute is shown but not
editable (retyping its value would turn an attested claim into a typed one),
and a profile's pinned, overridden and inline entries are read, carried
through a save untouched, and left to
pnmto change.
- Values are opt-in and cost a round-trip. The attribute list is read
without values;
-
A persona's linkage is on screen. A membership row says when the same persona is shown to other communities — the fact that lets two of them compare notes and find one person behind both, and the one thing a holder cannot work out by looking at a single row.
- The VTA Service panel is about the agent again. Its Context Identities
list, and the
n/g/dverbs that acted on it, moved to the identity pane; a persona DID is the holder's identity, not a property of the agent that hosts its keys. With one list left on the panel,Tabno longer switches focus — it asks for the invitation-credential listing, which is all it ever did besides move the cursor.
-
Saving a profile on Linux corrupted the whole login keyring. Not just the OpenVTC entry — every secret in the collection became unreadable, and the next unlock prompt from any application was really a create a new keyring prompt, which orphaned the original one.
OpenVTC stored its
SecuredConfigenvelope pretty-printed, so the secret handed to the credential store contained real newlines. gnome-keyring writes an item's secret into its.keyringfile verbatim but reads it back throughGKeyFileunescaping, so those newlines came back as extra lines of the file, and the daemon rejected the file entirely:keyring was in an invalid or unrecognized format.Nothing about that is visible on macOS or Windows, whose stores treat a secret as opaque bytes — which is why a formatting choice with no reader survived this long.
The envelope is now written as compact single-line JSON, and the encoder refuses to hand the store a secret containing a line break, a control character, a backslash, or any non-ASCII byte at all: its payloads are BASE64URL, so anything else is our bug, and it now fails the save instead of corrupting a keyring. Existing entries are read back unchanged.
An already-corrupted keyring is orphaned, not destroyed;
docs/secured-configuration-management.mdhas the recovery steps. -
Setup could not link this client to a VTA at all. The last step of the wizard — "Provision integration DID + admin credential" — failed on every fresh install with
unsupportedType, against a VTA that was otherwise healthy and had already authenticated the setup key.The cause was upstream and had nothing to do with the VTA being asked. The
provision/integrationtrust task moved to 0.3 and 0.2 was removed rather than deprecated (the two disagree about the bundle digest and both close their response schema, so no single response satisfies both). The VTA moved; so did the SDK's REST runner. Its TSP runner did not, and its DIDComm runners did not — each held its own version literal, and each had been correct on the day it was written, because the two transports genuinely used to address different versions of this operation.Setup prefers TSP, so setup got the half that was left behind. Worse, the refusal arrives after authentication succeeds, which the SDK classifies as terminal — so it never fell back to the REST leg that would have worked, and the checklist showed a clean run of ticks with a failure on the last line.
Fixed upstream in vta-sdk 0.32.1, which routes every transport through one version constant and guards the client/server pair with a test. This release floors the dependency there.
-
A community could refuse something and this client would say nothing. Every inbound DIDComm problem-report went through the join handler, which correlates the thread id against a pending join request. A report threaded on anything else — a
members/vmcdelivery, say — matched nothing, and was dropped with a warn naming the correlation miss rather than the failure.Paired with a send that reported success on a bare local
Ok, that is how a completely broken reciprocal-VMC exchange stayed invisible for its entire life: the community rejected every delivery, said so, and the UI reported each one as sent.The join handler still declines to interpret a report it cannot correlate — it genuinely does not know what failed — but it now hands the code and comment back instead of swallowing them, and the dispatcher writes them to the community log. "We don't know which request" is not a reason to say nothing.
Two related honesty fixes: issuing a membership credential now says "sent — waiting for the community to acknowledge it" rather than claiming it was received, matching the personhood verbs beside it; and the community's acknowledgement receipt, previously an
info!in a log file, is written to the community log, since it is the only positive evidence the member ever gets that their half of the pair landed. -
A reciprocal membership credential never closed the join request it answered.
vtc/members/vmc/0.1carries an optionalrequestId— the retiredjoin-requests/acceptsemantics — and this client always sentNone, so a community's join request satApprovedindefinitely after the member was admitted and had sent their half back.Not an oversight in the wire body: the id was destroyed before anything could read it.
activatereplacesPending { request_id }withActive, so by the time the caller looked there was nothing to find.handle_credential_issuenow returns aCredentialIssueOutcome(modelled on theStatusOutcomebeside it) reporting the join it closed, captured before activation, and admission sends the reciprocal VMC naming it.Behaviour change: receiving the community's membership credential now auto-issues ours back. That is what admission means — membership is a pair, and the community is holding the request open waiting for our half — and this client already auto-answers
members/request-vmcthe same way. Best-effort: a failure is logged and leaves the join open, recoverable by issuing manually or by the community asking. -
A relationship credential carried no identifier of its own.
new_vrcleavesidunset, so every VRC this client issued went out without one. A credential with no identifier cannot be stored under one — a peer keying relationship credentials byidcannot make a re-issue idempotent, tell a renewal from a duplicate, or reference the VRC from a witness credential'sdigest.Nothing rejects a VRC for it today, which is the point: that is exactly what the reciprocal membership credential looked like right up until a community started keying on it and every delivery began failing silently. VRC issuance now goes through
openvtc_core::vrc::new_identified_vrc, which sets a freshurn:uuid:id — before signing, since a Data Integrity proof covers it and one added afterwards would leave a document whose proof no longer verifies. -
A renamed machine kept announcing its old name.
displayNameis written once, at registration, anddevice/registeris intentionally refused from the second launch on — so nothing could ever change it. Rename the machine, or run the install under a different profile, and its binding went on identifying it by a name that no longer picked it out of the list the name exists for.The heartbeat now carries the current name (vta-sdk 0.32 /
device_heartbeat_named), which the VTA applies only when it differs and only to the binding the caller authenticated as. A drift spotted in the listing is corrected immediately rather than on the next beat: the correction only travels on a heartbeat, so an install opened and closed inside five minutes would otherwise never send one and the stale name would outlive every launch. -
The activity log reported the mediator's scheduled reconnect as a fault, every twelve minutes. The messaging stack reconnects on purpose: the mediator's access token is refreshed at 80% of its ~900 s life and the SDK's websocket transport re-establishes the socket as part of that refresh — one drop per token per listener, back inside a second or two. That arrived as
Listener '…' disconnected (no transport error reported)followed by a reconnect, which made the log's most alarming line the one it printed most often. A real disconnect looked exactly like the routine one, so the line stopped carrying information.A drop is now held for 15 seconds before it is reported. If the listener returns first, the pair becomes one calm line —
Listener '…' reconnected after 2.1s— which states what was observed and not a cause the transport never surfaces. If it does not return, the line still appears and now says the listener is still disconnected, which is the thing worth acting on. Nothing is quietened that shouldn't be: the rapid-cycling warning still fires on the drop itself, before any grace can absorb it, so duelling sockets shout as loudly as before. -
Every launch after the first warned that the account was open somewhere else — naming this machine.
device/registeris deliberately not idempotent: aDeviceBindinghangs off the caller's ACL entry, there is exactly one per DID, and the VTA refuses a second claim withdevice/register:alreadyRegistered. OpenVTC read that refusal as a failure, so from the second launch on it held no device id — and the sibling filter excluded "us" by id alone. With nothing to exclude, this install reported its own binding as another live instance, on every start, with no second process anywhere. The one situation the warning exists for (two installs evicting each other from the mediator's one socket per DID) was the one it stopped being able to tell you about, because the same sentence appeared when nothing was wrong.A row now carries the DID that owns it (
consumerDid), which is exact rather than a guess: one binding per ACL entry means a row naming the DID we authenticate as is us, on every launch. The already-registered refusal is understood as the ordinary case it is (typed 409 over REST, either spelling of the reject code over DIDComm/TSP), and the listing recovers our own device id so a later launch names us the way a first one does. An install that can identify itself by neither id nor DID still reports everything live — a missed warning is the costlier direction. -
Every reciprocal membership credential we sent a community was rejected, and nothing here said so. A community stores a member's VMC keyed by the credential's own top-level
id, and refuses one that has none. Ours never had one:dtg-credentialshad no field for it at all, so a VMC built withnew_vmc()could not carry the W3C VC Data Model's credential identifier — the property was not merely unset, there was nowhere to put it. Every submission therefore failed the community's last check, after the issuer, subject and Data Integrity proof checks had all passed. The member → community half of the membership pair has never once landed, whether sent by hand or auto-issued in answer to amembers/request-vmc.It was silent from both ends. The send reports success as soon as the frame is accepted locally — "Membership credential issued and sent to the community." — which says nothing about whether the community took it. The community's rejection comes back as a problem-report threaded on the delivery, and this client routes every inbound problem-report through the join handler, which correlates against pending join request ids; a VMC's thread matches none, so the report was discarded with a warn that names the correlation miss rather than the failure. Two independent silences over one broken exchange.
dtg-credentials0.3 adds the field (with_id()), and the member VMC now gets a freshurn:uuid:identifier before it is signed — before, necessarily, since the proof covers it and an id spliced in afterwards would leave a document whose proof no longer verifies. Regression tests assert the id is present, fresh per issuance, and inside what the proof covers.The two silences are not fixed here and are worth their own change: a send
Okstill reads as acceptance, and a problem-report on any thread other than a join is still dropped. -
Stopping a listener left its mediator socket open, so the next listener for the same DID fought it forever.
Messaging::remove_listenerdropped the identity's wire and nothing more. That closes nothing:affinidi-messaging-sdkhas noDropforATM— the websocket task holds its own state and keeps reconnecting on its timer — and the identity's outbox drain still held the transport that owns the ATM. So the socket stayed up, holding the mediator's one-socket-per-DID slot, invisible tohas_listener. Anything that re-added that DID (a community going inactive and then being re-joined, a mediator change, the supervisor's own rebuild — which leaked another socket per attempt) opened a second one, and the mediator evicted each in favour of the other indefinitely. It read as a network fault:Listener '…' disconnected (no transport error reported)every 30-60 s,cycling rapidlywarnings, and a restart as the only cure.Removal now aborts that listener's drain and closes its websocket before it returns, then finishes the ATM's teardown detached so the state-handler loop never parks on it;
shutdownawaits the same teardown, because a caller that rebuilds the runtime would otherwise race its own orphans. Installing over an existing id — which no caller should do — tears the displaced wire down and warns rather than dropping it silently. Covered by a mediator-backed regression test that remove/re-adds one DID and fails if the new listener is evicted. -
The admin credential was doing two irreconcilable jobs.
ProtectedConfig's encryption key wasHKDF(admin_credential_private_key, …), which made one value both an authorisation grant designed to rotate (acl/swap-key) and be re-issued to a recovering install, and a data-at-rest key that must never change. Rotating the credential would have made the on-disk config undecryptable, and a recovered install necessarily holds a different credential — so recovery and existing local state were mutually exclusive. The profile now carries its own random 32-byte key inSecuredConfig, beside the credential bundle and under the same passphrase or token.Migration is transparent and crash-safe. Load tries three keys, newest first: the stored key, the pre-D12 derivation, and the pre-0.1.4 BIP32 seed. Anything but the first flags a re-key, completed on the next save. Keeping the older keys readable is what makes an interrupted re-key survivable —
Config::savewrites the secured blob before the public one, so a crash between them leaves the new key stored and the old blob on disk, and the user must still get in. -
Config records silently dropped fields written by newer builds. Neither
Account,PersonaRecord,CommunityRecordnorProtectedConfigcarrieddeny_unknown_fieldsor a catch-all, so serde discarded anything it did not recognise. Harmless while one writer owns the config — which is why it never bit — but a round trip through an older build strips a newer one's work, and that becomes real the moment a record is shared between two installs. Each now carries a#[serde(flatten)] extrathat preserves unknown fields verbatim.CommunityRecord's had to go on its deserialization shadow, which parses first; putting it on the record itself compiles and drops everything. -
One half-written persona took down the entire profile. A profile is saved in two non-atomic writes — the config file (carrying the account, and with it the persona records) and the
SecuredConfigblob in the OS credential store (carryingkey_info). A crash between them left a persona recorded with no key material, and key rehydration returnedErron the first verification method it could not find. That error propagated out of the persona loop and failed the whole load: every other persona, every community, every relationship, gone — reported as a verification method id, which tells the user nothing. Now a fault is isolated to the persona that has it. Everything loadable loads, what could not is collected into aLoadIntegrityreport, and the startup screen shows it and requires an explicit acknowledgement before continuing. The report distinguishes an interrupted write (real loss — say so) from an unreachable VTA (transient — say that instead), names what still works, and states plainly that nothing has been deleted: a degraded persona keeps its record and is skipped, never dropped, so an already-damaged profile is not quietly made worse. A membership whose persona did not load is reported and cannot be selected as the working context, which would otherwise be a silent dead end. -
Config::savewrote the config file before the secrets, manufacturing the above. Both orders lose the persona that was mid-creation — that is unavoidable without a transaction across two stores — but only file-first leaves damage behind: an account referencing keys that were never written. Secrets-first leaves the interrupted persona simply absent, with leftover keys reported as orphaned key records, and the load is clean. -
OpenVTC silently chose where to keep your keys, and on Linux chose badly.
linux-keyutils-keyring-storedocuments itself as RAM-only — "completely in-memory and will not persist across reboots. Consider the keyring a secure cache" — and OpenVTC registered it unconditionally on Linux as the sole home of a profile's BIP32 seed / VTA credential bundle. A reboot, a logout, or three days of the persistent keyring's default expiry destroyed the account, and it surfaced asConfig Error: Couldn't find openvtc secured configuration. Reason: No matching credential found— which reads like a corrupt install rather than the expected data loss it was.Two changes, and the second matters more than the first.
Durable, and the same as every other tool. Store registration now delegates to
vta_sdk::keyring_init::install_default_store— the same callpnm-climakes — soopenvtc,pnmand anything else on the SDK put secrets in the same place on the same OS: Apple Keychain, Windows Credential Manager, or DBus Secret Service. OpenVTC was the only tool in the workspace using the kernel keyring. Every store it will now select is durable.Fail closed. If the credential store cannot be opened, OpenVTC exits with an explanation instead of quietly writing the keys somewhere weaker. A tool that silently downgrades its own storage teaches users the secure backend is optional, and the moment it matters they discover their secrets were somewhere they never agreed to. Headless machines choose durable file storage deliberately with
OPENVTC_SECURE_STORE=file— a new encrypted-file store under~/.config/openvtc/secrets/at mode0600that refuses to hold an unencrypted profile, so key material is never written to disk in the clear.keyutilsremains selectable but is deprecated, warns loudly on every launch, and exists only so a profile written by an older build can be started once and exported. -
Every startup failure printed the same advice, including the ones with no network in them. The loading screen's single hint — "Check your network and that your VTA/mediator are reachable" — was shown for a missing credential, a locked keychain, a wrong passphrase and a corrupt blob alike, pointing the user away from the machine the problem was on. This is what the stack development guide's R6.4 forbids.
OpenVTCErrorgained a typedSecureStore { fault, .. }variant (missing / unavailable / ambiguous / corrupt / rejected) so the cases stay distinguishable, and a newdiagnosticsmodule turns each into its own report: what failed, what it means, the state of the profile's config file and credential store, commands to confirm it, and remedies in order — with restore-a-backup always ahead of the destructive reset, and no reset offered at all for a locked store or a wrong passphrase, where the keys are intact. The report is scrollable, is written to~/.config/openvtc/last-startup-failure.txtfor bug reports, and the rotating "your keys never leave your device" tip no longer appears under a fatal error.
-
openvtc healthnow reports local storage first. Which credential store is in use, where it keeps things, whether this profile's credential is actually there, and how long it will survive — plus the command to check it yourself. It runs when no account can be loaded at all, which used to abort with "nothing to check" and is exactly the run where the answer is wanted.--jsongains alocalobject alongside the existing keys. -
An explicit
[Ctrl+V]"paste an invitation" action on the join entry page. Bracketed paste worked the whole time, but nothing on screen said so — which is why the issue behind it was filed as "I cannot find anywhere to import a VIC". The key is named in every invitation state, and the terminal's own paste still works and remains the path that survives SSH, where reading the OS clipboard cannot. (vti-setup#29)
-
Thirteen setup-wizard pages that had become unreachable. R-A-5 moved persona minting out of setup and into the State-B join flow, which drives it from
JoinProgressrather than through wizard pages. The pages it left behind — mediator choice, display name, webvh address and webvh-server selection, DID key display and PGP export, and did-git-sign install — had no inbound navigation from any reachable page, and had sat that way long enough to start reading as live code. Tracing fromStartAskconfirmed the whole subgraph was orphaned:MediatorAskandWebvhServerSelecthad no callers at all, and everything else hung off one of those two.Gone with them:
Action::SetDIDKeys(never sent by anything),VtaCreateKeys,ExportDIDKeys,DidGitSignInstall,WebvhServerCreateDid,SetCustomMediator,SetUsername,CreateWebVHDID,ResetWebVHDID,ResolveWebVHDID; sixteenSetupEventvariants;SetupState.did_git_sign,.did_keys_export,.webvh_serverandvta.use_webvh_server/.webvh_servers; and the webvh-server probe provisioning ran to fill a list nothing read.SetupStatekeeps the fields the deleted pages wrote —did_keys,webvh_address,custom_mediator,username— because the join flow and the standalone persona mint now fill the same struct themselves before callingConfig::mint_persona_into. Roughly 3,800 lines. (vti-setup#31)Note for anyone who wanted the git-signing setup: installing did-git-sign was only ever offered by one of these pages, so it has been unreachable since R-A-5 regardless. The main page still detects an existing did-git-sign config; re-offering the install needs a new entry point.
-
The runtime loop's action handling is now callable by a test. It was a ~1,600-line function with a 900-line
matchat its centre and no seam a test could reach, which is why every fix in this release was verified by reading the code, by a live log, or by a unit test on some piece extracted from it. The actions move toruntime_actions::handle_action, taking anActionCtxof everything they act on; the loop keeps only the three arms that are about the loop itself. No behaviour changes — what changes is that a test can now press a key and assert what happened, including that an action dispatched at all, which is the property three surfaces were found to be silently violating. -
Requesting a credential from a peer, and creating a persona, no longer freeze the application. These were the last two network actions still awaited on the state-handler thread. Creating a persona is half a dozen VTA round-trips, so it was the longest freeze left in the app — and the one an operator is most likely to hit, since it is how an account gets its first identity. Both now run off the loop, and the persona overlay still shows each step as it happens: long jobs can report progress without blocking anything. A persona is only shown as created once it has actually been written to the config, rather than when the VTA finished minting it.
-
Leaving a community, or issuing it your membership credential, no longer freezes the application. Both send to the community and were awaited on the state-handler thread — and an unreachable community is a likely reason to leave one, which is exactly when the send retries longest. Both now run off the loop. A successful leave still marks the membership Left and tears down its messaging session; the record moves on the send rather than on a receipt, because the community's acknowledgement is advisory and a member who has announced a departure should not still be shown as a member if it never answers.
-
Opening the capabilities view, refreshing it, or committing a toggle no longer freezes the application. Each of those sends a governance document to the community and waits for the send — which retries against an unreachable peer — on the one thread that also services the inbound channel the reply will arrive on. Nothing could be received while a send was retrying, including the reply being waited for. The sends now run off the loop, and a reply armed for a community you have since navigated away from is dropped rather than matched against the wrong view.
-
Importing or archiving an invitation credential no longer freezes the application. Every vault verb — import, archive, unarchive, restore, soft-delete, purge — ran its round-trip on the state-handler thread, and each was followed by a re-read of the listing, so a single keypress could park every other part of the app for two round-trips. The work now runs off the loop. Validation of a pasted credential deliberately stays on it: rejecting a bad paste is local and instant, and it is what keeps you on the input field with the reason rather than dropping you into a failed state.
-
Managing agent names no longer freezes the application. The overlay's five verbs are Trust Tasks with a 60-second timeout each, and a mutation is two of them — the change, then the authoritative re-read of the registry. They ran on the state-handler thread, which is the only thread that services anything: for up to two minutes no inbound DIDComm was processed, no listener lifecycle applied, and no key read, including
q. The overlay locks its own input while it works, so the freeze looked local to the overlay; it was not. The work now runs off the loop, and a result that arrives after the overlay has been closed or switched to another persona is dropped rather than written over it — though a name it verified is still cached, because that is a fact about the DID regardless of what is on screen. -
Neither event loop can silently drop an action again. OpenVTC runs two action loops — the runtime one, and a smaller one for an account with no community — and each matched a hand-written list of actions ending in a silent catch-all. That is how three surfaces came to be missing from the second one, each presenting as a key that did nothing. Both matches are now exhaustive with no catch-all, so adding an
Actionvariant fails to compile until someone decides what each loop does with it. Verified by adding a probe variant: twoE0004errors, one per loop. Actions that are genuinely unavailable before you join a community stay inert, but say so on screen rather than doing nothing.
-
Removing a persona, and changing settings, work before you join a community. Two more casualties of the same gap:
yon the remove-identity confirm was dropped, leaving the prompt on screen with nothing behind it — which reads as a hang — and the entire settings surface was inert, so a State-A account could not change its own protection, logging or mediator. Settings need no messaging and no VTA session at all; they were simply never wired into that loop. -
Agent names can be managed on an account's first persona. With no communities yet, OpenVTC runs a second, smaller event loop (State A), and that loop had no arm for the agent-name verbs — so
gon a freshly minted persona was dropped into a catch-all and nothing happened, on the very screen that had just reported "Created persona DID …". State A is where an account's first persona is created, so it is also the first place anyone wants to name one. Restarting made it work, because an account that already has a persona starts in the full runtime loop instead — which is why this went unnoticed.The catch-all that swallowed them now says so on screen, rather than leaving a key that does nothing and no way to tell an unimplemented action from a broken one. What remains behind it genuinely needs a community — the inbox, relationships, credential exchange and the community verbs — and now says that instead of nothing.
-
The main page appears as soon as you press Enter on the loading screen. Startup brought every DIDComm listener up — a mediator authentication handshake and a websocket connect each, one after another — on the state-handler thread, which is the only thread that services UI actions. The loading screen had already been told to offer "Press Enter to continue", so the keypress sat unread in the action channel until the last socket was up, and the main page then arrived in one late jump. It reads as a freeze, and it got worse with every extra identity on the account. The connects now happen off that thread, which is what the code around them already claimed ("Phase 2 … runs ASYNCHRONOUSLY: we do NOT block the UI waiting for the listener").
-
A listener's messaging profile now says which identity is speaking, not just which community. The label named the community only, so two personas in one community produced byte-identical labels, a persona in several communities took whichever membership happened to be found first, and a persona with no community at all was labelled the literal string
"Persona". All three showed up in live logs. Because this label names the messaging profile, it is what the transport puts in everywebsocket_run{profile=…}span and what a mediator-side log has to be correlated against — so one listener reconnecting and several reconnecting independently looked the same. Each label now carries the persona's own DID alongside the community, truncated the way the relationship labels already truncate theirs. -
The VTA Service panel stops feeling frozen. Tab into the Invitation Credentials list ran a credential-vault query before the focus change, and the state handler services actions one at a time — so a two-line in-memory focus move waited on a network round-trip with a 30-second timeout, and nothing on screen explained the pause. The listing now runs off the loop: focus moves immediately, the panel says a query is in flight, and "No invitation credentials" is only claimed once the vault has actually answered. A refresh asked for while one is running is deferred rather than dropped, so a just-archived credential is never left rendered as active.
-
gopens the agent-name manager whichever list is focused. It was bound only to the Context Identities list, so pressing it after a Tab produced nothing at all — no action, no message — which is indistinguishable from a dead keyboard and had people restarting the app. Two more silent no-ops behind the same report are fixed with it: a persona selection left pointing past the end of a rebuilt list disabled every key scoped to that list (and restarting "fixed" it only because the selection resets to the first row), and both lists drew a "◀ focus" marker from their own focus alone — so a panel the keyboard could not reach still claimed focus and advertised keys that were being discarded. It now points at the key that gets you there. -
A claimed agent name is no longer reported as no name at all. When the claim succeeded but the read-back that follows it failed, the overlay rendered "No agent names yet." directly above "Applied, but could not reload the list" — the prominent half saying the opposite of what had happened, for a name the DID document already carried. A registry that could not be read is now shown as unknown rather than empty.
-
Listener log lines identify the listener, not just its name. A name is not an identity: aliases and verified agent names are many-to-one, and a relationship R-DID resolves through to its peer's name — so two different listeners could produce byte-identical activity-log lines. Once names resolved a few minutes after launch, a log that had been readable as distinct listeners collapsed into one repeated name, and "one listener reconnecting in a loop" became indistinguishable from "several reconnecting on their own schedules". Every line that names a listener now carries the listener id it resolved from — abbreviated beside the name, in full behind
Enter— because the id is what correlates with the mediator's own logs. -
A disconnect with no transport error says so. It rendered as a bare "disconnected", identical to a drop whose cause was simply not captured, so a socket that closed cleanly and one that was lost for unknown reasons read the same.
-
A pasted invitation now fills in the community it is for, and Enter says something either way. On the join entry page a pasted VIC was routed to the invitation slot and nothing else: the DID input stayed empty, so Enter — which swallowed the keypress on an empty field, with no message — did nothing at all. The credential in hand names its community (a VIC's issuer is the VTC), so the operator was being asked to go and find a DID they already had, and got a screen that looked frozen when they didn't.
A paste now records the issuer and prefills the input with it, and the loaded invitation says which community it came from. Prefilled, not auto-submitted: an invitation arrives from someone else, so the community stays visible and editable before Enter commits to joining it. Anything typed by hand wins over the credential, and a redraw never overwrites an edit. Enter on an empty field now reports why nothing happened. (vti-setup#29)
-
The invitation prompt is legible, and sits where it is needed. It was dark grey italic — the dimmest text on the page — below the input and below the brighter examples block, and read as decoration; the reporter could not find anywhere to import a VIC at all. The invitation status and any error now sit directly above the input in full-contrast colours, and all of it wraps to the terminal width rather than clipping its tail. (vti-setup#29)
-
The setup wizard no longer advertises a step it will never run. R-A-5 ended setup at profile security — a persona is minted later by the join flow — but the breadcrumb still listed a fourth "Digital Identity" step (or "Display Name" on the webvh-server path) that could never become active, so the wizard appeared to skip a step on the way to Setup Complete. Setup is now shown as the four steps it actually has. (vti-setup#31)
-
Take
affinidi-messaging-delivery0.1.14, which stops the layer acking a message no consumer received. An ack is a delete at the mediator, and the dispatcher acked unconditionally — including when its subscriber broadcast had just reported that nobody was listening. Those messages were destroyed: no subscriber is installed for a moment at startup and again on the way down, and what arrives in that window is whatever the peer happened to send.This is the layer-side half of the same defect as the unbounded event channel below. That change stopped this client discarding messages the mediator had already deleted; this one stops them being deleted before this client is listening at all. Lockfile only — the requirement was already
0.1.12, so nothing but resolution moves. Verified against a live test mediator: all fourdidcomm_transport_e2ecases pass, including the stored-mail pickup path. -
A join whose reply was lost can be reconciled again.
join_status_pollexists to ask a community what became of a join it never answered, but it was gated onrequest_id_confirmed— and the id it needed is the community's, learned from the first correlated reply. A join that never got one held only its own placeholder, which the VTC answersnot foundfor, so the mechanism worked whenever it wasn't needed and failed whenever it was. The other recovery, collecting stored mail, is empty once that mail has been acked and deleted, so both failed together and the record satPendingfor good.A poll now omits the id when we don't hold the community's, which asks "what is my open request?" — resolved from the authenticated applicant (VTI#985). The reply carries the id, and
handle_join_status_responsealready adopts it, so an unconfirmed record repairs itself on the first answer and quotes the real id from then on.request_id_confirmedstill decides what we send; it no longer decides whether we may ask.Requires
vta-sdk0.24. -
Inbound messages are no longer dropped when the event channel fills. The DIDComm event channel was a 256-slot bounded channel whose overflow behaviour was log-and-drop, on the reasoning that a pathological mediator should not grow memory without bound. That missed what a dropped event costs: by the time an event reaches this channel the delivery layer has already acked the message, and an ack is a delete at the mediator. A dropped event was therefore not a deferred message but a permanently destroyed one — carrying membership credentials and join verdicts — with one
warn!line as the only record.Backpressure was never actually on offer. Blocking on a full channel would stall the consumer, the delivery layer's subscriber broadcast would overflow instead, and its
subscribe()stream swallows that asLaggedsilently — strictly worse than the warned drop. The only real choice was where the loss happens, so the channel is now unbounded and the answer is nowhere.pickup_storedkeeps its ack-after-handoff ordering: a message that cannot be taken stays stored at the mediator and is offered again.
-
A persona minted without TSP now says so, at mint time. OpenVTC always requests the
#tspservice —create_did_via_serversetsadd_tsp_service: trueon every one of the three paths that mint a persona — but the VTA drops it unless it has[services] tspenabled with a mediator configured. That refusal was silent, and the resulting persona looks entirely healthy: it resolves, it has a mediator, it messages DIDComm peers fine. It is only unable to reach a TSP-only community, which surfaces much later as a join that goes out and is never answered.Since the service is written at mint time and the document is never revisited, such a persona never recovers on its own — so the warning names the consequence, says the DID cannot gain the service later, and names the VTA setting that fixes it. Emitted as a
warn!at the single mint point (so the log always has it) and surfaced on all three user-facing surfaces: the setup wizard's message list, the join flow's status line, and the persona manager's progress channel. A healthy mint stays silent. -
openvtc healthreports progress as it works. The command is almost entirely network waits — adid:webvhresolution is an HTTPS fetch and each probe is bounded at 10s — so a chain with a few mediators could sit silent for most of a run that took 14 seconds. Each step now announces itself before the wait and reports the time it actually took, which makes the slow step visible while it is slow rather than inferable afterwards (it isn't: the finished report has no timings). Progress goes to stderr and the report to stdout, soopenvtc health --json > report.jsonstill pipes cleanly while showing the operator what is being waited on.
-
openvtc healthno longer probes#filesand#whois. Both are served by the DID host the report just fetcheddid.jsonlfrom, so resolving the DID had already proven that host answers — and#filespoints at the directory rather than the document, so the probe reported a 404 for a path that never serves a bare GET. Four such lines per party buried the transport probes that carry information. Implemented as a skip-list of the two document-adjacent service types rather than an allow-list of known transports, so a transport type this build has never heard of is still probed. -
A probe's verdict now agrees with its status code. "reachable (HTTP 404)" read as a contradiction — the word claimed health, the number denied it, and nothing told the reader which to believe. Statuses are graded: 2xx/3xx is
ok, 4xx isresponding(the normal answer from an endpoint that takes POSTs and websockets rather than GETs), and 5xx isserver error. Only the last is raised as a finding, which is a case the flat "reachable" actively hid: the host is up and the service behind it is failing. -
A DIDComm-only persona against a TSP-only community now says what to do. The advertised sets alone (
we offer [didcomm], they offer [tsp]) do not tell an operator which side to change. A persona minted before the client requested#tspcannot reach a TSP-only community and will never gain the service on its own — the document is written at mint time and not revisited — so the finding now says that, and that re-minting is the fix. Scoped to that one direction: re-minting our persona does not fix a DIDComm-only community.
- The Communities footer no longer advertises
ctwice. It listed every binding unconditionally, soc: capabilitiesandc: cancelappeared side by side and read as a collision. It never was one —cis capabilities on an Active row and cancel on a Pending one, and the states are mutually exclusive. The footer now shows what the selected row actually accepts, which also removes four silent no-ops it was advertising:l/mon a Pending or Inactive row andx/don an Active one. The two panel-level keys (j,v) stay listed always, including when nothing is selected.
A first-run join could go out and never be answerable. This fixes the state machine that caused it, and adds the command that would have found it in one step instead of five service logs.
-
A first-run join is answerable again. Creating a persona and then joining — the ordinary first-run order — left the client in the State-A degraded loop instead of handing off to the runtime. That loop has no inbound arm, so the community's reply was received by the SDK, acknowledged (and therefore deleted at the mediator), and dropped before any handler saw it. The join stayed
Pendingforever with a clean log on both sides, and no restart could recover it: the mail was gone from the mediator, andjoin-requests/statuspolling is gated on a request id that only arrives in a reply that was never processed.The hand-off was gated on the join having minted the account's first identity. It never was: the degraded loop mints personas too (
CreatePersonaSubmit), andConfig::active_identity()reportsSomefor any persona at all, so the guard was false exactly when a persona had been created first. It is now gated on the join alone. -
A live listener can no longer outlive its consumer. The degraded loop re-checks
list_listeners()each iteration and hands off whenever it holds one, whatever opened it. A socket this loop owns is a mailbox nobody reads, so the backstop is unconditional rather than specific to the join path — the failure mode it prevents is silent, permanent message loss that looks like a community which never answered.
-
openvtc health [--vtc <did>] [--json]— resolve the messaging chain and print the map. For every DID involved (each persona, the VTA, every mediator, each VTC) it resolves the document, prints theservicearray verbatim — including entries of types this build does not recognise, since a party publishing one is indistinguishable from a party publishing nothing when read through the capability matcher alone — and probes any transport URLs. It then runs the same TSP > DIDComm > REST negotiation a real send performs, so the reported transport is the one that would actually be used, and names every party behind each mediator so a split-mediator topology is visible rather than assumed away.Read-only, and deliberately usable while things are broken: it takes no process lock (so it runs against a profile a stuck TUI is holding), and account details are best-effort (
--vtcalone works with no account, and a config that will not decrypt is reported as a finding rather than an abort). Exits non-zero if any DID fails to resolve or any pair shares no transport.
-
trust-tasks-rs0.4 → 0.6,trust-tasks-capability-client0.3 → 0.5, floorvta-sdkat 0.23.3. This is a follow, not a lead: vta-sdk 0.23.3 declarestrust-tasks-rs ^0.6where 0.23.0 declared^0.4, so holding 0.4 stopped matching the stack and started splitting the type — a dependency refresh alone put 0.4.1 and 0.6.5 in the same binary, which is precisely what the pin exists to prevent.cargo tree -dshowing twotrust-tasks-rsrows is the check that it has drifted again. -
Dependency refresh:
affinidi-messaging-sdk0.19.5 → 0.19.7,vta-sdk0.23.0 → 0.23.3,vta-service0.15.0 → 0.15.3,vti-common0.11.39 → 0.11.40, plus transitive updates.
The first tagged release since 0.2.0 (21 May). 0.2.1 was version-bumped and written up but never tagged or published, so everything under it ships here too.
The theme is the join ceremony's asynchronous half: a community's reply now reaches the applicant whether or not it happened to be connected when the reply was sent, and a join left unresolved is reconciled by asking rather than by waiting.
-
Ask a community about a join it has not answered — every way a
Pendingjoin could previously resolve was the community volunteering something: a verdict, a credential, a problem-report. If any of those was lost — a socket down at the wrong moment, a mediator that dropped it, a decision a human took days later when nothing was listening — the record satPendingand this client never asked.join-requests/status/0.1is the protocol's answer to exactly that, and OpenVTC had implemented only the receiving half (handle_join_status_responseexisted with nothing to trigger it).A minute-by-minute tick now reconciles each pollable
Pendingjoin, starting with an immediate poll at launch — a join stillPendingwhen the app starts is precisely the one whose answer may have been lost. Per record it then backs off 1 → 2 → 4 → 8 minutes, capped at 15, with at most four polls per tick across the account (R1.4), so a parked join is noticed promptly without this client becoming a load source. Pacing is deliberately in memory: it is about this process's politeness, and a stale on-disk backoff would suppress the poll a fresh launch most wants to make. The poll takes the transport the submit took — a TSP-only community would never see a DIDComm poll. -
Adopt the community's own request id when it first replies — a join is recorded against the id of the request document we sent, because that is the only handle available until the VTC answers; the VTC mints its own and returns it in the first correlated reply. The submit-receipt path already swapped the id in, but a
refer/request_moreverdict carriesrequestIdtoo and read straight past it — correlation there is bythid, so nothing needed it.A referred join is the one that then sits
Pendingfor as long as a human takes, so it is the one that most needs to be askable-about later, and without the id there is no handle to ask with.CommunityRecord::request_id_confirmednow records whose id a record holds, and is the gate on polling: quoting our own placeholder would be asking about a request the community has never heard of. Records written before this default to unconfirmed, which is the safe reading — we cannot tell whose id they hold.Note what this does not cover: a join that received no reply at all has no confirmed id and cannot be polled. That case is not a gap here — it is recovered by collecting the stored mail the reply is sitting in.
-
Collect the messages the mediator is holding, instead of waiting to be pushed — every inbound message OpenVTC has ever received arrived by live delivery. A mediator live-streams a message only to a recipient connected at the instant it lands; everything else is stored, and a stored inbox is redelivered only when a new websocket displaces an existing one for the same DID (
websocket_streaming.rs,if replacing). Enabling live delivery drains nothing. So a listener that connects for the first time — the applicant persona during a join, a community's session after a restart, any identity whose socket was down when a reply arrived — was never told what was already waiting for it, and nothing in this client ever asked.That is the join that sits
Pendingwhile the community's outbox reportsSent, and it is why relaunching the app appeared to fix it: the new process's socket displaced the old one, and that is what made the mediator redelivery fire. The recovery was a side effect of how the previous process happened to exit.Messaging::pickup_storednow collects a listener's stored mail over message-pickup 3.0 and hands each message to the state handler on the same channel a live frame uses, andpickup_on_connectruns it on every connect — first connect and reconnect alike, since a reconnect is exactly when something may have landed with nobody attached. Messages are acknowledged after handoff, never before, the same discipline the delivery layer's own dispatcher follows; a frame that could not be unsealed, mapped, or queued is deliberately not acked, so nothing is deleted unread. Bounded at 200 messages per connect (R1.4), with the remainder logged rather than silently dropped.Classification is shared with the live dispatcher rather than copied (
classify_inbound), so the two paths cannot drift into admitting different message types — and the authcrypt sender binding is applied to a collected message too, because these carry membership decisions and credentials and must not be the one path where a spoofedfromis believed.Delivery is at-least-once by design (a message may also arrive live); the runtime loop's
SeenMessagesis what makes that harmless. -
Start messaging before the first join, not after it — a State-A account (no persona yet) ran its join with no messaging runtime at all, so the applicant had no socket for the entire ceremony. A community auto-admitting an invited join answers in under a second, so its reply was stored rather than streamed — and since the hot-start's listener was a first connect, the mediator never redelivered it either. The first join a new operator makes was the one join with no live recipient.
The runtime now comes up before the State-A branch and empty (
start_empty_service;Messaging::startruns its dispatcher before the first transport exists, so this is a supported state, not a stub), and the degraded loop hands it tojoin_flow. A State-A join therefore connects its applicant before submitting, exactly as the runtime-loop join already does.install_listenersthen adds whatever is missing, skipping any the join already brought up — one websocket per DID.The durable join record also now states whether the applicant was connected at submit. A join sent from an unconnected persona is still valid, but it is the one that takes the collect-on-connect path, and an operator reading the log later could not otherwise tell that from a community that never answered.
-
Connect the applicant persona before submitting the join, not after — a join over an accepted invitation is auto-admitted in well under a second, and the community pushes the membership credential (VMC) and role credential (VEC) straight back. The persona's mediator socket, though, only came up once the join flow had returned to the main page, so the reply arrived while its recipient had no live stream. A mediator live-streams a message only if the recipient is connected at the instant it lands: both credentials were stored and never pushed, and nothing polled the mailbox afterwards.
It read as a failed join with clean logs at every hop — the community's outbox said sent, the mediator held two messages, and the membership sat
Pendingwith no error anywhere. Observed with a ~29 s gap between the credentials landing and the persona's websocket registering, which is simply how long the operator spent on the confirmation screen.run_join_sequencenow installs the persona listener as soon as the applicant identity exists and waits — bounded at 10 s — immediately beforesubmit_join_request, so the socket is live for the whole window in which a reply can arrive and the connect is spent on the invitation resolution and VP build rather than on the operator's time. The wait is finite and never fatal (R1.2): a slow or failed connect says which it was (R6.4) and the submit goes out regardless — the community still admits, and the reply is collected when the listener does come up.register_joined_sessionalready tolerated a listener that exists, so it now binds to it rather than installing a second.Cancel-safety is preserved on the pattern
minted_personaestablished: the listener id is returned only when this call installed it, and is held outside the interruptible future so a Ctrl-C — or a failed submit — tears it down instead of leaving a socket open for a persona that was just rolled back. A reused persona already serving another community is never claimed, so its session survives a cancelled join.Independent of this, the mediator-side half of the same gap is fixed upstream in
affinidi-messaging-mediator0.18.16: enabling live delivery now redelivers what is already queued. Deployments want both — this change stops the reply from being stranded, that one stops an already-stranded reply from staying stranded.
-
Refresh every dependency to its latest release —
cargo updateacross the whole graph, plus the manifest bumps needed to cross a major boundary:vta-sdk0.20 → 0.21.7,affinidi-messaging-sdk0.18 → 0.19.2,trust-tasks-rs0.2 → 0.3.0,trust-tasks-capability-client0.1 → 0.2.0, and thevta-servicedev-dependency 0.13 → 0.14.16.One source change was needed.
affinidi-messaging-core0.1.6 marksProtocol#[non_exhaustive]and adds aDIDCommV1variant (Aries RFC 0019), so the inbound dispatcher'smatchindidcomm.rsno longer compiles as written. It gained a wildcard arm that logs and drops: OpenVTC speaks DIDComm v2.1 and TSP, neither listener it installs ever negotiates v1, and nothing downstream of that match could read a v1 payload. The arm is also what keeps the next variant from being a compile break.Everything else rode the bump untouched — the join ceremony, the reciprocal VMC exchange, and the TSP leg all dispatch on
vta_sdk::protocolsconstants rather than string literals. Verified with the full suite including the#[ignore]d e2e tests, which are the ones that actually exercise these crates: the in-process mediator transport round-trip, the join/self-remove lifecycle, and the MockVta bootstrap againstvta-service0.14.Two pins survive re-evaluation and stay:
rand0.8 andx25519-dalek2.x are both still forced bypgp0.20.0, which remains the latest release and declaresrand ^0.8.6/x25519-dalek ^2.0.1.Known duplicate, upstream to fix:
did-git-sign0.4.1 still depends onvta-sdk0.19.28, so the binary links two vta-sdk majors. That belongs toverifiable-git-infrastructure, not here. -
Drop two resolved advisory ignores from
deny.toml— RUSTSEC-2026-0215 (smallstrunmaintained) and RUSTSEC-2024-0370 (proc-macro-errorunmaintained). Both crates left the dependency graph with this refresh, exactly as each ignore's comment predicted, andcargo denynow reports them as unmatched. The remaining ignores still match and stay. -
Follow the VTC Trust Tasks onto the
spec/vtcregistry authority —vta-sdk0.20.0 → 0.20.1 (andtrust-tasks-rs0.2.26 → 0.2.38). The VTC's Trust Tasks have moved off the non-conformanttrusttasks.org/openvtc/vtc/…authority to the canonical registry attrusttasks.org/spec/vtc/…(OpenVTC/verifiable-trust-infrastructure#806, dtgwg-trust-tasks-tf#144).No source change was needed: the join ceremony and the reciprocal-VMC exchange dispatch on
vta_sdk::protocolsconstants rather than string literals, so the new URIs arrive with the bump —MEMBER_REQUEST_VMC_TYPEandMEMBER_VMC_TYPEnow readspec/vtc/members/{request-vmc,vmc}/0.1, and the join-request and self-remove receipts likewise. That indirection is why this is a lockfile change and not an audit of every dispatch site.The inbound router already accepted both authorities —
OPENVTC_CATCH_ALL_PATTERNgained itsspec/vtc/arm ahead of the migration precisely so migrated traffic would not be dropped before dispatch. It stays dual-arm for now: theopenvtc/vtc/arm can be retired once no supported VTC emits it, and eight VTC tasks are still on the old authority awaiting a canonical fold.
- Derive unlock key via Argon2id in non-openpgp import path — the non-
openpgp-cardbuild was passing the raw passphrase toSecuredConfig::save()as the AES-256 key, bypassing the Argon2id KDF and making the saved config unrecoverable sinceUnlockCode::from_string()always applies Argon2id at load time - Reject path-traversal characters in profile name —
--profileandOPENVTC_CONFIG_PROFILEwere spliced verbatim into lock-file paths, config paths, and OS keyring account names; now restricted to[A-Za-z0-9._-]with no..component - Redact armored private key block in
DIDKeysExportStateDebug— the derivedDebugimpl could dump the full PGP-armored private key through any{:?}ofState(panic backtrace, tracing, debug print) - Warn that
--unlock-codeis visible in the process list — the flag exposes the passphrase viaps//proc/<pid>/cmdlineand shell history; help text now documents this and a runtime warning nudges users toward the interactive prompt - Restore terminal on panic via panic hook — panics inside the render loop, key handlers, or spawned tasks no longer leave the TTY in raw mode on the alternate screen
- Drop exported private-key armor from
Stateafter use — the armored PGP private key block was cloned through the state broadcast channel on every tick for the remainder of the setup wizard - Avoid OOB panic on stale token-list index — unplugging or re-enumerating tokens no longer panics the TUI when a retained selection index exceeds the new bounds
- Clear private-key clipboard when leaving export page — the ASCII-armored PGP private key block placed on the OS clipboard by
[C]is now cleared on continue (unless the user has copied something else in the meantime) - Clear
ConfigImportpassphraseInputbuffers after dispatch — both passphrase inputs are now reset once wrapped inSecretStringand dispatched, matching the other secret-input pages
- Full TUI main menu panels in
openvtc— 8 panels: Inbox, Relationships, Credentials, Settings, VTA Service, Logs, Help/Status, Quit - Inbox panel with real-time task processing: auto-handles trust-pongs, relationship finalization, and rejections; queues interactive tasks; detail views for all task types (inbound/outbound requests, VRCs, pings, informational)
- Relationships panel with list/detail/new-request views, inline alias editing ('e' key), R-DID privacy toggle, trust-ping with RTT latency
- Credentials panel with Received/Issued tabs, raw VRC JSON in detail view, clipboard copy ('c' key), VRC request and removal
- Settings panel with inline editing, config export/import, passphrase protection management, hardware token detection and factory reset
- VTA Service panel showing VTA URL, DID, credential DID, key count, and backend type
- Logs panel with scrollable timestamped activity log, selected entry copy ('c'), copy all ('a')
- Activity log panel at bottom of screen showing real-time timestamped events (
[HH:MM:SS] message) - Status/Help panel with DID clipboard copy hotkeys ([1] persona, [2] mediator), visual feedback on copy
- R-DID generation for both BIP32 and VTA backends — VTA path authenticates and creates keys via API; both sender and receiver can use R-DIDs
- Dynamic R-DID listeners — automatically added when creating R-DIDs (sender or receiver), enabling message delivery to relationship-specific DIDs
- VRC issuance from inbox with DataIntegrityProof signing; VRC rejection with message back to requester
- Friendly name in relationship requests — sender's name included in request body, auto-set as contact alias on accept, R-DID recommendation shown when sender uses one
- DIDComm service integration (
affinidi-messaging-didcomm-service0.2) — replaces manual messaging with Router-based dispatch, automatic reconnection, message pickup, and multi-DID listener support - Periodic keepalive ping (60s) with live RTT latency in connection status header
- Inbox task count badge on menu item ("Inbox (3)" in red when tasks pending)
- Bracketed paste for all 21 text input fields — paste is instant regardless of string length
- Up/Down arrow navigation in all multi-field forms alongside Tab
- Config versioning with stepwise migration framework
- Panel trait for content panels — unified render interface
- Outbound message retry via
DIDCommService::send_message_with_retry - Auto-reconnect mediator on DID change in settings
- 15 unit tests covering core functions
- Contact management actions (add/remove)
- Trust-pings only responded to from mediator DID or established relationships — prevents presence leakage
- Passphrases removed from cloned State — length-only fields in UI, consumed via
mem::take - Token admin PIN wrapped in
Arc<SecretString>for shared allocation - Inbound message body size validation (1MB limit), task ID deduplication, sender verification
- Collection bounds (10K tasks, 5K relationships), untrusted display text sanitization
- Unlock rate limiting (5 attempts, exponential backoff), path redaction, file path validation
- Key material explicit drop with documented zeroization limitation
- Structured audit log entries for security-relevant operations
- R-DID message routing — acceptance, finalize, VRC, and ping messages now use relationship DID instead of persona DID when R-DID exists
- Config persistence — all mutating actions save to disk
- Setup → main transition —
sync_from_config()now called after setup wizard completes - VRC "From:" blank — extract remote DID from relationship for VRC tasks
- Alias on accept — sender's name set as contact alias, existing alias-less contacts updated
- Backspace to empty in relationship form fields
- Tab after backspace fix — dedicated
FocusFieldaction for field switching - DIDComm listener secrets — pass DID secrets to listeners for mediator authentication
- All
.unwrap()/.expect()replaced with proper error propagation - Clipboard graceful degradation,
sanitize_displayANSI stripping order
- Workspace consolidation — renamed the active CLI package and binary
openvtc-cli2→openvtc, and renamed the supporting libraryopenvtc-lib→openvtc-core. The unsuffixed name now belongs to the user-facing binary, matching the convention used by uv, ruff, deno, and cargo. The library ispublish = false, so no external consumers are affected. vta-sdk0.5 consumed from crates.io — dropped the temporary../verifiable-trust-infrastructure/vta-sdkpath pin so the workspace no longer requires a sibling checkout to build.- Replaced manual messaging layer with
affinidi-messaging-didcomm-service— deleted messaging/mod.rs (~280 lines) and outbound_queue.rs (~90 lines), added didcomm.rs (~260 lines) with Router, listeners, and send_message_with_retry - Grouped ~65-variant Action enum into 5 domain sub-enums
tokio::sync::watchreplaces mpsc for State updates- Panel trait with per-panel structs implementing unified render interface
- Dynamic DID display width (
shorten_did(did, max_width)— 60 chars default, full if fits) Cow<str>for zero-alloc DID truncation- Explicit
Arc::clone(),#[must_use]on pure functions, doc comments on State types VecDeque<String>for O(1) bounded activity logRelationshipRequestBody.nameprotocol field for friendly names
- Legacy
openvtc-clicrate — the original prompt-driven CLI was phased out in favour of the TUI. All ongoing work lives inopenvtc. - Dead
VtaAuthenticatesetup page — online provisioning emitsVtaAuthCompleteddirectly fromVtaProvisioning, so the legacy authenticate screen was unreachable.
After cutting the v0.2.0 branch a multi-axis review (code quality, security, tests, docs) flagged a set of findings that landed on the same release branch before merge. They're listed separately so the diff between v0.1.x and v0.2.0 stays readable.
- Per-entry random Argon2 salt with transparent v1→v2 migration.
derive_passphrase_keypreviously used a deterministic salt = SHA-256(info), so two operators with the same passphrase produced the same KEK and exported backups were byte-comparable. The newpassphrase_encrypt_v2/passphrase_decryptAPI inopenvtc-core::config::secured_configwrites a magic-prefixed[OPV2 | salt(16) | nonce(12) | ct+tag]blob with a fresh random salt; the decrypt path auto-detects v1/v2 so existing exports keep opening. Argon2id parameters bumped to OWASP "high-value KEK" floor (m=128 MiB, t=4, p=1). did-git-signsigning policy. The proxy now refuses to sign unless the parent process name starts withgitorssh-keygen, and writes every signing attempt — accepted or denied — to~/.config/did-git-sign/audit.log(mode 0600) with parent PID/name, namespace, buffer path and SHA-256. Blocks the "malicious build script obtains a signature with namespace=git over attacker-chosen content" pivot.- DIDComm replay window + seen-message LRU in
process_inbound_message: drop messages withcreated_timeoutside ±48h / +5m skew, drop messages whoseexpires_timealready passed, dedupe on a 1024-entry process-lifetime ID LRU. - DID validation uses a real W3C DID Core 1.0 syntax parser instead of a
did:prefix check; rejects bidi-override / zero-width chars in DID fields. - Inbox display-name sanitisation strips bidi-override / isolate / zero-width / BOM unicode (Cf class) plus ANSI escapes / control chars, and clamps inbound contact aliases to 64 chars before persistence.
- Bounded DIDComm event channel (256-entry capacity) so a noisy mediator can't grow memory without limit; overflow logs and drops, mediator pickup redelivers when we drain.
did.jsonlwrite path is now the resolved profile dir, not the current working directory.- Dependabot: transitive openssl/rustls-webpki/rand bumped via
cargo updateto clear nine open advisories.pgpwas already at the patched 0.19. - Tagged-variant downgrade defence on
SecuredConfigFormat. Switched the on-disk variant tag from#[serde(untagged)]to#[serde(tag = "format")]so every blob carries an explicit"format"discriminator. Without it, an attacker with write access to the OS keychain could substitute aPasswordEncryptedblob with{"text": "<plaintext>"}and serde would silently match it asPlainText, bypassing AES-256-GCM. Newassert_format_matches_intentcross-validation gate adds a second defence layer — a tagged-but-weaker blob is rejected before any decrypt or re-save. Old (untagged) blobs migrate transparently on first load. Folded from @ojasshelke's PR #34; the PR's HKDF v2 fixed-salt variant is superseded by our random-per-entry-salt v2 (OPV2magic prefix) above.
Three community PRs against main were assessed and folded into the release. Each PR's substantive value is preserved with Co-authored-by: trailers; the corresponding PRs are closed with a comment pointing here.
- #57 — profile-name validation hardening (@sameerchore).
validate_profile_namenow trims leading/trailing whitespace before validating, and the empty/whitespace check runs before the character check (so" "gets a clear "cannot be empty or contain only whitespace" error instead of the confusing "Invalid profile name ' '"). Three new integration tests pin the behaviour. - #51 — cross-platform config paths (@krsatyamthakur-droid, closes #47).
profile_dirandget_lock_filenow usedirs::config_dir()on Windows (typically%APPDATA%\openvtc); Unix/macOS continues to use~/.config/openvtc/so existing installs don't move.get_config_pathandget_lock_filereturnPathBufinstead ofStringend-to-end. - #34 —
SecuredConfigserde-format hardening (@ojasshelke). Tagged-variant downgrade defence + intent-gate cross-validation, described under Security above. The PR's HKDF v2 fixed-salt scheme was superseded by our random-salt OPV2 v2 and intentionally not folded.
- State-handler split.
state_handler/mod.rswas 2,255 lines with a 500-linetokio::select!arm; it's now 813 lines (-64%). Each per-domain match (Inbox, Relationship, Credential, Settings, Contact) was extracted to adispatch(action, ctx).awaitentry point in the corresponding sub-module. - Layering: moved
colors.rsand thedialoguerpassphrase prompt out ofopenvtc-coreso the daemon (openvtc-service) and automation (robotic-maintainers) crates no longer pull inratatui+dialoguertransitively. - Lifted four DID-truncation helpers into a single
openvtc-core::displaymodule (truncate_did,truncate_did_centered). - Tightened
openvtc-corepublic surface — dropped a deadpub usere-export and scoped two helpers topub(crate). - Fixed silent failures in the state handler: surfaced previously-swallowed
save_config/remove_listener/ inbox-task errors vialog_error. Replaced four.expect("valid route")panics in DIDComm router init with?. Replacedpanic!("Cannot create log file …")with stderr + continue. - Fixed DIDComm-only VTA fallback in
relationships.rs(usedbuild_runtime_vta_clientinstead of REST-onlychallenge_response).
- In-process mediator harness (
openvtc-core/tests/common/mod.rs): wraps the upstreamaffinidi-messaging-test-mediator0.2 fixture viaTestMediator::with_users(["alice", "bob"]), which boots a realaffinidi-messaging-mediatoron an ephemeral loopback port (memory-backed store, generateddid:peeridentity advertisingdm/#auth/#ws, Ed25519 JWT signing keypair) and returns Alice + Bob as ALLOW_ALL accounts whose DIDComm service URI is the mediator's DID — the routing/2.0 shape required for forwards to short-circuit to local delivery instead of being enqueued for external forwarding. The previous in-tree harness predated the test-mediator crate; the migration drops ~400 lines of fixture code and four dev-deps (affinidi-messaging-mediator,-mediator-common,-sdk,sha256). - End-to-end integration tests (
relationship_e2e.rs): drive a real Alice→Mediator→Bob DIDComm round-trip, a productionRelationshipRequestBodyround-trip, and a two-leg VRC request/reject round-trip — all in ~350ms once the mediator is up. Plus a smoke test (mediator_smoke.rs) that asserts the well-known endpoint serves a DID Document. Marked#[ignore](each spawns the mediator, ~1s); CI's coverage job runs them with--include-ignored. - 38 new unit tests across
setup_flow/navigation(25 table-driven), BIP32 derivation (7 known-answer vectors), AES-GCM tampering (6) — locking the wizard flow, derivation contract, and AEAD failure modes before the v0.3.0 work begins. - CI adds a
cargo-denyjob (advisories + licenses + bans + sources, with documentedRUSTSEC-2023-0071rsa Marvin-Attack andRUSTSEC-2024-0370proc-macro-error ignores) and acargo-llvm-covcoverage job (uploadslcov.infoartifact, runs ignored tests). MSRV check bumped 1.91 → 1.94 to matchCargo.toml.
Picks up the May 2026 Affinidi-stack releases. All bumps cleared on crates.io; build, full test suite, and integration tests pass.
affinidi-tdk0.6 → 0.7 — accessor-method API onTDKSharedState/TDKEnvironment/TDKProfile. Field accesses (.secrets_resolver,.environment,.profiles,.default_mediator,.ssl_certificate_paths) are now method calls.TDKSharedState::default().await(removed in tdk 0.6) replaced withTDKSharedState::new(TDKConfig::headless()?).await?inopenvtc-service.affinidi-messaging-didcomm-service0.2 → 0.3 — version bump driven by the upstreamMediatorACLSeterror-type relocation; downstream impact is?-transparent thanks toFrom<ACLError> for ATMError.affinidi-messaging-test-mediator0.1 → 0.2 (dev-deps only) —TestMediator::with_users(["alice", "bob"])replaces our hand-rolledMemoryStore+ ALLOW_ALL registration dance. Dropsaffinidi-messaging-mediator,-mediator-common,-sdkandsha256from dev-deps.- Working with the upstream maintainers, this branch's review of the May 2026 test-mediator changes also surfaced two follow-ups landing post-publication: an IPv6 routing-classification fix and
mediator-commonfeature-gating to keep the SDK light. Neither is on the path used by openvtc tests (loopback over127.0.0.1).
- README, CONTRIBUTING, SECURITY, CLAUDE.md aligned to the post-rename workspace shape (
openvtcbinary +openvtc-corelib). - CHANGELOG
[0.2.0]entry above describes the release as it actually shipped.
- Upgraded
pgp0.18 → 0.19, resolving 3 Dependabot alerts: parser crash on crafted RSA secret key packets (CVE-2026-21895), crash from deeply nested messages, and integrity protection not always checked on encrypted data
- Hardware token touch prompt overlay in
openvtc-cli2— a centered popup now appears when a YubiKey (or other OpenPGP card) requires physical touch confirmation, and auto-dismisses when the touch completes - Progress feedback during VTA credential validation in
openvtc-cli2setup wizard - Unit tests for
MessageTypeandKeyPurposeinopenvtc-lib - GitHub Discussions guidance in
CONTRIBUTING.md
- Upgraded
secrecy0.8 → 0.10 (SecretVec<u8>replaced withSecretBox<Vec<u8>>,SecretString::new()API updated) - Upgraded
openpgp-card0.5 → 0.6 andopenpgp-card-rpgp0.6 → 0.7 - Migrated pgp 0.19 API changes:
EncryptionKey/DecryptionKeytraits,SubpacketData::IssuerKeyId,Timestamptypes
- Stale
openvtc-cli2/did.jsonltest artifact
- Removed legacy SHA-256+HKDF encryption — existing configs must be recreated with
openvtc setup UnlockCode::from_string()now returnsResultand enforces minimum 8-character passphrasederive_passphrase_key()now returnsResult— callers must handle the error
- Replaced
rand::thread_rng()withOsRngin all cryptographic key generation paths (BIP39 entropy, PGP export, DID key generation) - Hardened Argon2id parameters: 64 MiB memory / 3 iterations (up from default 19 MiB / 2 iterations) per OWASP recommendations
- Added
#![deny(unsafe_code)]toopenvtc-lib— no unsafe code in production paths - Added DID format validation for
OPENVTC_MEDIATOR_DIDandOPENVTC_ORG_DIDenvironment variable overrides - Replaced all production
unwrap()calls with proper error handling in setup wizard, clipboard operations, and service initialization - Replaced ~15 silent
let _ =error discards withdebug!/warn!logging in state handler, service, and robotic-maintainers
- Argon2id as sole KDF (removed legacy fallback)
- Profile name validation (alphanumeric, hyphens, underscores only)
- Rate limiting to
openvtc-service(50 msg/sec with throttle logging) - Graceful shutdown signal handling (SIGINT/SIGTERM) in
openvtc-service - Criterion benchmarks for
derive_passphrase_keyandunlock_code_encrypt/unlock_code_decrypt - Integration tests for profile validation, relationships, VRCs, tasks, and logs (38 new tests)
CODE_OF_CONDUCT.md(Contributor Covenant v2.1)- Windows to CI test matrix
- MSRV verification (Rust 1.91.0) in CI pipeline
- API documentation for public modules (relationships, VRCs, tasks, logs, config)
- All Clippy warnings (migrated deprecated Protocols API, collapsible-if, items-after-test-module)
- Corrected valid-until prompt handling for VRC issuance in
openvtc-cli(PR #23)
A standalone CLI tool for signing git commits using DID Ed25519 keys managed by a VTA. Acts as a git SSH signing proxy — no private key material ever touches disk.
- Git SSH signing proxy via
gpg.ssh.programintegration - VTA authentication with token caching in OS keyring
- Credential private key stored in OS keyring (macOS Keychain / Linux Secret Service)
- Ed25519 signing key fetched from VTA at sign-time and zeroized after use
- SSH signature output in PROTOCOL.sshsig format
initcommand — configures git and sets up allowed_signers for verificationstatuscommand — displays current signing configuration and keyring stateverifycommand — end-to-end test of keyring, VTA auth, key fetch, and signing- Config validation: rejects non-HTTPS VTA URLs, empty credentials, non-Ed25519 keys
- Retry logic for VTA authentication (up to 2 attempts on transient failures)
didwebvh-rs0.1 → 0.4affinidi-tdk0.5 → 0.6 (affinidi-messaging-didcomm0.12 → 0.13)affinidi-data-integrity0.4 → 0.5dtg-credentialsswitched from local path to crates.io (0.1)vta-sdkupdated to 0.3 (health.versionis nowOption<String>,VtaClient::set_tokenno longer requires&mut self,CreateDidWebvhRequesthas new optional fields)- All transitive dependencies updated to latest compatible versions via
cargo update
- Replaced manual
DIDWebVHState::default()+create_log_entry()pattern with the newcreate_did(CreateDIDConfig)API in bothopenvtc-libandopenvtc-cli create_initial_webvh_did()is now async (required bycreate_did)- Added
LogEntryMethodstrait import forget_did_document()access
DataIntegrityProof::sign_jcs_data()is now async — added.awaitinopenvtc-cli,robotic-maintainers, anddtg-credentialsDTGCredential::sign()is now asyncCreateDidWebvhRequest.server_idchanged fromStringtoOption<String>CreateDidWebvhRequestnow requiresurl: Option<String>field and new optional fields (did_document,did_log,signing_key_id,ka_key_id,set_primary)CreateDidWebvhResultBody.mnemonicchanged toOption<String>Message::pack_encrypted()removed — replaced withATM::pack_encrypted(&msg, to, from, sign_by)Message.type_field renamed toMessage.typdidcomm::error::Errorreplaced bydidcomm::DIDCommErrorPackEncryptedOptionsremoved — encryption options are now implicit in the pack function choiceUnpackMetadatamoved fromdidcommtomessaging::messages::compatVtaClient::set_token()no longer requires&mut selfHealthResponse.versionchanged fromStringtoOption<String>
- Custom
Debugimplementations forPersonaDIDKeysandKeyInfothat redact secret material - Replaced debug logging of full
SecuredConfigstruct with safe summary - Fixed
unwrap()in SSH signature encoding path withexpect()and context - VTA URL validation — rejects plain HTTP (except localhost for development)
- Ed25519 key type validation when fetching signing keys from VTA
- Empty access token rejection after VTA authentication
- Extracted 11 hardcoded protocol URLs to
protocol_urlsconstants module inopenvtc-lib - Added
mediator_did()andorg_did()helper functions with environment variable overrides (OPENVTC_MEDIATOR_DID,OPENVTC_ORG_DID) - Updated
MessageTypeFrom/TryFromimpls and VRC message builders to use protocol URL constants - Removed unused
consoleandcrosstermdependencies fromopenvtc-lib
- openvtc-lib: Added 14 new tests (2 → 16 total)
- Encrypt/decrypt roundtrip, wrong key rejection, empty data, large data, different key ciphertext divergence, corrupted data detection, zeroize verification
- Protected config save/load roundtrip, wrong seed rejection, serialization, contacts find/remove, credential seed determinism and divergence
- did-git-sign: Added 6 new tests (5 → 11 total)
- Config validation (empty URL, HTTP rejection, HTTPS acceptance, localhost exception, empty key ID rejection, seed material zeroization)
- Added
did-git-sign/README.mdwith setup instructions, architecture diagram, security model, and config format reference - Added workspace crates table and DID Git Signing section to root
README.md
- Fixed deterministic encryption vulnerability in
unlock_code_encrypt/unlock_code_decrypt(openvtc-lib). The previous implementation used a seeded PRNG to derive both the AES-256-GCM key and nonce from the unlock code, producing identical ciphertext for the same password and plaintext. The fix uses HKDF-SHA256 for key derivation with a random nonce (viaOsRng), ensuring each encryption produces unique output. Existing configs encrypted with the old format are transparently decrypted via a legacy fallback and re-encrypted with the secure format on the next save.
- CLI interface for
openvtc-servicewith--config/-cflag to specify an alternate configuration file path (default:conf/config.json). --helpand--versionflags foropenvtc-service.- Comprehensive operator documentation for
openvtc-service: configuration schema, logging (RUST_LOG), runtime behavior, and protocol context.
- Unused
chronoandranddependencies fromopenvtc-service.
- Aligned documented minimum Rust version with workspace
rust-version(1.91.0) in root README,openvtc-lib, andopenvtc-serviceREADMEs. - Removed duplicate introductory paragraph and repeated bullet in Decentralised Identity section.
- Fixed typo "Remove" to "Remote" in Private Configuration section.
- Changed incorrect
htmlcode fence totextfor a URL example under Host Your DID Document. - Updated README badges to link to current repository (
OpenVTC/openvtc).