Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions crates/vault-agent/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,40 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> 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();
Expand Down
29 changes: 29 additions & 0 deletions crates/vault-agent/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `_`.
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading