Skip to content

Commit 602c082

Browse files
Merge pull request #11 from Spacecraft-Software/pin-unlock
feat(vault): PIN unlock
2 parents b1779d4 + 3013aca commit 602c082

9 files changed

Lines changed: 639 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **PIN unlock.** Unlock the vault with a short PIN instead of the master
14+
password (like the Bitwarden extension/desktop), built on the encrypted cache.
15+
- `vault pin set` (requires an unlocked agent) encrypts the user key under a
16+
key derived from the PIN — same KDF/stretch/`EncString` crypto as the
17+
master path, PIN as the secret, email as salt — and stores it as
18+
`pin_protected_user_key` in the cache (envelope schema 3). `vault pin
19+
disable` forgets it; `vault pin status` reports enabled + attempts left.
20+
- `vault unlock --pin` recovers the user key from the cache with the PIN and
21+
builds a **read-only** session (no token — `sync`/`add`/`edit`/`remove`
22+
return `Error::Offline`, like an offline unlock). Plain `vault unlock`
23+
stays master-password.
24+
- **Lockout:** wrong PINs are counted in the cache (so the limit survives an
25+
agent restart); the 5th wrong PIN wipes `pin_protected_user_key` and
26+
returns `PinLockedOut` — re-enable after a master-password unlock. Wrong
27+
PINs before that return `BadPin { attempts_remaining }`. New typed errors
28+
`BadPin` / `PinLockedOut` / `PinNotSet` (CLI exit codes 12/13/14). PIN must
29+
be ≥ 4 characters (validated client-side).
30+
- The cache→vault recovery core is shared between the offline-master and PIN
31+
paths (`recover_user_key` + `vault_from_user_key`); the attempt/lockout
32+
logic is a pure `pin_attempt` over an in-memory cache, with a thin
33+
disk-backed `unlock_pin` wrapper.
34+
- Tests: store pin-field round-trip; pure `pin_attempt` lifecycle (recover →
35+
reset counter → 5-strike lockout + key wipe → stays locked), `PinNotSet`
36+
with no enrollment, and `pin_protect_user_key``recover_user_key`
37+
round-trip. No new dependencies.
38+
- Out of scope (tracked): TUI PIN entry; a PIN/offline session syncing once
39+
token persistence lands; Bitwarden's "require master password on restart"
40+
(memory-only pin key) mode.
41+
1342
- **Encrypted-cache persistence + offline unlock.** The agent now writes its
1443
vault to disk and can unlock without the network — the substrate for the
1544
upcoming **PIN unlock** (and useful on its own).

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ config file; `login` and `unlock` then resolve those from the profile, so their
6060
once registered. `login` is the first-time "authenticate and verify sync";
6161
`unlock` is the routine "hand the agent my key again".
6262

63+
### PIN unlock
64+
65+
For quick access without re-typing the master password, enroll a PIN (after an
66+
online unlock) and unlock with it:
67+
68+
```sh
69+
vault pin set # prompts for a PIN (≥ 4 chars)
70+
vault unlock --pin # prompts for the PIN; unlocks from the local cache
71+
vault pin status # enrolled? attempts remaining?
72+
vault pin disable # forget the PIN
73+
```
74+
75+
A PIN session is **read-only** (browse / `get` / copy from the encrypted cache);
76+
`sync` and edits need a master-password unlock. Five wrong PINs disable the PIN
77+
and require a master-password unlock — bounding brute-force of a short secret.
78+
6379
## Configuration
6480

6581
Persistent settings live at `$XDG_CONFIG_HOME/vault/config.toml`, managed with

crates/vault-agent/src/server.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,40 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
9696
Err(e) => Response::Error(e),
9797
}
9898
}
99+
Request::UnlockPin { server, email, pin } => {
100+
let pin = zeroize::Zeroizing::new(pin);
101+
// PIN unlock is offline (cache only) and synchronous; run it, then
102+
// install the resulting read-only vault.
103+
match crate::unlock::unlock_pin(&server, &email, &pin) {
104+
Ok(vault) => {
105+
let mut s = state.lock().await;
106+
s.vault = Some(vault);
107+
s.touch();
108+
Response::Ok
109+
}
110+
Err(e) => Response::Error(e),
111+
}
112+
}
113+
Request::PinSet { pin } => {
114+
let pin = zeroize::Zeroizing::new(pin);
115+
let mut s = state.lock().await;
116+
let res = s.pin_enroll(&pin);
117+
s.touch();
118+
drop(s);
119+
match res {
120+
Ok(()) => Response::Ok,
121+
Err(e) => Response::Error(e),
122+
}
123+
}
124+
Request::PinDisable { server, email } => {
125+
match crate::unlock::pin_disable(&server, &email) {
126+
Ok(()) => Response::Ok,
127+
Err(e) => Response::Error(e),
128+
}
129+
}
130+
Request::PinStatus { server, email } => {
131+
Response::PinStatus(crate::unlock::pin_status(&server, &email))
132+
}
99133
Request::Lock => {
100134
let mut s = state.lock().await;
101135
s.lock();

crates/vault-agent/src/state.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ impl Vault {
7777
}
7878
}
7979

80+
/// Wrong-PIN attempts allowed before the PIN is wiped and a master-password
81+
/// unlock is required (mirrors the Bitwarden default).
82+
pub const MAX_PIN_ATTEMPTS: u32 = 5;
83+
8084
/// Filesystem-safe per-account cache subdirectory, keyed by host + email so
8185
/// distinct accounts (and the same email on different servers) never collide.
8286
/// Any character outside `[A-Za-z0-9._-]` becomes `_`.
@@ -196,6 +200,31 @@ impl AgentState {
196200
self.clipboard_sweep();
197201
}
198202

203+
/// Enroll a PIN: encrypt the unwrapped user key under a key derived from
204+
/// `pin` (account KDF, email salt) and store it in the cache, resetting the
205+
/// attempt counter. Requires an unlocked agent and an existing cache (which
206+
/// an online unlock always wrote).
207+
///
208+
/// # Errors
209+
///
210+
/// [`IpcError::Locked`] if the agent isn't unlocked, [`IpcError::Internal`]
211+
/// if no cache exists yet or the write fails.
212+
pub fn pin_enroll(&self, pin: &[u8]) -> Result<(), IpcError> {
213+
let v = self.vault.as_ref().ok_or(IpcError::Locked)?;
214+
let pin_protected =
215+
crate::unlock::pin_protect_user_key(&v.email, v.kdf, &v.user_enc, &v.user_mac, pin)?;
216+
let mut cache = crate::unlock::load_cache(&v.server, &v.email).ok_or_else(|| {
217+
IpcError::Internal("no cached vault — unlock online before setting a PIN".to_owned())
218+
})?;
219+
cache.pin_protected_user_key = Some(pin_protected);
220+
cache.pin_failures = 0;
221+
let dir = crate::unlock::cache_dir(&v.server, &v.email)
222+
.ok_or_else(|| IpcError::Internal("no data directory".to_owned()))?;
223+
vault_store::save_to_dir(&dir, &cache)
224+
.map_err(|e| IpcError::Internal(format!("write cache: {e}")))?;
225+
Ok(())
226+
}
227+
199228
/// Build a `Status` snapshot for `Request::Status` / `Request::Ping`.
200229
#[must_use]
201230
pub fn status_snapshot(&self) -> Status {

0 commit comments

Comments
 (0)