feat(vault): encrypted-cache persistence + offline unlock#10
Merged
Conversation
Wire up vault-store (built but never used) so the agent persists its vault to disk and can unlock without the network — the substrate for the upcoming PIN unlock, and useful on its own (unlock survives restart / works offline). On an online unlock (and every sync) the agent writes $XDG_DATA_HOME/vault/<account>/cache.json (<account> = sanitized host_email): the /sync response encrypted under the user key, plus the protected_user_key (the login token Key — the user key encrypted under the master-stretched key, safe at rest) and the account kdf params. VaultCache is now schema 2; the new fields are serde-defaulted so older files still load. Offline unlock: when a live login fails with a network error and a cache exists, perform_unlock falls back to unlock_from_cache — re-derive the master key locally from the cached KDF params, decrypt protected_user_key (the EncString MAC check detects a wrong password -> BadPassword), and load ciphers from the encrypted payload. Bad password / 2FA still propagate (no fallback). An offline session has no token, so Vault.client is now Option and is None for cache unlocks; sync/add/edit/remove return the new typed Error::Offline (CLI exit 11). Read paths work fully from cache. Local mutations don't re-persist the cache yet (can lag until next sync) — tracked. KdfParams gains serde derives. No new dependencies (vault-store was already a listed dep). Tests: KdfParams serde round-trip; VaultCache protected-key+kdf round-trip and legacy-v1 load; pure unlock_from_cache recovery + wrong-password rejection; account_dir_name sanitization; offline-session Error::Offline gating. 140 workspace tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's clippy caught three lints a local run missed (running cargo build right before clippy let it reuse fingerprints and skip re-linting the crate): backtick `EncString` in a doc comment, use Option::map_or_else for the cache-fallback match, and rename a too-similar senc/smac test binding pair to stretch_enc/stretch_mac. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wires up
vault-store(fully built but never used) so the agent persists its vault to disk and can unlock without the network. This is the substrate for the upcoming PIN unlock (PR B), and valuable on its own: unlock now survives a restart and works offline once you've unlocked online at least once.Persistence
On an online
unlock(and everysync), the agent writes$XDG_DATA_HOME/vault/<account>/cache.json(<account>= sanitizedhost_email):/syncresponse encrypted under the user key (payload),protected_user_key— the login tokenKey(the user key encrypted under the master-stretched key, safe at rest),kdfparams.VaultCacheis now schema 2; the 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,
perform_unlockfalls back tounlock_from_cache: re-derive the master key locally from the cached KDF params, decryptprotected_user_key(theEncStringMAC check detects a wrong password →BadPassword), and load ciphers from the encrypted payload. Bad password / 2FA still propagate (no fallback).Capability boundary
An offline session has no access token, so
Vault.clientis nowOptionandNonefor cache unlocks.sync/add/edit/removereturn the new typedError::Offline("unlock again while online…", CLI exit code 11); status / list / get / copy / TUI browse work fully from cache. (Local mutations don't re-persist the cache yet — 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; offline-sessionError::Offlinegating. 140 tests green,clippy -D warningsclean,cargo denyclean, both headless builds green. No new dependencies.Next (PR B — PIN)
pin_protected_user_keyin the envelope (user key under a PIN-derived key),vault pin set/disable/status, PIN unlock path, 5-attempt lockout → wipe pin key + require master password. Reuses everything here.🤖 Generated with Claude Code