feat(vault): PIN unlock#11
Merged
Merged
Conversation
Unlock the vault with a short PIN instead of the master password (like
the Bitwarden extension/desktop), built on PR A's encrypted cache.
vault pin set (requires an unlocked agent) encrypts the user key under a
key derived from the PIN — the same KDF/stretch/EncString crypto as the
master path, PIN as the secret, email as salt — and stores it as
pin_protected_user_key in the cache (envelope schema 3). vault pin
disable forgets it; vault pin status reports enabled + attempts left.
vault unlock --pin recovers the user key from the cache with the PIN and
builds a read-only session (no token; sync/add/edit/remove return
Error::Offline, like an offline unlock). Plain unlock stays
master-password.
Lockout: wrong PINs are counted in the cache (survives an agent
restart); the 5th wipes pin_protected_user_key and returns PinLockedOut
— re-enable after a master-password unlock. Earlier wrong PINs return
BadPin { attempts_remaining }. New typed errors BadPin / PinLockedOut /
PinNotSet (CLI exit 12/13/14). PIN must be >= 4 chars (validated
client-side).
The cache->vault recovery core is shared by the offline-master and PIN
paths (recover_user_key + vault_from_user_key); the attempt/lockout
logic is a pure pin_attempt over an in-memory cache, wrapped by a thin
disk-backed unlock_pin.
Tests: store pin-field round-trip; pure pin_attempt lifecycle (recover ->
reset -> 5-strike lockout + key wipe -> stays locked), PinNotSet with no
enrollment, pin_protect_user_key <-> recover_user_key round-trip. 143
workspace tests green. No new dependencies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's clippy flagged four nursery/pedantic lints a local run missed (cargo's mtime-based fingerprint let a cached clean result stand; a `cargo clean -p` before clippy gives a truthful re-lint): - server.rs: drop the mutex guard before the match in the PinSet arm (significant_drop_tightening), matching the other arms. - unlock.rs: name the recovered-user-key tuple `type UserKey` (type_complexity). - state.rs: pin_enroll takes `&self` (it never mutates self). - main.rs: print_pin_status takes `PinStatus` by value (it's Copy). 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
Unlock the vault with a short PIN instead of the master password (like the Bitwarden extension/desktop), built directly on PR A's encrypted cache + offline-unlock machinery. PR B of the PIN line of work.
Enroll / manage
vault pin set(requires an unlocked agent) encrypts the 64-byte user key under a key derived from the PIN — the 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, new fields serde-defaulted).vault pin disableforgets it;vault pin statusreports enabled + attempts remaining (human +--json).Unlock
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, exactly like an offline unlock). Plainvault unlockis unchanged (master password).Lockout
pin_failures), so the limit survives an agent restart. The 5th wrong PIN wipespin_protected_user_keyand returnsPinLockedOut(re-enable after a master-password unlock); earlier ones returnBadPin { attempts_remaining }. New typed errorsBadPin/PinLockedOut/PinNotSet(CLI exit 12/13/14). PIN must be ≥ 4 chars (validated client-side).Design
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-memoryVaultCache, wrapped by a thin disk-backedunlock_pin— so the security-critical logic is unit-tested without touching disk or network.Tests
Store pin-field round-trip; pure
pin_attemptlifecycle (recover → reset counter → 5-strike lockout + key wipe → stays locked-out),PinNotSetwith no enrollment, and apin_protect_user_key↔recover_user_keyround-trip. 143 tests green,clippy -D warningsclean (forced re-lint),cargo denyclean, both headless builds green. 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.
🤖 Generated with Claude Code