diff --git a/CHANGELOG.md b/CHANGELOG.md index 065ae7e..4daef3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,35 @@ range may break in any release. ### Added +- **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/`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 `vault unlock` + stays 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_key` and + returns `PinLockedOut` — re-enable after a master-password unlock. Wrong + PINs before that return `BadPin { attempts_remaining }`. New typed errors + `BadPin` / `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 pure `pin_attempt` over an in-memory cache, with a thin + disk-backed `unlock_pin` wrapper. + - Tests: store pin-field round-trip; pure `pin_attempt` lifecycle (recover → + reset counter → 5-strike lockout + key wipe → stays locked), `PinNotSet` + with no enrollment, and `pin_protect_user_key` ↔ `recover_user_key` + round-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). diff --git a/README.md b/README.md index f19086b..089e678 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,22 @@ config file; `login` and `unlock` then resolve those from the profile, so their once registered. `login` is the first-time "authenticate and verify sync"; `unlock` is the routine "hand the agent my key again". +### PIN unlock + +For quick access without re-typing the master password, enroll a PIN (after an +online unlock) and unlock with it: + +```sh +vault pin set # prompts for a PIN (≥ 4 chars) +vault unlock --pin # prompts for the PIN; unlocks from the local cache +vault pin status # enrolled? attempts remaining? +vault pin disable # forget the PIN +``` + +A PIN session is **read-only** (browse / `get` / copy from the encrypted cache); +`sync` and edits need a master-password unlock. Five wrong PINs disable the PIN +and require a master-password unlock — bounding brute-force of a short secret. + ## Configuration Persistent settings live at `$XDG_CONFIG_HOME/vault/config.toml`, managed with diff --git a/crates/vault-agent/src/server.rs b/crates/vault-agent/src/server.rs index 36f4800..ce14010 100644 --- a/crates/vault-agent/src/server.rs +++ b/crates/vault-agent/src/server.rs @@ -96,6 +96,40 @@ async fn dispatch(req: Request, state: &Arc>) -> Response { Err(e) => Response::Error(e), } } + Request::UnlockPin { server, email, pin } => { + let pin = zeroize::Zeroizing::new(pin); + // PIN unlock is offline (cache only) and synchronous; run it, then + // install the resulting read-only vault. + match crate::unlock::unlock_pin(&server, &email, &pin) { + Ok(vault) => { + let mut s = state.lock().await; + s.vault = Some(vault); + s.touch(); + Response::Ok + } + Err(e) => Response::Error(e), + } + } + Request::PinSet { pin } => { + let pin = zeroize::Zeroizing::new(pin); + let mut s = state.lock().await; + let res = s.pin_enroll(&pin); + s.touch(); + drop(s); + match res { + Ok(()) => Response::Ok, + Err(e) => Response::Error(e), + } + } + Request::PinDisable { server, email } => { + match crate::unlock::pin_disable(&server, &email) { + Ok(()) => Response::Ok, + Err(e) => Response::Error(e), + } + } + Request::PinStatus { server, email } => { + Response::PinStatus(crate::unlock::pin_status(&server, &email)) + } Request::Lock => { let mut s = state.lock().await; s.lock(); diff --git a/crates/vault-agent/src/state.rs b/crates/vault-agent/src/state.rs index b85972d..2e19e4e 100644 --- a/crates/vault-agent/src/state.rs +++ b/crates/vault-agent/src/state.rs @@ -77,6 +77,10 @@ impl Vault { } } +/// Wrong-PIN attempts allowed before the PIN is wiped and a master-password +/// unlock is required (mirrors the Bitwarden default). +pub const MAX_PIN_ATTEMPTS: u32 = 5; + /// Filesystem-safe per-account cache subdirectory, keyed by host + email so /// distinct accounts (and the same email on different servers) never collide. /// Any character outside `[A-Za-z0-9._-]` becomes `_`. @@ -196,6 +200,31 @@ impl AgentState { self.clipboard_sweep(); } + /// Enroll a PIN: encrypt the unwrapped user key under a key derived from + /// `pin` (account KDF, email salt) and store it in the cache, resetting the + /// attempt counter. Requires an unlocked agent and an existing cache (which + /// an online unlock always wrote). + /// + /// # Errors + /// + /// [`IpcError::Locked`] if the agent isn't unlocked, [`IpcError::Internal`] + /// if no cache exists yet or the write fails. + pub fn pin_enroll(&self, pin: &[u8]) -> Result<(), IpcError> { + let v = self.vault.as_ref().ok_or(IpcError::Locked)?; + let pin_protected = + crate::unlock::pin_protect_user_key(&v.email, v.kdf, &v.user_enc, &v.user_mac, pin)?; + let mut cache = crate::unlock::load_cache(&v.server, &v.email).ok_or_else(|| { + IpcError::Internal("no cached vault — unlock online before setting a PIN".to_owned()) + })?; + cache.pin_protected_user_key = Some(pin_protected); + cache.pin_failures = 0; + let dir = crate::unlock::cache_dir(&v.server, &v.email) + .ok_or_else(|| IpcError::Internal("no data directory".to_owned()))?; + vault_store::save_to_dir(&dir, &cache) + .map_err(|e| IpcError::Internal(format!("write cache: {e}")))?; + Ok(()) + } + /// Build a `Status` snapshot for `Request::Status` / `Request::Ping`. #[must_use] pub fn status_snapshot(&self) -> Status { diff --git a/crates/vault-agent/src/unlock.rs b/crates/vault-agent/src/unlock.rs index 442bb60..9c098e8 100644 --- a/crates/vault-agent/src/unlock.rs +++ b/crates/vault-agent/src/unlock.rs @@ -109,55 +109,100 @@ async fn online_unlock( Ok(vault) } -/// Offline unlock from the encrypted local cache — no network. Derives the -/// master key from the cached KDF params, decrypts the protected user key (the -/// `EncString` MAC check is the wrong-password detector), then loads ciphers -/// from the cached `/sync` payload. The session has no token (`client = None`). +/// Offline unlock from the encrypted local cache — no network. Recovers the +/// user key from the master password via the cached protected key, then builds +/// a read-only (`client = None`) vault. A wrong password is `BadPassword`. fn unlock_from_cache( cache: &VaultCache, server: &str, email: &str, password: &[u8], ) -> Result { - let email_lower = email.trim().to_lowercase(); - let kdf: KdfParams = cache - .kdf - .ok_or_else(|| IpcError::Internal("cached account has no KDF params".to_owned()))?; let protected = cache .protected_user_key .as_deref() .ok_or_else(|| IpcError::Internal("cached account has no protected user key".to_owned()))?; + let (user_enc, user_mac) = recover_user_key(cache, email, password, protected) + .map_err(|e| e.into_ipc(|| IpcError::BadPassword))?; + vault_from_user_key(cache, server, email, user_enc, user_mac) +} + +/// The unwrapped user key: the symmetric encryption and MAC halves, each +/// zeroised on drop. +pub type UserKey = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>); + +/// What went wrong recovering the user key from a protected key + secret. +pub enum KeyRecover { + /// The secret (master password / PIN) was wrong — MAC mismatch. + WrongSecret, + /// A non-recoverable error (missing KDF, malformed key). + Internal(String), +} +impl KeyRecover { + /// Map to an `IpcError`, using `wrong` for the wrong-secret case so each + /// caller can choose `BadPassword` vs PIN-specific handling. + fn into_ipc(self, wrong: impl FnOnce() -> IpcError) -> IpcError { + match self { + Self::WrongSecret => wrong(), + Self::Internal(s) => IpcError::Internal(s), + } + } +} + +/// Derive a key from `secret` + the cache's KDF/email salt and decrypt +/// `protected` (an `EncString` over the 64-byte user key). Shared by the +/// offline-master and PIN paths; the MAC check distinguishes a wrong secret. +pub fn recover_user_key( + cache: &VaultCache, + email: &str, + secret: &[u8], + protected: &str, +) -> Result { + let email_lower = email.trim().to_lowercase(); + let kdf: KdfParams = cache + .kdf + .ok_or_else(|| KeyRecover::Internal("cached account has no KDF params".to_owned()))?; let master = Zeroizing::new( - derive_master_key(password, email_lower.as_bytes(), kdf).map_err(crypto_err)?, + derive_master_key(secret, email_lower.as_bytes(), kdf) + .map_err(|e| KeyRecover::Internal(format!("crypto: {e}")))?, ); - let (stretch_enc, stretch_mac) = stretch_master_key(&master).map_err(crypto_err)?; + let (stretch_enc, stretch_mac) = + stretch_master_key(&master).map_err(|e| KeyRecover::Internal(format!("crypto: {e}")))?; let stretch_enc = Zeroizing::new(stretch_enc); let stretch_mac = Zeroizing::new(stretch_mac); - - // A decrypt failure here is overwhelmingly a wrong password (MAC mismatch); - // surface it as such rather than a generic internal error. let (user_enc_arr, user_mac_arr) = decrypt_user_key(protected, &stretch_enc, &stretch_mac) - .map_err(|_| IpcError::BadPassword)?; - let user_enc = Zeroizing::new(user_enc_arr); - let user_mac = Zeroizing::new(user_mac_arr); + .map_err(|_| KeyRecover::WrongSecret)?; + Ok((Zeroizing::new(user_enc_arr), Zeroizing::new(user_mac_arr))) +} +/// Build a read-only vault (`client = None`) from a recovered user key by +/// decrypting the cached `/sync` payload. Shared by every cache-based unlock. +pub fn vault_from_user_key( + cache: &VaultCache, + server: &str, + email: &str, + user_enc: Zeroizing<[u8; 32]>, + user_mac: Zeroizing<[u8; 32]>, +) -> Result { let payload = cache .load_payload(&user_enc, &user_mac) .map_err(|e| IpcError::Internal(format!("decrypt cached vault: {e}")))?; let sync: SyncResponse = serde_json::from_slice(&payload) .map_err(|e| IpcError::Internal(format!("parse cached vault: {e}")))?; let (ciphers, folders) = ciphers_and_folders(&sync, &user_enc, &user_mac); - + let kdf = cache + .kdf + .ok_or_else(|| IpcError::Internal("cached account has no KDF params".to_owned()))?; Ok(Vault { server: server.to_owned(), - email: email_lower, + email: email.trim().to_lowercase(), user_enc, user_mac, ciphers, folders, client: None, - protected_user_key: protected.to_owned(), + protected_user_key: cache.protected_user_key.clone().unwrap_or_default(), kdf, device_id: cache.device_id.clone(), last_sync: cache.last_sync.clone(), @@ -165,11 +210,130 @@ fn unlock_from_cache( } /// Load the account's cache, if one exists and parses. -fn load_cache(server: &str, email: &str) -> Option { +pub fn load_cache(server: &str, email: &str) -> Option { let dir = vault_store::default_data_dir()?.join(account_dir_name(server, email)); vault_store::load_from_dir(&dir).ok() } +/// Path to the account's cache directory. +pub fn cache_dir(server: &str, email: &str) -> Option { + Some(vault_store::default_data_dir()?.join(account_dir_name(server, email))) +} + +/// Write `cache` back to the account's directory. +fn save_cache(server: &str, email: &str, cache: &VaultCache) -> Result<(), IpcError> { + let dir = cache_dir(server, email) + .ok_or_else(|| IpcError::Internal("no data directory".to_owned()))?; + vault_store::save_to_dir(&dir, cache) + .map_err(|e| IpcError::Internal(format!("write cache: {e}")))?; + Ok(()) +} + +/// PIN unlock from the cache: recover the user key with `pin` and build a +/// read-only vault. Tracks failed attempts in the persisted cache; the +/// `MAX_PIN_ATTEMPTS`-th wrong PIN wipes the pin key and reports `PinLockedOut`. +pub fn unlock_pin(server: &str, email: &str, pin: &[u8]) -> Result { + let mut cache = load_cache(server, email).ok_or(IpcError::PinNotSet)?; + let res = pin_attempt(&mut cache, server, email, pin); + // The attempt mutates the counter (and may wipe the key on lockout) whether + // or not it succeeded — persist that. Best-effort: a write failure mustn't + // mask the unlock result. + let _ = save_cache(server, email, &cache); + res +} + +/// Pure PIN-attempt logic over an in-memory cache (no disk): on the correct PIN +/// resets `pin_failures` and returns a read-only vault; on a wrong PIN bumps +/// the counter and returns `BadPin`, wiping the pin key and returning +/// `PinLockedOut` once `MAX_PIN_ATTEMPTS` is reached. +pub fn pin_attempt( + cache: &mut VaultCache, + server: &str, + email: &str, + pin: &[u8], +) -> Result { + // A wiped-by-lockout cache keeps `pin_failures` at the max — report lockout + // (not "no PIN set") so the user knows to use the master password. + if cache.pin_failures >= crate::state::MAX_PIN_ATTEMPTS { + return Err(IpcError::PinLockedOut); + } + let protected = cache + .pin_protected_user_key + .clone() + .ok_or(IpcError::PinNotSet)?; + + match recover_user_key(cache, email, pin, &protected) { + Ok((user_enc, user_mac)) => { + cache.pin_failures = 0; + vault_from_user_key(cache, server, email, user_enc, user_mac) + } + Err(KeyRecover::Internal(s)) => Err(IpcError::Internal(s)), + Err(KeyRecover::WrongSecret) => { + cache.pin_failures += 1; + let remaining = crate::state::MAX_PIN_ATTEMPTS.saturating_sub(cache.pin_failures); + if remaining == 0 { + // Lockout: drop the pin key so it can't be brute-forced further. + cache.pin_protected_user_key = None; + Err(IpcError::PinLockedOut) + } else { + Err(IpcError::BadPin { + attempts_remaining: remaining, + }) + } + } + } +} + +/// Encrypt the 64-byte user key under a key derived from `pin` (account KDF, +/// email salt) — the value stored as `pin_protected_user_key`. The inverse of +/// the PIN side of [`recover_user_key`]. +/// +/// # Errors +/// +/// [`IpcError::Internal`] on a key-derivation failure. +pub fn pin_protect_user_key( + email: &str, + kdf: KdfParams, + user_enc: &[u8; 32], + user_mac: &[u8; 32], + pin: &[u8], +) -> Result { + let email_lower = email.trim().to_lowercase(); + let master = Zeroizing::new( + derive_master_key(pin, email_lower.as_bytes(), kdf) + .map_err(|e| IpcError::Internal(format!("crypto: {e}")))?, + ); + let (stretch_enc, stretch_mac) = + stretch_master_key(&master).map_err(|e| IpcError::Internal(format!("crypto: {e}")))?; + let mut user_key = Zeroizing::new(Vec::with_capacity(64)); + user_key.extend_from_slice(user_enc); + user_key.extend_from_slice(user_mac); + Ok(vault_core::EncString::encrypt(&stretch_enc, &stretch_mac, &user_key).serialize()) +} + +/// Forget any enrolled PIN for the account (idempotent — no cache is success). +pub fn pin_disable(server: &str, email: &str) -> Result<(), IpcError> { + let Some(mut cache) = load_cache(server, email) else { + return Ok(()); + }; + cache.pin_protected_user_key = None; + cache.pin_failures = 0; + save_cache(server, email, &cache) +} + +/// Report PIN enrollment + attempts remaining for the account. +pub fn pin_status(server: &str, email: &str) -> vault_ipc::proto::PinStatus { + let cache = load_cache(server, email); + let enabled = cache + .as_ref() + .is_some_and(|c| c.pin_protected_user_key.is_some()); + let failures = cache.as_ref().map_or(0, |c| c.pin_failures); + vault_ipc::proto::PinStatus { + enabled, + attempts_remaining: crate::state::MAX_PIN_ATTEMPTS.saturating_sub(failures), + } +} + /// Re-cast a `/sync` payload into the typed views the agent caches: a list of /// [`Cipher`]s and an `id → decrypted-name` folder map. Shared by the initial /// [`perform_unlock`] and the standalone `resync` so both paths interpret the @@ -382,6 +546,102 @@ mod tests { ); } + /// Build a cache with a payload + master + PIN protected keys, for the + /// in-memory PIN-attempt tests. + fn seeded_pin_cache( + email: &str, + user_enc: &[u8; 32], + user_mac: &[u8; 32], + kdf: KdfParams, + pin: &[u8], + ) -> VaultCache { + let sync = SyncResponse { + profile: serde_json::Value::Null, + folders: vec![], + collections: vec![], + ciphers: vec![serde_json::json!({ + "Id": "c1", + "Type": 1, + "Name": enc_str(user_enc, user_mac, "github.com"), + })], + domains: serde_json::Value::Null, + sends: vec![], + }; + let sync_bytes = serde_json::to_vec(&sync).unwrap(); + let mut cache = VaultCache::new("dev".into(), "https://x".into(), email); + cache.set_payload(user_enc, user_mac, &sync_bytes).unwrap(); + cache.kdf = Some(kdf); + cache.pin_protected_user_key = + Some(pin_protect_user_key(email, kdf, user_enc, user_mac, pin).unwrap()); + cache + } + + #[test] + fn pin_attempt_recovers_resets_counts_and_locks_out() { + use vault_core::kdf::KdfType; + let kdf = KdfParams { + kind: KdfType::Pbkdf2Sha256, + iterations: 1_000, + memory_kib: None, + parallelism: None, + }; + let email = "user@example.org"; + let user_enc = [7u8; 32]; + let user_mac = [9u8; 32]; + let mut cache = seeded_pin_cache(email, &user_enc, &user_mac, kdf, b"1234"); + + // Correct PIN → read-only vault with the cached cipher. + let vault = pin_attempt(&mut cache, "https://x", email, b"1234").unwrap(); + assert!(vault.client.is_none()); + assert_eq!(vault.ciphers.len(), 1); + + // Two wrong PINs bump the counter and report the remaining count. + for expected in [4u32, 3] { + assert!(matches!( + pin_attempt(&mut cache, "https://x", email, b"0000"), + Err(IpcError::BadPin { attempts_remaining }) if attempts_remaining == expected + )); + } + assert_eq!(cache.pin_failures, 2); + + // A correct PIN resets the counter back to zero. + pin_attempt(&mut cache, "https://x", email, b"1234").unwrap(); + assert_eq!(cache.pin_failures, 0); + + // Five wrong PINs in a row → lockout, pin key wiped. + let mut last = Err(IpcError::PinNotSet); + for _ in 0..crate::state::MAX_PIN_ATTEMPTS { + last = pin_attempt(&mut cache, "https://x", email, b"0000"); + } + assert!(matches!(last, Err(IpcError::PinLockedOut))); + assert_eq!( + cache.pin_protected_user_key, None, + "pin key wiped on lockout" + ); + // A further attempt stays locked out (not "no PIN set"). + assert!(matches!( + pin_attempt(&mut cache, "https://x", email, b"1234"), + Err(IpcError::PinLockedOut) + )); + } + + #[test] + fn pin_attempt_without_enrolled_pin_is_pin_not_set() { + use vault_core::kdf::KdfType; + let kdf = KdfParams { + kind: KdfType::Pbkdf2Sha256, + iterations: 1_000, + memory_kib: None, + parallelism: None, + }; + let mut cache = VaultCache::new("dev".into(), "https://x".into(), "u@e.org"); + cache.kdf = Some(kdf); + assert!(matches!( + pin_attempt(&mut cache, "https://x", "u@e.org", b"1234"), + Err(IpcError::PinNotSet) + )); + } + #[test] fn ciphers_and_folders_skips_malformed_folder_entries() { let enc = [3u8; 32]; diff --git a/crates/vault-cli/src/main.rs b/crates/vault-cli/src/main.rs index 5fbcc09..c9c2173 100644 --- a/crates/vault-cli/src/main.rs +++ b/crates/vault-cli/src/main.rs @@ -21,7 +21,7 @@ use tokio::net::UnixStream; use zeroize::{Zeroize, Zeroizing}; use vault_ipc::proto::{ - Error as IpcError, Field, Item, ListEntry, Removed, Request, Response, Saved, Status, + Error as IpcError, Field, Item, ListEntry, PinStatus, Removed, Request, Response, Saved, Status, }; use vault_ipc::{default_socket_path, read_frame, sanitize_socket_path, write_frame}; @@ -134,10 +134,19 @@ enum Cmd { /// Account email. Falls back to the registered profile, then `$VAULT_EMAIL`. #[arg(long)] email: Option, + /// Unlock with the enrolled PIN instead of the master password + /// (read-only, offline session — sync/edits need a master unlock). + #[arg(long)] + pin: bool, /// Emit JSON instead of staying silent on success. #[arg(long)] json: bool, }, + /// Manage the unlock PIN (set / disable / status). + Pin { + #[command(subcommand)] + action: PinAction, + }, /// Wipe the in-memory key (the agent stays running). Lock { /// Emit JSON instead of staying silent on success. @@ -308,6 +317,46 @@ enum ConfigAction { }, } +#[derive(Subcommand, Debug)] +enum PinAction { + /// Enroll a PIN (requires an unlocked agent); prompts twice. + Set { + /// Server origin. Falls back to the profile, then `$VAULT_SERVER`. + #[arg(long)] + server: Option, + /// Account email. Falls back to the profile, then `$VAULT_EMAIL`. + #[arg(long)] + email: Option, + /// Emit JSON instead of staying silent on success. + #[arg(long)] + json: bool, + }, + /// Forget the enrolled PIN. + Disable { + /// Server origin. Falls back to the profile, then `$VAULT_SERVER`. + #[arg(long)] + server: Option, + /// Account email. Falls back to the profile, then `$VAULT_EMAIL`. + #[arg(long)] + email: Option, + /// Emit JSON instead of staying silent on success. + #[arg(long)] + json: bool, + }, + /// Show whether a PIN is enrolled and how many attempts remain. + Status { + /// Server origin. Falls back to the profile, then `$VAULT_SERVER`. + #[arg(long)] + server: Option, + /// Account email. Falls back to the profile, then `$VAULT_EMAIL`. + #[arg(long)] + email: Option, + /// Emit JSON instead of a human-readable line. + #[arg(long)] + json: bool, + }, +} + #[derive(Clone, Copy, Debug, clap::ValueEnum)] enum FieldArg { Password, @@ -402,8 +451,10 @@ async fn run(cmd: Cmd, ep: Endpoint<'_>) -> Result<(), u8> { Cmd::Unlock { server, email, + pin, json, - } => cmd_unlock(ep, server, email, json).await, + } => cmd_unlock(ep, server, email, pin, json).await, + Cmd::Pin { action } => cmd_pin(ep, action).await, Cmd::Lock { json } => cmd_ack(ep, Request::Lock, "locked", json).await, Cmd::Sync { json } => cmd_sync(ep, json).await, Cmd::List { json } => cmd_list(ep, json).await, @@ -645,20 +696,32 @@ async fn cmd_unlock( ep: Endpoint<'_>, server: Option, email: Option, + pin: bool, json: bool, ) -> Result<(), u8> { let acct = resolve_account(server, email)?; - let password = read_password()?; - let mut stream = connect(ep).await?; - let req = Request::Unlock { - server: acct.server, - email: acct.email, - password, - device_id: acct.device_id, + let req = if pin { + let pin = read_secret("PIN: ")?.ok_or_else(|| { + eprintln!("vault: empty PIN"); + 2u8 + })?; + Request::UnlockPin { + server: acct.server, + email: acct.email, + pin, + } + } else { + Request::Unlock { + server: acct.server, + email: acct.email, + password: read_password()?, + device_id: acct.device_id, + } }; + let mut stream = connect(ep).await?; let resp = exchange(&mut stream, &req).await?; - // Wipe our copy of the request — the password field is now zero'd inside - // the moved Request, but the wire buffer was already serialised. Drop is + // Wipe our copy of the request — the secret field is now zero'd inside the + // moved Request, but the wire buffer was already serialised. Drop is // best-effort beyond that point. drop(req); match resp { @@ -671,6 +734,83 @@ async fn cmd_unlock( } } +/// `vault pin set/disable/status`. +async fn cmd_pin(ep: Endpoint<'_>, action: PinAction) -> Result<(), u8> { + match action { + PinAction::Set { + server, + email, + json, + } => { + // Ensure an account exists; the agent needs an unlocked vault. + let _ = resolve_account(server, email)?; + let pin = read_secret("New PIN (>= 4 chars): ")?.ok_or_else(|| { + eprintln!("vault: empty PIN"); + 2u8 + })?; + if pin.len() < 4 { + eprintln!("vault: PIN must be at least 4 characters"); + return Err(2); + } + cmd_ack(ep, Request::PinSet { pin }, "pin set", json).await + } + PinAction::Disable { + server, + email, + json, + } => { + let acct = resolve_account(server, email)?; + cmd_ack( + ep, + Request::PinDisable { + server: acct.server, + email: acct.email, + }, + "pin disabled", + json, + ) + .await + } + PinAction::Status { + server, + email, + json, + } => { + let acct = resolve_account(server, email)?; + let mut stream = connect(ep).await?; + let req = Request::PinStatus { + server: acct.server, + email: acct.email, + }; + match exchange(&mut stream, &req).await? { + Response::PinStatus(s) => { + print_pin_status(s, json); + Ok(()) + } + Response::Error(e) => report_error(&e), + other => unexpected(&other), + } + } + } +} + +/// Human / JSON rendering of `vault pin status`. +fn print_pin_status(s: PinStatus, json: bool) { + if json { + println!( + "{}", + serde_json::json!({ "enabled": s.enabled, "attempts_remaining": s.attempts_remaining }) + ); + } else if s.enabled { + println!( + "pin: enabled ({} attempt(s) remaining)", + s.attempts_remaining + ); + } else { + println!("pin: disabled"); + } +} + /// Fire-and-acknowledge: send `req`, expect a bare `Ok`, and (only under /// `--json`) print a `{ "": true }` envelope. Human mode stays silent /// on success, matching the pre-`--json` behaviour of `lock`/`stop-agent`. @@ -1124,6 +1264,9 @@ fn report_error(e: &IpcError) -> Result<(), u8> { IpcError::NoSuchField { .. } => 8, IpcError::AmbiguousItem { .. } => 10, IpcError::Offline => 11, + IpcError::BadPin { .. } => 12, + IpcError::PinLockedOut => 13, + IpcError::PinNotSet => 14, IpcError::Network(_) | IpcError::Internal(_) | IpcError::Decrypt(_) diff --git a/crates/vault-ipc/src/proto.rs b/crates/vault-ipc/src/proto.rs index 544cc4e..eec94d9 100644 --- a/crates/vault-ipc/src/proto.rs +++ b/crates/vault-ipc/src/proto.rs @@ -159,6 +159,44 @@ pub enum Request { uri: Option, }, + /// Enroll a PIN: encrypt the unwrapped user key under a key derived from + /// `pin` and store it in the cache. Requires an unlocked agent. `pin` is + /// secret, wiped after derivation. + PinSet { + /// The PIN bytes (UTF-8), sent only on the local UDS. + pin: Vec, + }, + + /// Forget the enrolled PIN (wipe the pin-protected key + attempt counter). + /// Carries the account so the cache can be found while the agent is locked. + PinDisable { + /// Server origin. + server: String, + /// Account email. + email: String, + }, + + /// Report whether a PIN is enrolled and how many attempts remain. + PinStatus { + /// Server origin. + server: String, + /// Account email. + email: String, + }, + + /// Unlock the agent from the cache using `pin` instead of the master + /// password (read-only session, no network token). `pin` is secret; the + /// account locates the cache (no login, so the persisted device id is + /// reused). + UnlockPin { + /// Server origin. + server: String, + /// Account email. + email: String, + /// The PIN bytes (UTF-8), sent only on the local UDS. + pin: Vec, + }, + /// Cleanly shut the agent down. Equivalent to `vault stop-agent`. Quit, } @@ -181,10 +219,21 @@ pub enum Response { Saved(Saved), /// `Copy` / `CopyText` result — the value is on the agent's clipboard. Copied(Copied), + /// `PinStatus` result. + PinStatus(PinStatus), /// Recoverable error — operation declined or failed. Error(Error), } +/// Wire shape for `Response::PinStatus`. +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct PinStatus { + /// Whether a PIN is currently enrolled. + pub enabled: bool, + /// Attempts remaining before lockout (meaningful only when `enabled`). + pub attempts_remaining: u32, +} + /// Wire shape for `Response::Copied`. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Copied { @@ -349,6 +398,20 @@ pub enum Error { /// to sync or modify items. #[error("offline session — unlock again while online to sync or modify items")] Offline, + /// Wrong PIN; `attempts_remaining` before the PIN is wiped and a + /// master-password unlock is required. + #[error("incorrect PIN ({attempts_remaining} attempt(s) left)")] + BadPin { + /// Attempts left before lockout. + attempts_remaining: u32, + }, + /// Too many wrong PINs — the PIN was disabled; unlock with the master + /// password. + #[error("too many incorrect PINs — PIN disabled; unlock with your master password")] + PinLockedOut, + /// `unlock --pin` (or `pin status` action) but no PIN is enrolled. + #[error("no PIN is set — run `vault pin set` after unlocking")] + PinNotSet, /// Any other internal error — message is for the operator, not for parsing. #[error("internal: {0}")] Internal(String), diff --git a/crates/vault-store/src/lib.rs b/crates/vault-store/src/lib.rs index b77f671..b7ea6b7 100644 --- a/crates/vault-store/src/lib.rs +++ b/crates/vault-store/src/lib.rs @@ -24,7 +24,7 @@ use vault_core::EncString; use vault_core::kdf::KdfParams; /// Current on-disk schema version. -const SCHEMA_VERSION: u32 = 2; +const SCHEMA_VERSION: u32 = 3; /// Persistent on-disk cache for one Bitwarden account. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -53,6 +53,16 @@ pub struct VaultCache { /// `None` on caches written before schema 2. #[serde(default)] pub kdf: Option, + /// The user key encrypted under a PIN-derived key (an `EncString`), set by + /// `vault pin set`. Lets `unlock --pin` recover the vault from a short PIN + /// instead of the master password. `None` when no PIN is enrolled. Safe at + /// rest: brute-force is bounded by the account KDF and the attempt lockout. + #[serde(default)] + pub pin_protected_user_key: Option, + /// Consecutive failed PIN attempts. Persisted so the lockout survives an + /// agent restart; reset to 0 on a correct PIN or a fresh enrollment. + #[serde(default)] + pub pin_failures: u32, } impl VaultCache { @@ -68,6 +78,8 @@ impl VaultCache { payload: None, protected_user_key: None, kdf: None, + pin_protected_user_key: None, + pin_failures: 0, } } diff --git a/crates/vault-store/tests/cache.rs b/crates/vault-store/tests/cache.rs index 23ac6b6..00fb6fe 100644 --- a/crates/vault-store/tests/cache.rs +++ b/crates/vault-store/tests/cache.rs @@ -40,7 +40,7 @@ fn cache_round_trip_through_disk() { let path = save_to_dir(tmp.path(), &cache).unwrap(); assert!(path.exists()); let on_disk = std::fs::read_to_string(&path).unwrap(); - assert!(on_disk.contains("\"schema_version\": 2")); + assert!(on_disk.contains("\"schema_version\": 3")); assert!( !on_disk.contains("Ciphers"), "payload must be encrypted on disk" @@ -64,8 +64,24 @@ fn protected_user_key_and_kdf_round_trip() { assert_eq!(loaded.kdf, Some(fast_pbkdf2())); } -/// A schema-1 cache (no `protected_user_key` / `kdf`) must still deserialize — -/// the new fields are serde-defaulted. +#[test] +fn pin_fields_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let mut cache = VaultCache::new("dev".into(), "https://x".into(), "a@b"); + cache.pin_protected_user_key = Some("2.ppp|qqq|rrr".into()); + cache.pin_failures = 3; + save_to_dir(tmp.path(), &cache).unwrap(); + + let loaded = load_from_dir(tmp.path()).unwrap(); + assert_eq!( + loaded.pin_protected_user_key.as_deref(), + Some("2.ppp|qqq|rrr") + ); + assert_eq!(loaded.pin_failures, 3); +} + +/// A schema-1 cache (no `protected_user_key` / `kdf` / pin fields) must still +/// deserialize — the new fields are serde-defaulted. #[test] fn legacy_v1_cache_still_loads() { let tmp = tempfile::tempdir().unwrap(); @@ -82,6 +98,8 @@ fn legacy_v1_cache_still_loads() { assert_eq!(loaded.email, "a@b"); assert_eq!(loaded.protected_user_key, None); assert_eq!(loaded.kdf, None); + assert_eq!(loaded.pin_protected_user_key, None); + assert_eq!(loaded.pin_failures, 0); } #[test]