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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ range may break in any release.

### Added

- **TUI in-place unlock.** When the agent is locked, `vault-tui` no longer
dead-ends at a "run `vault unlock`" banner — it shows an interactive unlock
prompt for the registered account: type the master password, or `Tab` to a
PIN when one is enrolled, and drop straight into the browser. Reuses the
readline `TextInput` (secret masked with `•`) and the existing
`Unlock`/`UnlockPin` requests — no protocol or agent changes; failed unlocks
show the error (incl. `BadPin { attempts_remaining }` / `PinLockedOut`) and
clear the field.
- To read the account (server/email/device_id) — which a *locked* agent
doesn't report — `config.rs` was extracted from `vault-cli` into a shared
**`vault-config`** crate, now used by both the CLI and the TUI (single
source of truth for `config.toml`; the agent still doesn't read config).
- Tests: `vault-config`'s units moved with it; new `vault-tui` units for
`UnlockState::request` (password vs PIN), `toggle_pin` (no-op without
enrollment, clears on switch), `unlock_failed`, and a `TestBackend` smoke
that the unlock screen shows the account, masks the secret, and offers the
`Tab` hint only when a PIN is enrolled. No new external dependencies.

- **Token persistence + refresh.** The OAuth2 refresh token is now kept and
reused, so a cache/PIN/offline session can become fully capable and a
long-lived session survives access-token expiry.
Expand Down
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/vault-cli",
"crates/vault-tui",
"crates/vault-theme",
"crates/vault-config",
]

