Skip to content

feat(vault): PIN unlock#11

Merged
UnbreakableMJ merged 2 commits into
mainfrom
pin-unlock
Jun 14, 2026
Merged

feat(vault): PIN unlock#11
UnbreakableMJ merged 2 commits into
mainfrom
pin-unlock

Conversation

@UnbreakableMJ

Copy link
Copy Markdown
Contributor

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 / 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, new fields serde-defaulted).
  • vault pin disable forgets it; vault pin status reports enabled + attempts remaining (human + --json).

Unlock

  • 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, exactly like an offline unlock). Plain vault unlock is unchanged (master password).

Lockout

  • Wrong PINs are counted in the cache (pin_failures), so the limit survives an agent restart. The 5th wrong PIN wipes pin_protected_user_key and returns PinLockedOut (re-enable after a master-password unlock); earlier ones return BadPin { attempts_remaining }. New typed errors BadPin / 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 pure pin_attempt over an in-memory VaultCache, wrapped by a thin disk-backed unlock_pin — so the security-critical logic is unit-tested without touching disk or network.

Tests

Store pin-field round-trip; pure pin_attempt lifecycle (recover → reset counter → 5-strike lockout + key wipe → stays locked-out), PinNotSet with no enrollment, and a pin_protect_user_keyrecover_user_key round-trip. 143 tests green, clippy -D warnings clean (forced re-lint), cargo deny clean, 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

UnbreakableMJ and others added 2 commits June 15, 2026 02:43
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>
@UnbreakableMJ
UnbreakableMJ merged commit 602c082 into main Jun 14, 2026
8 checks passed
@UnbreakableMJ
UnbreakableMJ deleted the pin-unlock branch June 14, 2026 23:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant