All notable changes to Vault are documented here. Dates are ISO 8601 UTC per
the Spacecraft Software Standard §12. Vault is pre-1.0; versions in the 0.x
range may break in any release.
-
Interactive secret prompts no longer echo to the terminal.
vault login/vault unlockprinted the master password in clear text as it was typed (visible on screen and in scrollback); the PIN, theadd/editlogin password, and the card number/CVV and identity SSN/passport/license prompts had the same flaw. The CLI now disables terminalECHOfor the duration of every interactive secret read (aNoEchoRAII guard overrustix::termios, restored on drop — including on error/panic; no new dependency, nounsafe). Interactive entry now also submits on Enter (the master-password path previously read until EOF, so a typed password sat untilCtrl-D). Piped / redirected input is unchanged —pass show | vault loginstill reads the whole stream — and non-secret prompts (the register server picker, account email, the ephemeral authenticator code) still echo by design. -
Bumped
quinn-proto0.11.14 → 0.11.15 (RUSTSEC-2026-0185). A remote memory-exhaustion (DoS) advisory inquinn-proto's out-of-order stream reassembly, published 2026-06-22.quinn-protois a phantomCargo.lockentry — an unenabled QUIC/HTTP3 path ofreqwest; Vault speaks HTTP/2 only, so it never enters the build graph and the flaw is unreachable — butcargo auditscans the lockfile literally, so the patched release is pulled in to keep the supply-chain gate green.
-
TUI
Spacereveals the password from every pane.Spacerevealed a login's password from the item list, but did nothing while the detail pane was focused (logins have no per-field detail rows, so there was nothing to reveal) and was inert in the folder pane. Reveal target resolution now lives in a testableApp::reveal_target: the detail pane reveals the cursor-selected masked field for cards/identities and falls back to the item's primary secret for logins/notes, and the list and folder panes both reveal the selected item's primary secret — soSpaceworks from all three panes. -
Login against current Bitwarden / Vaultwarden servers. Once the master password was accepted, the server rejected the token request with
400 version_header_missing— "No client version header found, required to prevent encryption errors" — becausevault-apisent no client-identification headers. The client now sends the Bitwarden trio (Bitwarden-Client-Name,Bitwarden-Client-Version,Device-Type) asreqwestdefault headers on every request. The advertised version (vault_api::CLIENT_VERSION) is a recent Bitwarden client version — not Vault's own — chosen to clear the server's min-version gate while keeping it on the EncString type-2 / PBKDF2+Argon2 crypto path Vault implements. Because the check sits on the post-authentication path, a wrong password still fails first withinvalid_grant("bad password"), which is why the missing header only surfaced once the password was correct. -
2FA challenge misreported as "bad password". Bitwarden's hosted server returns the
TwoFactorProviderslist with provider ids as JSON strings (["0","7"]);vault-api's parser expected integers, so the whole 2FA error body failed to deserialize and fell through to a genericinvalid_grant— which the agent rendered as "bad password", even with a correct password and a valid authenticator. The provider-id parse now accepts strings or integers (and never fails the surrounding 2FA detection). The agent also logs the raw server body on a login400, so an opaque "bad password" can't mask an actionable response again. -
vault syncreported 0 items from a populated vault. The/syncresponse (and every nested cipher) is camelCase on current Bitwarden cloud and Vaultwarden, butSyncResponseand thevault-corecipher types werePascalCase; every field fell back to its#[serde(default)]empty value, so a full vault parsed as zero ciphers. The API-service wire structs are now camelCase (identity-service structs —TokenResponse,TwoFactorErrorBody— stay PascalCase, matching that endpoint), folder-name extraction tolerates both casings, and the test fixtures were corrected to the real shape.
-
vault exec— inject secrets as env vars at launch.vault exec -- claude(or--profile <name> -- <cmd>) resolves every var in a new[exec.profiles.<name>]mapping against the agent and injects the plaintext only into the launched child's environment — never the invoking shell, never disk — instead ofexporting API keys into a shell session for its lifetime. Mappings are edited viavault config exec set/unset/list(no manual TOML). Each mapping is an item spec:<item name>(password field, the default) or<item name>#<field>where<field>isusername/notes/totp/custom:<name>— the last resolves a named custom field, which required a newField::Custom(String)wire variant (vault-ipc) and custom-field decryption support invault-core(PlainCipher::fields,DecryptOptions::custom_fields) andvault-agent. Every var is resolved before the child spawns — a missing item, missing field, or ambiguous name aborts first, so the child never sees a partially-populated environment. Seedocs/exec.mdfor the grammar and what env-var injection does and doesn't protect against. -
Passphrase generation (
vault generate --passphrase+ TUI). The generator now produces diceware passphrases — N words drawn from the EFF large wordlist (7776 words, the list Bitwarden uses) joined by a separator, with optional capitalization and an appended digit — alongside the existing character passwords. CLI:--passphraseplus--words(3–20),--separator,--[no-]capitalize,--[no-]include-number;--jsonemits the passphrase shape. TUI generator overlay:Tabswitches Password⇄Passphrase,+/-adjust the count (length or words), anda/n/etoggle capitalize / number / separator. Defaults match Bitwarden (3 words,-, no caps, no number). New[generate]config keys (generate.mode/length/ classes /words/separator/capitalize/include_number) remember your preferences — the TUI overlay opens with them andvault generateuses them as defaults (explicit flags override). Generation stays fully client-side invault-core(generate_passphrase, unbiased rejection sampling overgetrandom), so no secret touches the agent or the network. The wordlist is the EFF's CC-BY-3.0 data — the project's first third-party-derived in-tree file — vendored atcrates/vault-core/src/wordlist.rswith aREUSE.tomlcarve-out,LICENSES/CC-BY-3.0.txt, and aCREDITS.mdentry; regenerate viacrates/vault-core/tools/gen_wordlist.sh. -
Fingerprint unlock (Linux, off by default). With
agent.session_keyringand the newagent.fingerprint_unlockenabled,vault unlock --fingerprint(and a TUI unlock-screen mode) re-unlock the keyring-held session after a fingerprint verified — inside the agent, over D-Bus to the systemfprintd(via the new off-by-defaultfingerprintcargo feature +zbus). Idle-lock zeroises the in-memory key but keeps the keyring entry so a touch works after a timeout (lifetime:agent.fingerprint_ttl_secs); the agent no longer silently auto-resumes, and manualvault lockstill clears everything. Enrollment stays OS-level (fprintd-enroll); Vault only verifies and stores no template. Posture (PRD §7.3): this is convenience + user-presence, not a cryptographic boundary — the keyring entry is possessor-gated, so it's no stronger thansession_keyringand weaker than a master-password unlock. Seedocs/fingerprint.md. -
Organization / Collection items now decrypt (org-key support). Vault previously skipped every organization-owned cipher — the bulk of a vault that uses Collections — because it held no key for them. At unlock the agent now unwraps each organization's symmetric key from
/sync(profile.organizations[].key, an RSA-OAEP-SHA1 envelope opened with the account key recovered fromprofile.privateKey) and routes each cipher's decryption byorganization_id(org key vs user key). Works online and from the offline cache. New dependencyrsa(RSA decryption —ringhas none); see the justifiedRUSTSEC-2023-0071ignore indeny.toml/ CI. Items whose org key can't be unwrapped are still skipped, and editing org items is refused for now (the write path would re-encrypt under the wrong key) — reads/copy work. -
vault syncprogress spinner. On a TTY,vault syncnow animates a spinner while the agent pulls and decrypts/sync, then prints✓ synced N items. Suppressed under--jsonand when stderr is not a terminal, so scripts and machine consumers are unaffected. -
EncString fuzz soak passed (PRD §11.4 / RELEASING.md gate #1). A ≥ 24 h libFuzzer soak of the
EncStringtype-2 parser completed with 0 findings: 8,874,210,317 executions over 86,401 s (~102.7 k exec/s), coverage flat at 312 blocks, exit 0,fuzz/artifacts/empty. The verbatim run is captured indocs/fuzzing-report.md. First of the v0.1 operational gates cleared; the tag still waits on the live PQC handshake and the daily-driver attestation. -
REUSE compliance (Standard §4.3) + CI gate. Added
LICENSES/GPL-3.0-or-later.txtand aREUSE.toml(apath="**"annotation supplying copyright + license in bulk, mirroringconstruct/), so every file carries license + copyright info andreuse lintpasses (77/77 files, GPL-3.0-or-later only). A newREUSE lintCI job enforces it. Satisfies the §5.2 posture requirement for aLICENSES/directory. -
justfiledev gates. Recipes mirroring CI exactly —just fmt/clippy(fresh-isolated-D warnings, the only form that matches the runner) /test/headless/version-gate/deny/audit, withjust cirunning the full set, plusjust fuzz [secs]andjust pqc. Encodes the commands from.github/workflows/ci.ymlso they can't drift and are one keystroke. -
RELEASING.md+ v0.1 status reconcile. A maintainer checklist for cutting thev0.1.0tag (operational gates → mechanical version bump / CHANGELOG date / signed tag), and PRD §11–§12 annotated with each criterion's status. Docs only — no version bump or tag yet (those wait on the operational gates). -
Scrolling detail pane. The detail pane now scrolls to keep the focused field visible — the granular identity view is ~18 fields and could overflow a shorter terminal. Reuses the form's
scroll_offset; only the detail-focused view scrolls (browsing is unchanged). -
Granular identity field selectors. 16 new
--fieldselectors expose every identity field individually —identity-{title,first-name,middle-name, last-name,username,company,ssn,passport,license,address1,address2,address3, city,state,postal,country}— alongside the existing composedidentity-name/identity-address.vault get … --field identity-ssnnow works, and the TUI identity detail pane shows the full granular set so per-field reveal/copy reaches every field (SSN/passport/license masked, revealed on demand like the card CVV). Same proto → agent → CLI → TUI shape as thecard-*selectors. -
TUI per-field reveal/copy in the detail pane. The detail pane is now focusable (
Tabcycles folders → items → detail): with it focused,j/kmove a field cursor andSpace/creveal/copy the selected field — so the card CVV (and any non-primary field) can finally be revealed and copied, not just the item's primary field. Masked fields (card number/CVV) still fetch only on reveal. Item-listSpace/ckeep hitting the primary field. Cards and identities get the field cursor; logins keep theirc/u/o/tkeys. -
TUI form scrolling + full identity editing. The add/edit form overlay now scrolls (a viewport that keeps the focused row visible; the keybind footer stays fixed, with a
↕cue when there's more). This retires the curated 10-field identity limit: the TUI identity form now edits the full 18-field set, including the SSN/passport/license secrets (masked while unfocused, like the card number/CVV; redacted in anyDebug). Purevault-tui— theIdentityWritebackend already carried all the fields. -
card-cardholderfield selector.vault get <card> --field card-cardholdernow returns the cardholder name, and the TUI detail pane shows aHolderline — which also lets the TUI card edit form prefill the cardholder (it previously started blank, as there was no read selector). Closes the card edit-prefill gap from the card-write / TUI-card slices. -
mlockthe user keys (no swap exposure). The agent's unwrapped user keys (user_enc/user_mac) are now wrapped in aSealedKeynewtype that boxes the bytes for a stable address andmlocks their page(s) so they can't be paged to a swap file, completing the agent memory-hygiene hardening (core dumps + ptrace already covered). Surgical (only the key pages, never the whole process → noRLIMIT_MEMLOCK/OOM risk) via the saferegioncrate, keeping#![forbid(unsafe_code)]; best-effort (a refused lock logs nothing and the key is still zeroized).SealedKeyderefs to[u8; 32], so the encrypt/decrypt call sites are unchanged; it zeroizes while still locked, then unlocks, on drop. -
Agent anti-leak hardening (core dumps + ptrace). On startup the agent now disables core dumps and marks itself non-dumpable (which also blocks same-user ptrace), so the in-memory user key / refresh token can't leak to a core file or a debugger. Done via the
secmem-proccrate (auditedrustixsyscalls, keeps the agent's#![forbid(unsafe_code)]); best-effort — a sandbox that restrictssetrlimitlogs a warning and the agent keeps running. PRD §12 M7 hardening groundwork. (mlock/no-swap remains a tracked follow-up.) -
Post-quantum transport (
pqcfeature, off by default). A GPL-clean hybrid X25519MLKEM768 key-exchange group is added to the rustls client config, so a TLS 1.3 handshake negotiates a post-quantum-secure secret when the server offers it (silent classical fallback otherwise). The classical half reuses ring's X25519; the PQ half is RustCryptoml-kem(Apache-2.0/MIT) — not aws-lc-rs, whose OpenSSL-licensed AWS-LC is GPL-incompatible. Build withcargo build -p vault-agent --features pqc; seedocs/pqc.md. Satisfies the PRD §12 M7 "PQC transport feature flag" item (vault-api/src/pqc.rs, client role only). Tests cover the ML-KEM client kx round-trip + the hybrid layout. -
EncStringparser fuzz harness. A cargo-fuzz / libFuzzer target (fuzz/fuzz_targets/enc_string_parse.rs) for the security-critical Bitwarden "type 2" parser — feeds arbitrary input toEncString::parseand asserts the parse → serialize → parse round-trip is stable. Lives in a standalonefuzz/workspace (nightly + sanitizer only), so the CI gates are untouched; run withcargo +nightly fuzz run enc_string_parse(seedocs/fuzzing.md). Groundwork toward the PRD §11.4 / M7v0.1gate (the ≥ 24 h soak is a maintainer step). -
TUI identity create/edit (curated). The
vault-tuiadd/edit form (a/e) now composes identity (type 4) items — the Type row cycleslogin → secure note → card → identity. To fit the non-scrolling overlay it edits a curated 10-field subset (title, first/last name, email, phone, address1, city, state, postal code, country); the long-tail fields and the SSN/passport/license secrets stay CLI-only. On edit, email/phone prefill from the detail pane (the composite Person/Address lines can't be split back, so those start blank = leave unchanged). Builds the existingIdentityWriteonRequest::Add/Edit— no backend change. With this, all four cipher types are editable from both front-ends. Tests: identity add carries the curated fields, identity edit prefill/diff, 4-way type-toggle cycle. -
Create/edit identity ciphers.
vault add … --type identityandvault editnow build/update identity (type 4) items — the last cipher type to get a write path. The 15 non-secret fields (title, names, username, company, email, phone, address1–3, city, state, postal code, country) are flags; the three sensitive fields — SSN, passport, license — are prompted on the controlling terminal when their bool flag (--ssn/--passport/--license) is set (never argv). The identity username is--identity-username(the bare--usernameis the login field). Editing identity fields on a non-identity item is rejected.vault-core:from_plainemits anIdentityfor type 4.vault-ipc: a typedIdentityWrite(ssn/passport/license as zeroizedVec<u8>, redactedDebug) onRequest::Add/Edit(serde-defaulted).vault-agent:add_cipherbuilds aPlainIdentity;apply_cipher_editssets only the given identity fields. Tests:from_plain→decryptidentity round-trip;apply_cipher_editsidentity partial-update;IdentityWritetransport +Debug-redaction.- This unblocks identity TUI editing (the next follow-up) — the backend it needed now exists.
-
TUI card create/edit. The
vault-tuiadd/edit form (a/e) now composes card (type 3) items, not just logins and secure notes. The Type row cycleslogin → secure note → card; the card rows are cardholder/brand/number/expiry (oneMM/YYYYfield, split on submit)/CVV, with number and CVV masked while unfocused. On edit, brand/expiry prefill from the detail pane's on-select fetch and secrets start blank (blank = leave unchanged). Builds the existingCardWritepayload onRequest::Add/Edit— no backend change. Identity TUI editing stays pending (no identity write path yet). Tests: card add carries split expiry + redacts secrets, card edit prefill/diff,parse_expiry. -
Create/edit card ciphers.
vault add … --type cardandvault editnow build/update card (type 3) items: cardholder/brand/expiry via flags, the number and CVV prompted on the controlling terminal (/dev/tty, never argv — so they don't leak to shell history /ps).--expirytakesMM/YYYYorMM/YY; on edit,--number/--codere-prompt those secrets. Editing card fields on a non-card item is rejected.vault-core:from_plainemits aCardfor type 3.vault-ipc: a typedCardWrite(secrets as zeroizedVec<u8>, redactedDebug) onRequest::Add/Edit(serde-defaulted).vault-agent:add_cipherbuilds aPlainCard;apply_cipher_editssets only the given card fields (others preserved).- Identity create/edit remains the tracked follow-up (read-only for now).
Tests:
from_plain→decryptcard round-trip;apply_cipher_editscard partial-update;CardWritetransport +Debug-redaction; CLIsplit_expiry.
-
TUI card/identity detail render. Selecting a card or identity in
vault-tuinow shows its fields in the detail pane: card brand/expiry with a masked number (Spacereveals it, re-masked on navigation) and masked CVV; identity name/email/phone/address.ccopies the primary field per type (card number / identity email / login password);Spacereveals the primary secret (card number / login password). Non-sensitive fields are fetched on select via the existingRequest::Get; the card number/CVV are fetched only on reveal — no card secret enters the TUI until asked. No proto/agent/core change (reuses the PR #23Fieldselectors). This completes card/identity support end-to-end (CLI + TUI). Tests:primary_secret_field/primary_copy_fieldunits +TestBackendrenders for card (masked number, revealed) and identity. -
Card & identity cipher types (read, CLI).
vault-corenow models card (type 3) and identity (type 4) ciphers — full field sets, decrypted intoPlainCard/PlainIdentity(sensitive fields zeroized on drop) via newDecryptOptions { card, identity }. NewFieldselectors —card-number/card-brand/card-expiry/card-codeandidentity-name/identity-email/identity-phone/identity-address— make them readable:vault get <item> --field card-number. The agent composes the derived ones (card-expiry→MM/YYYY;identity-name/-addressjoined). Rendering them in the TUI detail pane is the next slice (the list path only carries name/username today). Tests: vault-core card/identity decrypt; vault-agentget_itemcard-number/expiry +NoSuchField; vault-ipcFieldround-trip. No new deps. -
Live TOTP codes from login ciphers. A login's stored
totpfield (anotpauth://totp/…URI or a bare base32 secret) now yields the current RFC 6238 code rather than the raw secret:vault get <item> --field totpprints it, and the TUI's newtkey copies it. The code is generated in the agent, so the secret never crosses the socket. Newvault-core::totpmodule (HOTP/dynamic-truncation, hand-rolled base32, otpauth parsing; SHA1 default + SHA256/SHA512) behind the one new depsha1. RFC 6238 vector + base32 + parse unit tests; an agent test thatField::Totpreturns a 6-digit code (not the secret).Field::Totp/FieldArg::Totpalready existed — only the value changed and thetkey was added. -
Interactive TOTP two-factor auth. A 2FA-enabled account is no longer a dead end on the password grant: on
Error::TwoFactorRequired, prompt for the authenticator code and resubmit the token grant with it.vault-api:login_passwordgainstwo_factor: Option<&TwoFactor>and appendstwoFactorToken/twoFactorProvider/twoFactorRemember; a wrong code re-challenges so the caller re-prompts.vault-ipc:TwoFactorCode+ serde-defaultedtwo_factoronRequest::Unlock(forward-compatible).vault-agentthreads it into the password grant (provider0= authenticator).vault-cli:vault login/unlockresubmit on a challenge, reading the code from the controlling terminal (/dev/tty) so it works even with the password piped to stdin;--totp/$BW_TOTPsupply it non- interactively.vault-tui: an "Authenticator code" step in the unlock screen (stashes the password, resubmits with the code).- Scope: the authenticator/TOTP provider only; other providers (email, Duo, WebAuthn) and "remember this device" are future work. An API key still bypasses 2FA entirely.
- Tests:
vault-apiwiremock challenge→resubmit;vault-ipctransport round-trip;vault-tuibegin_2fa/request-with-code units.
-
tui.vim— vim jump motions in the TUI. Opt-in (vault config set tui.vim true): on top of the defaulthjkl, the browser gainsgg(top),G(bottom), andCtrl-d/Ctrl-u(half-page; a fixed step — the pure-renderApphas no viewport height). Becausegbecomes theggprefix in vim mode, the generator overlay moves fromgtoCtrl-g; non-vim mode is unchanged (gopens the generator). Read byvault-tuidirectly (not relayed to the agent). NewAppmotions (move_top/move_bottom/page_up/page_down, reusing the focus-aware re-maskingmove_*pattern) + aggprefix state machine. Completes the PRD §7.1 config registry. Tested invault-config(round-trip / reject / not-an-agent-flag) andvault-tui(jump- clamp + empty-list no-op + the
ggarm/take sequence).
- clamp + empty-list no-op + the
-
ui.reduced_motionconfig key (reserved). Completes the PRD §7.1 config registry with the accessibility preference to suppress animated TUI elements. The TUI has no animations yet (it's fully event-driven — no spinner, no lock-countdown, no blink), so the key is inert groundwork:vault-configrecords/validates it andvault-tuipopulates anApp.reduced_motionflag from it, ready for a future spinner / lock-countdown to honor without re-plumbing. It's a TUI-rendering preference, read by the TUI directly (not relayed to the agent). Tested invault-config(round-trip, rejects non-booleans, never emitted as an agent flag). -
TUI About overlay.
vault-tuigains a read-only About screen (open with?or:about, dismiss withEsc/q) showing the version, maintainer, copyright/license, and canonical URL — rendered from the sameATTRIBUTION/PKG_VERSIONconstants asvault-tui --version, so it can't drift. This was the last unfilled v0.1 success criterion that the Standard §13.2 block appears "in--version,--help, README, and the TUI About screen" (PRD §14). NewInputMode::About+render_about(modeled on the generator overlay); aTestBackendtest asserts the §13.2 content reaches the screen. -
clipboard.backendselection. New config key choosing how the TUI's copy keys reach a clipboard:auto(default — nativearboard, else the TUI falls back to OSC52),arboard(force native; warns if unavailable), orosc52(the agent declines so the TUI copies through the terminal via an OSC52 escape — for SSH/tmux, so copies land on the local machine). The agent can't emit OSC52 itself (no terminal);osc52just makes it step aside for the TUI's existing client-side path.clipboard::BackendChoice+select()(wrapping the existingdetect()); a--clipboard-backendflag (defined unconditionally so a headless agent accepts it as a no-op) flowing from the config like the other keys.vault statusreportsosc52even with no native backend held.- Tests:
vault-configvalidation/round-trip +agent_argsemission;BackendChoiceas_str/default,select(osc52)isNone(CI-safe), and aStatustest for theosc52-mode label.
-
Scheduled background sync. New
sync.interval_secsconfig key: while unlocked, the agent re-pulls/syncon that cadence (atokiointerval task,server::scheduled_sync_loop), refreshing the in-memory vault and the encrypted on-disk cache so it never drifts from the server and an offline/PIN unlock later reads fresh data — no manualvault sync.0/unset disables.- Reuses
AgentState::resyncwholesale; best-effort — aLocked/Offline/ network result is logged and skipped. It deliberately does nottouch(), so background syncs never defer the idle-lock countdown. - Flows config → auto-spawn flag (
--sync-interval-secs) likeidle_lock_secs; takes effect on the next agent spawn.last_sync(already invault status) surfaces freshness — no protocol change. - Tests:
vault-configget/set/unset +agent_argsflag emission; an agent invariant test that a lockedresyncis a cleanLockedno-op and doesn't movelast_activity.
- Reuses
-
Session resume across agent restart (opt-in, Linux). With the new
agent.session_keyringconfig key, an unlock mirrors the user key into the Linux kernel session keyring (kernel memory — never on disk, never swapped, possessor-gated, evicted on logout). A restarted agent (crash,SIGTERM,stop-agent+ auto-spawn) reads it back and resumes unlocked without the master password, bounded by the idle-lock TTL: the keyring entry carries a kernel timeout (refreshed on activity, throttled), so a dead agent's session self-expires.- Lock semantics split: explicit
vault lockand idle-lock clear the keyring (durably locked);stop-agent/SIGTERM/crash leave it so the next agent resumes.AgentState::lock(in-memory wipe) vs the newlock_and_clear_session. - New
crates/vault-agent/src/session.rsover the pure-Rustlinux-keyutilscrate ([target.'cfg(target_os = "linux")'], MIT/Apache-2.0); a no-op stub elsewhere.main.rsattempts resume at startup via the existingunlock::load_cache+vault_from_user_keypath. vault config set agent.session_keyring true(flows to the auto-spawned agent as--session-keyring, mirroringidle_lock_secs).- This is the sole, default-off carve-out to PRD §7.3 / G4 ("master key never resident outside the agent process"); documented in PRD §7.3. Off, the key never leaves the process. Linux-only — a no-op everywhere else.
- Tests:
SessionBlobserde round-trip (cross-platform) + a keyring store/load/clear round-trip that skips gracefully where no keyring exists;vault-configagent.session_keyringget/set/unset +agent_argsflag emission.
- Lock semantics split: explicit
-
Bitwarden personal API-key authentication (2FA accounts). A new auth path that uses the OAuth2
client_credentialsgrant, which is not 2FA-challenged — so an account with two-factor auth enabled can finally authenticate without an interactive TOTP prompt (Vault has no TOTP entry). The API key authenticates the session only; theKeyit returns is still wrapped under the stretched master key, so the master password is still required at every unlock to decrypt the vault.vault login --api-key: reads$BW_CLIENTID/$BW_CLIENTSECRET(matching the officialbwCLI), else prompts, then reads the master password. On success the agent authenticates viaclient_credentialsand persists the key (0600apikey.jsonin the account data dir) so plainvault unlock(and the TUI unlock) auto-reuse it — no 2FA, no re-supplying creds.vault apikey status/vault apikey forgetmanage the stored key (status echoes only the non-secretclient_id; forget falls back to the password grant).vault-api:BitwardenClient::login_api_key(grant_type=client_credentials,scope=api).vault-store:ApiKeyCreds+save/load/delete_apikey_to_dir(atomic, 0600, customDebugthat redacts the secret).vault-ipc: optionalapi_keyonRequest::Unlock(serde-defaulted, forward-compatible) plusApiKeyStatus/ApiKeyForgetverbs and anApiKeyStatusresponse (never carries the secret).vault-agent: grant selection (request creds → persisted key → password) with persistence on enrollment, andensure_onlinenow falls back to API-key re-auth — so a PIN/offline session of an API-key account can still go online forsync/edits even when the grant issued no refresh token.- The key is protected at rest by filesystem permissions (0600) only: it must be readable before unlock (it's used during auth), so it can't be wrapped under the user key — the same trust level as the stored refresh token.
- Tests:
vault-apiwiremock for theclient_credentialsform + a bad-keyServerStatus;vault-storeround-trip / delete /NotFound/ 0600-mode / Debug-redaction units;vault-ipctransport round-trips (api_keypresent- absent-decodes-to-
None,ApiKeyStatus). No new external dependencies.
- absent-decodes-to-
-
TUI in-place unlock. When the agent is locked,
vault-tuino longer dead-ends at a "runvault unlock" banner — it shows an interactive unlock prompt for the registered account: type the master password, orTabto a PIN when one is enrolled, and drop straight into the browser. Reuses the readlineTextInput(secret masked with•) and the existingUnlock/UnlockPinrequests — no protocol or agent changes; failed unlocks show the error (incl.BadPin { attempts_remaining }/PinLockedOut) and clear the field.- To read the account (server/email/device_id) — which a locked agent
doesn't report —
config.rswas extracted fromvault-cliinto a sharedvault-configcrate, now used by both the CLI and the TUI (single source of truth forconfig.toml; the agent still doesn't read config). - Tests:
vault-config's units moved with it; newvault-tuiunits forUnlockState::request(password vs PIN),toggle_pin(no-op without enrollment, clears on switch),unlock_failed, and aTestBackendsmoke that the unlock screen shows the account, masks the secret, and offers theTabhint only when a PIN is enrolled. No new external dependencies.
- To read the account (server/email/device_id) — which a locked agent
doesn't report —
-
Token persistence + refresh. The OAuth2 refresh token is now kept and reused, so a cache/PIN/offline session can become fully capable and a long-lived session survives access-token expiry.
vault-api:BitwardenClientkeeps therefresh_tokenfromlogin_password, exposes it (refresh_token()), and can be seeded with one (with_refresh_token). Newrefresh()runsgrant_type=refresh_token. The four server ops (sync/create/update/delete) route through onesend_with_authhelper that, on a401with a refresh token held, refreshes once and retries.- The refresh token is persisted encrypted under the user key in the cache
(
refresh_tokenenvelope field) on online unlock + everysync, and recovered into the in-memory session on a cache/PIN unlock. - New
Vault::ensure_online: a token-less session (offline / PIN) lazily upgrades to online on its firstsync/add/edit/removeby building a client from the stored refresh token and refreshing — so PIN unlock stays fast/offline but server ops "just work" when the network is up. A genuinely offline box (or no stored refresh token) still returnsError::Offline. - Tests: store refresh-token round-trip;
ensure_onlinereturnsOfflinewithout a client/token andOkwith a live client; refresh-token recovery from the cache on offline unlock. No new dependencies.
-
PIN unlock. Unlock the vault with a short PIN instead of the master password (like the Bitwarden extension/desktop), built on the encrypted cache.
vault pin set(requires an unlocked agent) encrypts the user key under a key derived from the PIN — same KDF/stretch/EncStringcrypto as the master path, PIN as the secret, email as salt — and stores it aspin_protected_user_keyin the cache (envelope schema 3).vault pin disableforgets it;vault pin statusreports enabled + attempts left.vault unlock --pinrecovers the user key from the cache with the PIN and builds a read-only session (no token —sync/add/edit/removereturnError::Offline, like an offline unlock). Plainvault unlockstays master-password.- Lockout: wrong PINs are counted in the cache (so the limit survives an
agent restart); the 5th wrong PIN wipes
pin_protected_user_keyand returnsPinLockedOut— re-enable after a master-password unlock. Wrong PINs before that returnBadPin { attempts_remaining }. New typed errorsBadPin/PinLockedOut/PinNotSet(CLI exit codes 12/13/14). PIN must be ≥ 4 characters (validated client-side). - The cache→vault recovery core is shared between the offline-master and PIN
paths (
recover_user_key+vault_from_user_key); the attempt/lockout logic is a purepin_attemptover an in-memory cache, with a thin disk-backedunlock_pinwrapper. - Tests: store pin-field round-trip; pure
pin_attemptlifecycle (recover → reset counter → 5-strike lockout + key wipe → stays locked),PinNotSetwith no enrollment, andpin_protect_user_key↔recover_user_keyround-trip. No new dependencies. - Out of scope (tracked): TUI PIN entry; a PIN/offline session syncing once token persistence lands; Bitwarden's "require master password on restart" (memory-only pin key) mode.
-
Encrypted-cache persistence + offline unlock. The agent now writes its vault to disk and can unlock without the network — the substrate for the upcoming PIN unlock (and useful on its own).
- On an online
unlock(and everysync), the agent persists$XDG_DATA_HOME/vault/<account>/cache.json(<account>= sanitizedhost_email): the/syncresponse encrypted under the user key, plus theprotected_user_key(the login tokenKey— the user key encrypted under the master-stretched key, safe at rest) and the accountkdfparams.vault-storewas already built for this but had never been wired in;VaultCacheis now schema 2 (new fields are serde-defaulted, so any older file still loads). - Offline unlock: when a live login fails with a network error and a
cache exists,
unlockfalls back to the cache — re-derives the master key locally from the cached KDF params, decrypts theprotected_user_key(the EncString MAC check detects a wrong password →BadPassword), and loads ciphers from the encrypted payload. Bad password / 2FA still propagate (no fallback). Unlock now survives restart and works without connectivity once you've unlocked online once. - An offline session has no access token, so
Vault.clientisNoneand sync / add / edit / remove return the new typedError::Offline("unlock again while online…", CLI exit code 11). Read paths (status / list / get / copy / TUI browse) work fully from cache. (Local mutations don't re-persist the cache yet, so it can lag edits until the nextsync— tracked.) - Tests:
KdfParamsserde round-trip;VaultCacheprotected-key + kdf round-trip and legacy-v1 load; a pureunlock_from_cacherecovery + wrong-password rejection;account_dir_namesanitization; and the offline-sessionError::Offlinegating. No new dependencies.
- On an online
-
TUI text-input editing — readline keys + bracketed paste (PRD §7.2). The
/search,:command line, and every add/edit form field were append/backspace-onlyStrings with no cursor and no paste. They now share aTextInput { buf, cursor }with real line editing:- Cursor movement
←/→/Home/End(+Ctrl+A/Ctrl+E),Deleteat the cursor, and insert anywhere — fix a mid-word typo without deleting back to it. - Kill/yank into a shared kill-ring:
Ctrl+W(word back),Ctrl+U(to start),Ctrl+K(to end),Ctrl+Y(yank). - Bracketed paste (
EnableBracketedPaste+Event::Paste): paste a value from the system clipboard — including over SSH/tmux — at the cursor; newlines are stripped (every input is single-line). Ctrl+Ssubmits the add/edit form (PRD §7.2 save). On the form's Type row←/→/Spacestill toggle login⇄note; cursor keys edit only in text fields. The caret renders where edits land (gi▌t), not just at the end.Ctrl+Cremains the global quit — a deliberate deviation from §7.2's literalCtrl+C=copy: there's no selection model, and quit-safety/muscle memory win. Deferred (tracked): selection +Ctrl+C/X/V/Zcopy/cut/paste/undo, and reading the agent clipboard back for paste.- TUI-only — no protocol, agent, CLI, or dependency changes (crossterm 0.28
already exposes the paste API). Tests:
TextInputunits (insert/delete at cursor, nav clamping, kills return removed text, paste at cursor, UTF-8 boundary safety), kill→yank round-trip, paste-into-focused-field, search-edit re-anchor, and a mid-string caretTestBackendsmoke.
- Cursor movement
-
vault register+vault login— account profile (PRD §7.1). The last two unimplemented verbs, as an account-profile flow (not the Bitwarden personal API-key model, which stays a tracked follow-up).registerrecords the account — server, email (lower-cased), and a freshly minted stabledevice_id— into a new[account]table inconfig.toml. No agent or network (a lighthttp(s)://check is the only validation; real errors surface at firstlogin). Re-registering keeps the existingdevice_id.loginauthenticates against the registered account (master password →Request::Unlock) and ends on a sync summary (logged in as <email> · <n> items · synced <ts>, or--json). It auto-spawns the agent, so a coldvault loginbrings everything up.unlocknow resolvesserver/emailfrom the profile too, so its flags (and$VAULT_SERVER/$VAULT_EMAIL) are optional once registered; precedence is explicit flag/env → profile → error.loginandunlockshare oneRequest::Unlock— the difference is resolution andlogin's verbose, status-backed success.- Stable device id.
Request::Unlockgains a serde-defaulteddevice_id: Option<String>; the agent uses it as the BitwardendeviceIdentifierinstead of minting a fresh UUID each unlock, so the account stops accumulating a new device per session. OldUnlockframes without the field still decode (regression-tested). - Tests:
[account]round-trip + "no empty table until set" + device-id mint/preserve + email lower-casing;resolve_accountprecedence (flag → profile → error); theUnlockdevice-id round-trip and old-frame-decode regression. Newuuiddep onvault-cli(already in the workspace tree).
-
vault config+vault purge(PRD §7.1). A persistent, typed config file and the two remaining settings/maintenance verbs.- Config file at
$XDG_CONFIG_HOME/vault/config.toml, modeled as a known-key registry:vault config set <key> <value>validates the key and parses the value to its type (a typo or non-numeric value is rejected, not silently kept),vault config unset <key>clears it, andvault config getwith no key lists every known key with its effective value (--jsonon all three). Writes are atomic (tempfile + rename). Recognised keys this release:clipboard.clear_secsandagent.idle_lock_secs(bothu64,0disables). - Wired into auto-spawn. When the CLI starts the agent, it sources those
two knobs from the config and passes them as launch flags — so a saved
clipboard.clear_secsfinally has a persistent home instead of being flag/env-only. On the auto-spawn path the config value populates the agent flag (winning over$VAULT_CLIPBOARD_CLEAR_SECS); a manually launched agent is unaffected, and a malformed config is reported and skipped rather than blocking the spawn. vault purgedrops a running agent's in-memory keys (best-effortLock, never auto-spawning) and removes the on-disk item cache ($XDG_DATA_HOME/vault). Confirmation-gated likeremove(--forceto skip; required when stdin isn't a TTY); an absent cache is success.- CLI-only slice — no protocol, agent, or TUI changes; the agent still
receives knobs as the launch flags it already accepted. New
tomldependency (MIT/Apache, within thedeny.tomlallow-list). - Tests: config registry units (set/get/unset round-trips, unknown-key and
bad-value rejection,
0accepted,KNOWN_KEYSreachable by all three ops, TOML serde round-trip) and a pureagent_argstest for the config→launch-flags mapping (both set / one set / none).
- Config file at
-
Clipboard hardening — clear-on-lock sweep, backend trait, OSC52 fallback, configurable interval. Closes the secret-can-outlive-the-agent limitation tracked since the M5 copy slice, and delivers PRD §7.5 in its architecturally honest shape.
- Clear-on-lock sweep. The agent remembers the last value it copied
(
Zeroizing, dropped once a timer clears it) andlock()now sweeps it off the clipboard — coveringvault lock,Quit/stop-agent, idle-lock, and the new SIGTERM handler (PRD §7.3: the agent locks, sweeps, removes its socket, and exits on SIGTERM). The sweep keeps the only-if-still-ours rule: a newer copy by the user is never clobbered. - Backend trait (PRD §7.5). New
vault-agent/src/clipboard.rsdefines aBackendtrait (arboardis the one system implementation; detection unchanged) so tests inject a fake clipboard — the sweep logic is now exercised end-to-end on headless CI — and a config-selected backend can slot in later.Statusgains a serde-defaultedclipboard_backendfield ("arboard"/ absent) so clients can see what they're talking to; old-agent frames still decode (regression-tested). - OSC52 fallback (client-side, by design). OSC52 copies by escaping to
the user's terminal — something the detached agent fundamentally cannot
do, so PRD §7.5's SSH/tmux fallback lives in the TUI: when the agent
declines with the new typed
Error::ClipboardUnavailable, the TUI fetches the value (id-targetedGet— the one path where the secret crosses the local UDS, same asvault get) and emits the OSC52 sequence itself, with tmux DCS-passthrough wrapping when$TMUXis set (set-clipboard onrequired). The TUI runs its own 30 s timed clear (OSC52!payload) while it lives, and sweeps on quit; a generated password falls back without anyGetsince it's already local. - Configurable interval.
vault-agent --clipboard-clear-secs N(then$VAULT_CLIPBOARD_CLEAR_SECS, then 30;0disables) sets the agent's default;Request::Copy/CopyTextstill override per call. The newResponse::Copied { clear_after_secs }reports the effective window, so the TUI's toast now shows the agent's real interval instead of a hardcoded 30. The futurevault configfile maps onto the same knob. - Tests: fake-backend units for sweep-on-lock, never-clobber-newer-copy,
and timer/sweep marker interplay;
Statusbackend-name reporting; interval-resolution precedence; OSC52 sequence/clear/tmux-wrapping units; and the old-agentStatusdecode regression.
- Clear-on-lock sweep. The agent remembers the last value it copied
(
-
M5 (slice 5) — CLI agent auto-spawn + headless feature gate. The two non-TUI M5 items, closing out the milestone.
- Auto-spawn (PRD §7.3). Any
vaultverb now startsvault-agentitself when the socket is dead (missing file or stale, connection-refused socket — other errors, e.g. permissions, still surface directly). The CLI locates the agent via$VAULT_AGENT_BIN, then avault-agentsibling of thevaultbinary, then$PATH; starts it in its own process group (--socketpassed explicitly, stdin/stdout null, stderr appended toagent.logbeside the socket in the 0700 runtime dir); and poll-connects until the agent accepts (2 s deadline, 25 ms interval), reusing the first accepted stream. Opt out per-call with the global--no-auto-spawn;stop-agentnever spawns (stopping a dead agent shouldn't start one). Newvault-cli/src/spawn.rsmodule; the old "start the daemon withvault-agent &" hint remains only on the no-spawn paths. - Headless gate (PRD G6). The
vaultbin now carriesrequired-features = ["cli"], making the documented server install literal:cargo install --path crates/vault-cli --no-default-features --features cli(pair withcargo install --path crates/vault-agent --no-default-featuresto drop the clipboard's X11/Wayland tree). A new CIheadlessjob builds both combos so the gate can't rot. README's Status and headless sections updated to match reality. - Known limitation: two racing CLI invocations can each spawn an agent; the second bind steals the socket path (the listener removes a pre-existing socket file) and the first agent is orphaned until its idle lock. Benign for the single-user posture; a flock around spawn is a possible follow-up.
- Tests: binary-resolution precedence (override > sibling >
$PATH, empty override ignored), dead-socket error classification, and poll-loop behavior (picks up a late listener; gives up at the deadline).
- Auto-spawn (PRD §7.3). Any
-
M5 (slice 4) — TUI mutations:
aadd,eedit,ddelete (confirm). PRD §7.2's Mutation row goes live, completing the daily-driver loop (browse → search → reveal/copy → mutate) without falling back to the CLI. Pure TUI slice: the agent's M4 write paths (Request::Add/Edit/Remove) are driven as-is — no protocol changes, no agent changes, no new dependencies.- Add (
a). A centered form overlay with a Type row (login ⇄ secure note, toggled with Space/←/→) over Name / User / Pass / URI / Folder / Notes (notes expose only Name / Folder / Notes). Tab/↓ and Shift-Tab/↑ cycle fields (wrapping); Enter validates (name required) and submits; Esc discards. Ctrl+G in the Pass field fills it with a fresh default-options password (PRD's "generate into the active field" story). Values typed under one type survive a toggle, but hidden login fields never leak into a secure-note submit. - Edit (
e). Same form, prefilled with the metadata the list already has (name / username / folder); type is fixed. Submit diffs against the prefill: untouched fields ride as "unchanged" on the wire (so an edit never re-encrypts a password it didn't see), a cleared field submits empty. The selected row's exact cipher id is the selector, so duplicate names can't mislead it. An edit with no changes is rejected in-form. - Delete (
d). A small confirm overlay (Delete 'name'? y/N);y/Enter sendsRequest::Removewith the exact id,n/Esc backs out. - On success the vault reloads and a toast reports
saved '…'/deleted '…'; on any error the form stays open so nothing typed is lost. Opening any mutation overlay re-masks a revealed secret. The unfocused Pass field renders masked; form secrets are redacted inDebug(FormState/FormSubmit). - Known deviation (tracked): PRD §7.2's full CUA bindings (Ctrl+C/X/V/Z/S/F) and bracketed paste in text inputs are deferred — fields accept typed input only this slice.
- Tests: 11 new
vault-tuiunits (form open/prefill/gating, focus wrap, type-toggle value preservation, Ctrl+G targeting, submit diff/validation, note-residue exclusion, confirm gating/take/cancel, overlay re-masking,Debugredaction) plusTestBackendsmokes for the form overlay (masked unfocused Pass) and the confirm overlay.
- Add (
-
M5 (slice 3) — TUI search, generator overlay, and
:command line. The three previewed keys go live:/filters the item list as you type,gopens a password-generator overlay, and:opens a small vim-style command line.- Search (
/). Live, case-insensitive substring match on item name and username, composed on top of the active folder filter. Enter accepts the query (filter stays, shown in theItems (n) /querypane title), Esc in search mode drops it, and Esc in normal mode peels an active filter back before quitting. Every query edit re-anchors the selection and re-masks any revealed secret. Arrow keys still move the selection mid-search. - Generator (
g). A centered overlay over the browser showing a freshly generated password (vault-core'sgenerate_password, same engine asvault generate):g/rregenerate,+/-adjust length (clamped 8–128, Bitwarden's ceiling),stoggles symbols,ccopies,Esccloses. The password lives in aGeneratorState(zeroised on drop, redacted inDebug). - Copying a generated password uses a new
Request::CopyText { text, clear_after_secs }: the value rides the local UDS once (exactly likeUnlock's password already does), and the agent writes it to its own clipboard with the same 30-second auto-clear machinery asRequest::Copy. Requires an unlocked agent; headless (--no-default-features) builds decline it cleanly. - Command line (
:). Deliberately tiny vocabulary:q/quit,r/refresh,sync(agent re-pulls/sync, list reloads),lock(agent drops keys, screen flips to the Locked banner). Unknown commands toast the vocabulary. The status bar echoes the line being edited (/query▌/:cmd▌) ahead of toasts and hints. - Tests:
vault-tuiadds search/compose/re-anchor, command-buffer, and generator (defaults, regenerate, clamp, symbols,Debug-redaction) units plusTestBackendsmokes for the query title/status echo, command echo, and generator overlay;vault-agent's locked-session test now coversCopyText-while-locked. No new dependencies.
- Search (
-
M5 (slice 2) — TUI reveal + clipboard copy. The detail pane is no longer secret-free:
Spacereveals the selected login's password on demand andc/u/ocopy the password / username / URI to the clipboard, with a status-bar toast (copied password · clears in 30s) and a 30-second auto-clear. Copy/reveal act only when the item list is focused.- Clipboard lives in the agent, not the TUI. A new
Request::Copy { id, name, field, clear_after_secs }has the agent decrypt the field, place it on its own clipboard (arboard,wayland-data-control), and schedule the clear — so the secret never crosses the socket on the copy path, the copy survives the TUI quitting, and a futurevault get --copybecomes possible. The auto-clear task only wipes the clipboard if it still holds what we wrote (or can't read it back, failing safe), leaving anything the user copied since untouched. Behind a default-onclipboardfeature onvault-agent; a--no-default-featuresheadless build drops the X11/Wayland tree and answersCopywith a clean "not compiled in" error. - Reveal uses
Request::Get, which gains anid: Option<String>field. The TUI targets the exact selected cipher id, closing a real footgun:get_itemmatched by name only, so a duplicate item name could reveal/copy the wrong item. Name remains the fallback selector and error label; the CLI passesid: None(unchanged behavior). Revealed plaintext is held in aRevealedSecretnewtype (zeroised on drop, redacted inDebug) and re-masked on any navigation. - Tests:
vault-agentgains an id-targeting-among-duplicate-names regression test and a pureshould_clear_clipboardunit;vault-tuiadds reveal / re-mask / toast /Debug-redaction units andTestBackendsmokes for the masked-by-default, revealed, and toast states. - Supply-chain:
arboardpullserror-code(BSL-1.0, via Windows-onlyclipboard-win) — added todeny.toml's allow-list (FSF-confirmed GPL-compatible). No new advisories (cargo deny check advisoriesclean). - Known limitation: on
Quit/stop-agenta pending auto-clear task dies with the runtime, so a just-copied secret can linger on the clipboard until overwritten (notably underwayland-data-control's serving process). A clear-on-shutdown sweep is a tracked follow-up.
- Clipboard lives in the agent, not the TUI. A new
-
M5 (slice 1) —
vault-tuiskeleton (read-only browser). The TUI stub is now a real cruxpass-style three-pane interface (ratatui+crossterm): left folder list, center filterable item list, right item detail, with a status bar. It is just another UDS client of the agent — the user key never crosses into it — and drives only the existingRequest::Status+Request::List, so no IPC change.- Requires a pre-unlocked agent; a locked or absent agent shows a centered
banner (
Locked/No agent).rrefreshes (re-runs Status + List). - Keys:
q/Esc/Ctrl+Cquit,j/k+ arrows move,Tab/h/lswitch pane focus. Folder selection filters the item list (All/Unfiled/ named folders, derived from the entries). The status bar previews the/ c u o g :keys as coming-soon so the layout is final. - Detail is read-only and secret-free this slice: it shows only the
non-secret
ListEntrymetadata (name, type, username, folder, id) the agent already returned — reveal/copy (which needRequest::Get) land with the copy slice. Terminal teardown is RAII + a panic hook, so a panic never leaves the terminal in raw mode.vault-tui --versioncarries the §13.2 block. - New modules
app(state + pure nav/filter logic, 6 unit tests),ui(rendering + a#RRGGBB→Colortheme helper overvault_theme::steelbore, 2TestBackendrender smoke tests), andclient(UDS request helper). - Supply-chain:
ratatui's tree adds two informational advisories with no fix short of dropping ratatui — RUSTSEC-2024-0436 (paste, unmaintained build-time proc-macro) and RUSTSEC-2026-0002 (lru0.12.5, unsoundIterMut, transitive).pasteis ignored indeny.toml; therustsec/audit-checkCI job ignores both (it fails on any advisory, unlikecargo deny) and gainschecks: writeso it can post its check-run annotations. Both documented with revisit notes.
- Requires a pre-unlocked agent; a locked or absent agent shows a centered
banner (
-
M4 (slice 4) —
--jsonon the lifecycle verbs + a realvault sync. Closes the two remaining M4 items.unlock,lock,sync, andstop-agentnow take--json. They stay silent on success in human mode (unchanged — existing pipelines keep working); under--jsoneach emits a small envelope:{"unlocked":true},{"locked":true},{"stopped":true}, and for sync{"synced":true,"items":N,"last_sync":"…"}.vault syncnow actually re-pulls/syncover the unlock-time session and replaces the in-memory ciphers, folder map, andlast_syncstamp (it was an M3 stub that only checked the unlocked flag). The agent answers a successful re-sync with a freshStatussnapshot, so--jsoncan report the new item count. Parity withunlock: only the in-memory vault is refreshed — the agent still doesn't write the on-diskvault-storecache. Known limitation: asynclong afterunlockcan401once the access token expires (no refresh-token flow yet in M4); it surfaces asIpcError::Network, same asadd/edit/remove.- The
/sync→(ciphers, folders)decode is factored out ofperform_unlockinto a sharedunlock::ciphers_and_folders, now the single spine of bothunlockandresync. Tests: two direct unit tests on that function (typed-view decode + malformed-folder skipping); the CLI'scmd_simplewas retired in favour ofcmd_ack(Ok-only acks) and a dedicatedcmd_sync.
-
M4 (slice 3) —
vault add+vault edit. The two remaining write verbs, the inverse of the read path: caller-supplied plaintext fields are encrypted inside the agent (the user key never leaves it) andPOST/PUTto the server. Login (--type login) and secure note (--type note) are supported.vault add <name> [--type login|note] [--username U] [--uri URL] [--folder F] [--notes N] [--generate[=LEN]] [--json]. The password is read from stdin or generated locally with--generate(printed back so the user has it); no--passwordflag, so secrets never enter argv / shell history.vault edit <selector> [--name|--username|--uri|--folder|--notes ...] [--password (stdin)] [--generate[=LEN]] [--json]. Only the flags you pass change;editre-encrypts just those fields onto a clone of the original encrypted cipher, so everything it doesn't individually edit — secondary URIs, custom fields, organization membership — survives verbatim.--urireplaces the primary URI and keeps the rest. Folder is resolved by id or case-insensitive name.- New
vault_core::Cipher::from_plain(the encryption inverse ofdecrypt),vault_api::BitwardenClient::{create_cipher, update_cipher}(POST/PUT /api/ciphers, camelCase request body with asecureNotemarker on type 2),Request::Add/Request::EditandResponse::Saved { id, name }, and the agent'sadd_cipher/edit_cipher(with folder name→id resolution). Tests: 3from_plainround-trips (vault-core);resolve_folderand twoapply_cipher_editscases proving a secondary URI survives an edit (agent); and#[ignore]d wiremock create/update + secure-note-marker tests (api).
(
add+editcomplete the write verbs;--jsonon the lifecycle verbs and the realvault synclanded in slice 4 above. M4 feature work is complete — the remaining M4 gate is the end-to-endadd → list/get → edit → get → removerun against a real Vaultwarden perdocs/m2-vaultwarden.md.)
-
vault-agentclippy debt from M4 slice 4 (the stale-artifact gremlin, again). A fresh full compile surfaced three findings a warm clippy cache had masked at commit time: anunused_import(Error as IpcErrorinserver.rs, now only referenced from the test module, so moved there) and tworedundant_pub_cratehits (unlock::ciphers_and_folders/now_isoarepubinside a private module, notpub(crate)). No behaviour change — purely the lints CI's cold compile would have failed on. -
CI is green for the first time (M0–M3 had been red on every push). Four jobs were failing independently of the code's behaviour:
clippy -D warnings— the workspace lints enableclippy::pedantic+clippy::nurseryand denyunwrap/expect/panic, but clippy had never run to completion locally (a stale-artifact issue), so the debt was never seen. Resolved across every crate:# Errors/# Panicsdoc sections,const fn,#[must_use], let-chains for collapsibleifs,map_or,sort_by_key, derivedDefault,Send/Syncbounds on the transport generics (future_not_send), and justified#[allow]s on the infallible-HMAC/RNGexpects and the civil-calendar casts invault-agent::unlock.vault --versionnow actually emits the §13.2 attribution block. clap only surfacesafter_helpon--help, so the block was missing from--version; it now rides inlong_version(mirrored invault-agent).- rustfmt —
cargo fmt --allapplied. - cargo-deny — two policy decisions, called out explicitly: (1) allow
CDLA-Permissive-2.0(webpki-roots ≥ 1.0 ships Mozilla's CA bundle under it — a permissive data licence, GPL-compatible, no copyleft on the linking program); (2) mark the eightvault-*cratespublish = falseand setallow-wildcard-paths = trueso intra-workspacepathdeps stop tripping the wildcard ban. Aclippy.tomlpermitsunwrap/expect/panicin tests only.
- M4 (slice 2) —
vault remove. Soft-deletes a cipher viaDELETE /api/ciphers/{id}and drops it from the in-memory cache. CLI:vault remove <selector> [-f|--force] [--json]. The selector matchesCipher.idexactly first, then falls back to a case-insensitive decrypted-name match; if a name resolves to more than one cipher the agent refuses withAmbiguousItem(CLI exit 10) and prints the matching ids so the caller can retry with the explicit UUID. Interactive callers must re-type the selector to confirm; non-TTY stdin requires--force.vault-agent::Vaultnow owns the authenticatedBitwardenClient(replacing the dead-codeaccess_tokenfield) so future M4 verbs reuse one session. NewIpcError::AmbiguousItem { name, ids }variant; newResponse::Removed { id, name }. Three new tests:resolve_cipher_matches_by_id_then_name,resolve_cipher_rejects_ambiguous_name(agent), and an#[ignore]d wiremock testdelete_cipher_sends_authorized_delete(api) that asserts theBearerheader and surfaces 404 asServerStatus. - M4 (slice 1) —
vault generate. Pure-local password generator with no agent or server interaction.vault-core::generate::generate_passwordtakes aGenerateOptions(length + per-class toggles for lowercase, uppercase, digits, symbols) and returns aZeroizing<String>. Sampling uses OSgetrandomwith 64-bit rejection sampling to avoid modulo bias; output is seeded with one character from each enabled class then Fisher–Yates shuffled. CLI verbvault generate [--length N] [--symbols] [--no-lowercase] [--no-uppercase] [--no-digits] [--json]. 8 integration tests incrates/vault-core/tests/generate.rs. - M0 scaffolding: Cargo workspace, eight member crates (
vault-core,vault-api,vault-store,vault-agent,vault-ipc,vault-cli,vault-tui,vault-theme). vault --versionemits the Standard §13.2 attribution block.- Posture files at repo root:
README.md,NOTICE.md,CONTRIBUTING.md,LICENSE,CREDITS.md,CHANGELOG.md. PRD.md— full product requirements document.- CI configuration:
fmt,clippy -D warnings,cargo audit,cargo deny. - M2 — login + sync against Vaultwarden.
vault-core::login::master_password_hash— base64PBKDF2-SHA-256(master_key, password, 1)for/identity/connect/token.vault-api—BitwardenClientoverreqwest+rustls, withprelogin,login_password, andsyncmethods.BaseUrlsaccommodates both Bitwarden's hosted split (api.bitwarden.com+identity.bitwarden.com) and Vaultwarden's single-origin/api+/identitydeployment. Two-factor detection on400withTwoFactorProviders[2]surfaces a typedTwoFactorRequirederror.vault-store—VaultCachewith serde JSON envelope on disk and an encryptedpayloadfield (VaultEncStringover the raw/syncresponse). Writes go through an atomicNamedTempFile::persistrename.- Tests —
vault-api::tests::parsing(7) covers wire-shape decoding;vault-store::tests::cache(4) covers the encrypted persistence round-trip;vault-api::tests::login_syncis the full wiremock integration test, kept#[ignore]pending a clean test-binary linker environment (see file preamble — library and--bin vaultbuild fine). docs/m2-vaultwarden.md— recipe for the real Vaultwarden-in-a-container manual gate.
- M1 — offline export decrypt.
vault-corenow ships:EncString(Bitwarden type 2: AES-256-CBC + HMAC-SHA-256, Encrypt-then-MAC with constant-time verification; legacy types 0/1 explicitly rejected).kdf::{KdfType, KdfParams}andderive_master_keycovering PBKDF2-SHA-256 and Argon2id (with Bitwarden's SHA-256 salt preprocessing).stretch_master_key— HKDF-SHA-256 expansion of a 32-byte master key into a 64-byte(enc, mac)pair using the officialinfo="enc"/info="mac"labels.EncryptedExport— parser and decryptor for password-protected Bitwarden.jsonexports, validating the password againstencKeyValidation_DO_NOT_EDITbefore touching the data payload.- 9 integration tests covering round-trip, tampering detection, both KDFs, wrong-password rejection, and envelope-shape validation.