[workspace.package]
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ vault pin status # enrolled? attempts remaining?
vault pin disable # forget the PIN
```

The TUI unlocks **in place**: when the agent is locked, `vault-tui` shows an
unlock prompt for your registered account (master password, or `Tab` to a PIN
when one is enrolled) instead of sending you back to the CLI.

A PIN session reads from the encrypted cache, and — once the network is
reachable — transparently goes online for `sync` and edits by refreshing a
stored token (no master password needed); only a genuinely offline box stays
Expand Down
1 change: 1 addition & 0 deletions crates/vault-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ tokio = { workspace = true }
toml = { workspace = true }
uuid = { workspace = true }
zeroize = { workspace = true }
vault-config = { path = "../vault-config" }
vault-core = { path = "../vault-core" }
vault-ipc = { path = "../vault-ipc" }
vault-store = { path = "../vault-store" }
3 changes: 2 additions & 1 deletion crates/vault-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@

#![forbid(unsafe_code)]

mod config;
mod spawn;

use vault_config as config;

use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};

Expand Down
2 changes: 1 addition & 1 deletion crates/vault-cli/src/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ pub async fn spawn_and_connect(socket: &Path) -> Result<UnixStream, String> {
// env/default precedence. A manually launched agent is unaffected. A
// malformed config shouldn't block bringing the agent up, so fall back to
// no extra args (the agent then keeps its defaults).
let extra = crate::config::load().map(|c| crate::config::agent_args(&c));
let extra = vault_config::load().map(|c| vault_config::agent_args(&c));
if let Err(msg) = &extra {
eprintln!("vault: ignoring config for auto-spawn: {msg}");
}
Expand Down
24 changes: 24 additions & 0 deletions crates/vault-config/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPDX-License-Identifier: GPL-3.0-or-later

[package]
name = "vault-config"
description = "Vault user configuration — typed config.toml (account profile + tunables)"
version.workspace = true
publish.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true

[lints]
workspace = true

[dependencies]
dirs = { workspace = true }
serde = { workspace = true }
tempfile = { workspace = true }
toml = { workspace = true }
uuid = { workspace = true }
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
//! whatever would have consumed it. The file lives at
//! `$XDG_CONFIG_HOME/vault/config.toml`; a missing file reads as defaults.
//!
//! Only the CLI reads this today — it sources the agent's launch flags from it
//! during auto-spawn (see `crate::spawn`). The registry is shaped to grow into
//! the rest of PRD §7.1's keys without disturbing callers.
//! Shared by the CLI (which sources the agent's auto-spawn launch flags and
//! manages the keys via `vault config`) and the TUI (which reads the
//! `[account]` profile to drive in-place unlock). The registry is shaped to
//! grow into the rest of PRD §7.1's keys without disturbing callers.

use std::ffi::OsString;
use std::io::Write;
Expand Down Expand Up @@ -111,8 +112,11 @@ impl Config {
}
}

/// Current value of `key` as a display string, `None` when unset. Errors
/// (as the offending key) when `key` is not recognised.
/// Current value of `key` as a display string, `None` when unset.
///
/// # Errors
///
/// Returns the offending key when `key` is not recognised.
pub fn get(&self, key: &str) -> Result<Option<String>, String> {
match key {
"clipboard.clear_secs" => Ok(self.clipboard.clear_secs.map(|v| v.to_string())),
Expand Down
1 change: 1 addition & 0 deletions crates/vault-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ tokio = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
zeroize = { workspace = true }
vault-config = { path = "../vault-config" }
vault-core = { path = "../vault-core" }
vault-ipc = { path = "../vault-ipc" }
vault-theme = { path = "../vault-theme" }
155 changes: 155 additions & 0 deletions crates/vault-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ pub enum InputMode {
Form,
/// `d` pressed — the delete-confirm overlay is open.
ConfirmDelete,
/// The agent is locked — keys edit the master-password / PIN entry.
Unlock,
}

/// Which pane currently takes navigation keys.
Expand All @@ -232,6 +234,51 @@ pub enum Screen {
/// Explanatory line.
body: String,
},
/// The locked agent + a registered account: an interactive unlock prompt.
Unlock,
}

/// State for the in-TUI unlock prompt, shown when the agent is locked and an
/// account is registered. The typed secret is zeroised on drop (`TextInput`).
#[derive(Clone, Debug)]
pub struct UnlockState {
/// Server origin from the registered profile.
pub server: String,
/// Account email from the profile.
pub email: String,
/// Stable device id from the profile (rides `Unlock`).
pub device_id: Option<String>,
/// The master password / PIN being typed (masked on screen).
pub secret: TextInput,
/// Whether the prompt is in PIN mode (vs master password).
pub use_pin: bool,
/// Whether a PIN is enrolled (drives offering the `Tab` toggle).
pub pin_enabled: bool,
/// Last failed-unlock message, shown under the field.
pub error: Option<String>,
}

impl UnlockState {
/// Build the unlock request for the current mode and typed secret.
#[must_use]
pub fn request(&self) -> vault_ipc::proto::Request {
use vault_ipc::proto::Request;
let secret = self.secret.as_str().as_bytes().to_vec();
if self.use_pin {
Request::UnlockPin {
server: self.server.clone(),
email: self.email.clone(),
pin: secret,
}
} else {
Request::Unlock {
server: self.server.clone(),
email: self.email.clone(),
password: secret,
device_id: self.device_id.clone(),
}
}
}
}

/// How a folder entry filters the item list.
Expand Down Expand Up @@ -693,6 +740,8 @@ pub struct App {
/// Transient status-bar message (copy feedback / errors). Cleared on the
/// next key press.
pub toast: Option<String>,
/// Interactive unlock state, `Some` while [`Screen::Unlock`] is shown.
pub unlock: Option<UnlockState>,
/// Set when the user asks to quit.
pub should_quit: bool,
}
Expand Down Expand Up @@ -720,6 +769,7 @@ impl App {
revealed: None,
osc52_clear_at: None,
toast: None,
unlock: None,
should_quit: false,
}
}
Expand Down Expand Up @@ -752,10 +802,42 @@ impl App {
revealed: None,
osc52_clear_at: None,
toast: None,
unlock: None,
should_quit: false,
}
}

/// Build the interactive unlock screen for a locked agent with a registered
/// account.
#[must_use]
pub fn unlock_screen(status: Status, unlock: UnlockState) -> Self {
let mut app = Self::message("", "", Some(status));
app.screen = Screen::Unlock;
app.mode = InputMode::Unlock;
app.unlock = Some(unlock);
app
}

/// Toggle between master-password and PIN entry (no-op unless a PIN is
/// enrolled); clears the field and any error on switch.
pub fn toggle_pin(&mut self) {
if let Some(u) = self.unlock.as_mut()
&& u.pin_enabled
{
u.use_pin = !u.use_pin;
u.secret.clear();
u.error = None;
}
}

/// Record a failed-unlock message and clear the typed secret.
pub fn unlock_failed(&mut self, msg: impl Into<String>) {
if let Some(u) = self.unlock.as_mut() {
u.error = Some(msg.into());
u.secret.clear();
}
}

/// The filter for the currently-selected folder (`All` if the pane is empty).
#[must_use]
pub fn active_filter(&self) -> &FolderFilter {
Expand Down Expand Up @@ -965,6 +1047,7 @@ impl App {
.as_mut()
.and_then(FormState::focused_field_mut)
.map(|f| &mut f.value),
InputMode::Unlock => self.unlock.as_mut().map(|u| &mut u.secret),
InputMode::Normal | InputMode::Generate | InputMode::ConfirmDelete => None,
}
}
Expand Down Expand Up @@ -2027,4 +2110,76 @@ mod tests {
"Debug leaked the plaintext: {rendered}"
);
}

fn unlock_state(pin_enabled: bool) -> UnlockState {
UnlockState {
server: "https://vault.example.org".into(),
email: "me@example.org".into(),
device_id: Some("dev-1".into()),
secret: TextInput::default(),
use_pin: false,
pin_enabled,
error: None,
}
}

#[test]
fn unlock_request_builds_password_or_pin_by_mode() {
use vault_ipc::proto::Request;
let mut app = App::unlock_screen(status(), unlock_state(true));
app.input_insert_str("hunter2");
match app.unlock.as_ref().unwrap().request() {
Request::Unlock {
server,
email,
password,
device_id,
} => {
assert_eq!(server, "https://vault.example.org");
assert_eq!(email, "me@example.org");
assert_eq!(password, b"hunter2");
assert_eq!(device_id.as_deref(), Some("dev-1"));
}
other => panic!("expected Unlock, got {other:?}"),
}

// Toggle to PIN, re-type → UnlockPin (no device_id field).
app.toggle_pin();
app.input_insert_str("4321");
match app.unlock.as_ref().unwrap().request() {
Request::UnlockPin { server, email, pin } => {
assert_eq!(server, "https://vault.example.org");
assert_eq!(email, "me@example.org");
assert_eq!(pin, b"4321");
}
other => panic!("expected UnlockPin, got {other:?}"),
}
}

#[test]
fn toggle_pin_is_noop_without_enrollment_and_clears_on_switch() {
let mut app = App::unlock_screen(status(), unlock_state(false));
app.toggle_pin();
assert!(
!app.unlock.as_ref().unwrap().use_pin,
"no PIN enrolled → no switch"
);

let mut app = App::unlock_screen(status(), unlock_state(true));
app.input_insert_str("abc");
app.toggle_pin();
let u = app.unlock.as_ref().unwrap();
assert!(u.use_pin);
assert!(u.secret.is_empty(), "switch clears the field");
}

#[test]
fn unlock_failed_records_error_and_clears_secret() {
let mut app = App::unlock_screen(status(), unlock_state(false));
app.input_insert_str("wrong");
app.unlock_failed("incorrect master password");
let u = app.unlock.as_ref().unwrap();
assert_eq!(u.error.as_deref(), Some("incorrect master password"));
assert!(u.secret.is_empty());
}
}
Loading
Loading