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

### Added

- **TUI card/identity detail render.** Selecting a card or identity in
`vault-tui` now shows its fields in the detail pane: card brand/expiry with a
masked number (`Space` reveals it, re-masked on navigation) and masked CVV;
identity name/email/phone/address. `c` copies the primary field per type (card
number / identity email / login password); `Space` reveals the primary secret
(card number / login password). Non-sensitive fields are fetched on select via
the existing `Request::Get`; the card number/CVV are fetched **only on reveal**
— no card secret enters the TUI until asked. No proto/agent/core change
(reuses the PR #23 `Field` selectors). This completes card/identity support
end-to-end (CLI + TUI). Tests: `primary_secret_field`/`primary_copy_field`
units + `TestBackend` renders for card (masked number, revealed) and identity.

- **Card & identity cipher types (read, CLI).** `vault-core` now models card
(type 3) and identity (type 4) ciphers — full field sets, decrypted into
`PlainCard`/`PlainIdentity` (sensitive fields zeroized on drop) via new
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@ vault get visa --field card-number # also: card-brand, card-expiry, card-cod
vault get me --field identity-email # also: identity-name, identity-phone, identity-address
```

Rendering these in the TUI detail pane is coming next; for now the TUI shows the
item name/type and copies login fields.
In the TUI, selecting a card or identity shows its fields in the detail pane:
card brand/expiry with the number masked (`Space` reveals it, re-masked when you
navigate away) and the CVV masked; identity name/email/phone/address. `c` copies
the primary field — a card's number, an identity's email (and a login's
password). The number/CVV are fetched only on reveal, so no card secret enters
the TUI until you ask.

### PIN unlock

Expand Down
55 changes: 55 additions & 0 deletions crates/vault-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,40 @@ pub struct FolderItem {
}

/// A secret currently shown in the detail pane: which item and field it
/// Cached non-sensitive display fields for the selected card/identity item,
/// fetched on selection. Sensitive fields (card number/CVV) are *not* here —
/// they stay masked and are fetched only via reveal, like passwords.
#[derive(Clone, Debug, Default)]
pub struct DetailView {
/// Id of the item these fields belong to (cache key).
pub id: String,
/// Ordered `(label, value)` pairs to show in the detail pane.
pub lines: Vec<(String, String)>,
}

/// The field `Space` reveals for a cipher type: a login's password or a card's
/// number. Identity and secure-note items have no masked secret (`None`).
#[must_use]
pub const fn primary_secret_field(cipher_type: u8) -> Option<Field> {
match cipher_type {
1 => Some(Field::Password),
3 => Some(Field::CardNumber),
_ => None,
}
}

/// The field `c` copies for a cipher type, with its toast label: a login's
/// password, a card's number, or an identity's email.
#[must_use]
pub const fn primary_copy_field(cipher_type: u8) -> Option<(Field, &'static str)> {
match cipher_type {
1 => Some((Field::Password, "password")),
3 => Some((Field::CardNumber, "card number")),
4 => Some((Field::IdentityEmail, "email")),
_ => None,
}
}

/// belongs to, plus the plaintext. The value is zeroised on drop and never
/// surfaced by `Debug`, so an `App` dump can't leak it.
#[derive(Clone)]
Expand Down Expand Up @@ -776,6 +810,9 @@ pub struct App {
pub confirm_delete: Option<(String, String)>,
/// Secret currently revealed in the detail pane, if any.
pub revealed: Option<RevealedSecret>,
/// Cached non-sensitive fields for the selected card/identity, `Some` while
/// such an item is selected (populated on select; `None` for logins/notes).
pub detail: Option<DetailView>,
/// When a pending OSC52 fallback copy should be cleared from the terminal
/// clipboard. The TUI owns this timer (the agent can't — it has no
/// terminal); the run loop races it against input.
Expand Down Expand Up @@ -819,6 +856,7 @@ impl App {
form: None,
confirm_delete: None,
revealed: None,
detail: None,
osc52_clear_at: None,
toast: None,
unlock: None,
Expand Down Expand Up @@ -855,6 +893,7 @@ impl App {
form: None,
confirm_delete: None,
revealed: None,
detail: None,
osc52_clear_at: None,
toast: None,
unlock: None,
Expand Down Expand Up @@ -1940,6 +1979,22 @@ mod tests {
assert!(app.command.is_empty());
}

#[test]
fn primary_fields_by_cipher_type() {
use super::{Field, primary_copy_field, primary_secret_field};
assert_eq!(primary_secret_field(1), Some(Field::Password));
assert_eq!(primary_secret_field(3), Some(Field::CardNumber));
assert_eq!(primary_secret_field(4), None); // identity: no masked secret
assert_eq!(primary_secret_field(2), None); // secure note
assert_eq!(primary_copy_field(1), Some((Field::Password, "password")));
assert_eq!(
primary_copy_field(3),
Some((Field::CardNumber, "card number"))
);
assert_eq!(primary_copy_field(4), Some((Field::IdentityEmail, "email")));
assert_eq!(primary_copy_field(2), None);
}

#[test]
fn about_overlay_open_and_close() {
let mut app = App::browsing(status(), vec![entry("a", None)]);
Expand Down
76 changes: 68 additions & 8 deletions crates/vault-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ async fn run(socket: &Path) -> anyhow::Result<()> {
});

loop {
// Refresh the card/identity detail cache for the current selection
// before drawing (cheap no-op unless the selection changed).
ensure_detail(&mut state, socket).await;
terminal.draw(|f| ui::render(f, &state))?;
if state.should_quit {
break;
Expand Down Expand Up @@ -372,7 +375,16 @@ async fn handle_normal_key(state: &mut App, key: KeyEvent, socket: &Path) {
}
KeyCode::Char('r') => *state = load_app(socket).await,
KeyCode::Char(' ') => toggle_reveal(state, socket).await,
KeyCode::Char('c') => copy_field(state, socket, Field::Password, "password").await,
// `c` copies the primary field for the selected item's type: login
// password / card number / identity email.
KeyCode::Char('c') => {
if let Some((field, label)) = state
.selected_entry()
.and_then(|e| app::primary_copy_field(e.cipher_type))
{
copy_field(state, socket, field, label).await;
}
}
KeyCode::Char('u') => copy_field(state, socket, Field::Username, "username").await,
KeyCode::Char('o') => copy_field(state, socket, Field::Uri, "URI").await,
KeyCode::Char('t') => copy_field(state, socket, Field::Totp, "TOTP code").await,
Expand Down Expand Up @@ -663,29 +675,77 @@ async fn toggle_reveal(state: &mut App, socket: &Path) {
let Some(sel) = state.selected_entry() else {
return;
};
if state.is_revealed(&sel.id, Field::Password) {
// The masked secret depends on the cipher type: login password / card
// number. Items without one (identity, secure note) can't be revealed.
let Some(field) = app::primary_secret_field(sel.cipher_type) else {
return;
};
if state.is_revealed(&sel.id, field) {
state.hide_revealed();
return;
}
let req = Request::Get {
id: Some(sel.id.clone()),
name: sel.name.clone(),
field: Some(Field::Password),
field: Some(field),
};
match client::request(socket, &req).await {
Ok(Response::Item(item)) => {
state.reveal(RevealedSecret::new(
sel.id,
Field::Password,
item.value.clone(),
));
state.reveal(RevealedSecret::new(sel.id, field, item.value.clone()));
}
Ok(Response::Error(e)) => state.set_toast(format!("reveal failed: {e}")),
Ok(other) => state.set_toast(format!("unexpected response: {other:?}")),
Err(e) => state.set_toast(e.to_string()),
}
}

/// Fetch one field's value for an item, or `None` on a missing field / error.
async fn fetch_field(socket: &Path, id: &str, name: &str, field: Field) -> Option<String> {
let req = Request::Get {
id: Some(id.to_owned()),
name: name.to_owned(),
field: Some(field),
};
match client::request(socket, &req).await {
Ok(Response::Item(item)) => Some(item.value.clone()),
_ => None,
}
}

/// Keep `state.detail` populated with the selected card/identity's non-sensitive
/// fields. A no-op when the selection is unchanged, a login/note, or absent —
/// run once per loop iteration before drawing. Sensitive fields (card number /
/// CVV) are never fetched here; they reveal on demand like passwords.
async fn ensure_detail(state: &mut App, socket: &Path) {
let Some(sel) = state.selected_entry().filter(|_| state.items_focused()) else {
state.detail = None;
return;
};
if state.detail.as_ref().is_some_and(|d| d.id == sel.id) {
return; // already cached for this item
}
let specs: &[(&str, Field)] = match sel.cipher_type {
3 => &[("Brand", Field::CardBrand), ("Exp", Field::CardExpiry)],
4 => &[
("Person", Field::IdentityName),
("Email", Field::IdentityEmail),
("Phone", Field::IdentityPhone),
("Address", Field::IdentityAddress),
],
_ => {
state.detail = None;
return;
}
};
let mut lines = Vec::new();
for (label, field) in specs {
if let Some(value) = fetch_field(socket, &sel.id, &sel.name, *field).await {
lines.push(((*label).to_owned(), value));
}
}
state.detail = Some(app::DetailView { id: sel.id, lines });
}

/// Ask the agent to copy `field` of the selected item to the clipboard, with a
/// timed auto-clear (the agent's configured default). The secret stays in the
/// agent and never enters this process — except on the OSC52 fallback, where
Expand Down
122 changes: 108 additions & 14 deletions crates/vault-tui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,30 +232,62 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
))]
},
|e| {
let steel = hex(steelbore::STEEL_BLUE);
let folder = e.folder.clone().unwrap_or_else(|| "(unfiled)".to_owned());
let username = e.username.clone().unwrap_or_else(|| "—".to_owned());
let mut lines = vec![
field_line("Name", &e.name, amber),
field_line("Type", type_label(e.cipher_type), info),
field_line("User", &username, info),
field_line("Folder", &folder, info),
field_line("Id", &e.id, info),
];
// Logins carry a password; show it masked, revealed on demand.
if e.cipher_type == 1 {
if app.is_revealed(&e.id, Field::Password) {
// A masked field shown in amber when revealed, steel when hidden.
let masked = |lines: &mut Vec<Line>, label: &str, field: Field| {
if app.is_revealed(&e.id, field) {
let value = app.revealed.as_ref().map_or(MASK, |r| r.value());
lines.push(field_line("Pass", value, amber));
lines.push(field_line(label, value, amber));
} else {
lines.push(field_line("Pass", MASK, hex(steelbore::STEEL_BLUE)));
lines.push(field_line(label, MASK, steel));
}
}
};
// Non-sensitive fields fetched for the selected card/identity.
let extra = app.detail.as_ref().filter(|d| d.id == e.id);
let hint = match e.cipher_type {
1 => {
let username = e.username.clone().unwrap_or_else(|| "—".to_owned());
lines.push(field_line("User", &username, info));
lines.push(field_line("Folder", &folder, info));
lines.push(field_line("Id", &e.id, info));
masked(&mut lines, "Pass", Field::Password);
"Space reveal · c/u/o copy"
}
3 => {
if let Some(d) = extra {
for (label, value) in &d.lines {
lines.push(field_line(label, value, info));
}
}
masked(&mut lines, "Number", Field::CardNumber);
lines.push(field_line("CVV", MASK, steel));
lines.push(field_line("Folder", &folder, info));
"Space reveal number · c copy number"
}
4 => {
if let Some(d) = extra {
for (label, value) in &d.lines {
lines.push(field_line(label, value, info));
}
}
lines.push(field_line("Folder", &folder, info));
"c copy email"
}
_ => {
lines.push(field_line("Folder", &folder, info));
lines.push(field_line("Id", &e.id, info));
"—"
}
};
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Space reveal · c/u/o copy",
Style::default()
.fg(hex(steelbore::STEEL_BLUE))
.add_modifier(Modifier::ITALIC),
hint,
Style::default().fg(steel).add_modifier(Modifier::ITALIC),
)));
lines
},
Expand Down Expand Up @@ -849,4 +881,66 @@ mod tests {
"URL missing:\n{text}"
);
}

#[test]
fn card_detail_shows_fields_and_masks_number() {
use crate::app::DetailView;
use vault_ipc::proto::Field;
let card = ListEntry {
id: "card1".into(),
name: "Visa".into(),
cipher_type: 3,
username: None,
folder: Some("Wallet".into()),
};
let mut app = App::browsing(status(), vec![card]);
app.detail = Some(DetailView {
id: "card1".into(),
lines: vec![
("Brand".into(), "Visa".into()),
("Exp".into(), "04/2030".into()),
],
});
let text = draw(&app);
assert!(text.contains("Brand"), "brand label missing:\n{text}");
assert!(text.contains("04/2030"), "expiry missing:\n{text}");
assert!(text.contains("Number"), "number label missing:\n{text}");
assert!(text.contains("CVV"), "cvv label missing:\n{text}");
assert!(text.contains(MASK), "number must be masked:\n{text}");
assert!(
!text.contains("4111"),
"raw card number must not appear unless revealed:\n{text}"
);

// After revealing the card number, it shows.
app.reveal(RevealedSecret::new(
"card1".into(),
Field::CardNumber,
"4111111111111111".into(),
));
assert!(draw(&app).contains("4111111111111111"), "revealed number");
}

#[test]
fn identity_detail_shows_contact_fields() {
use crate::app::DetailView;
let id = ListEntry {
id: "id1".into(),
name: "Me".into(),
cipher_type: 4,
username: None,
folder: None,
};
let mut app = App::browsing(status(), vec![id]);
app.detail = Some(DetailView {
id: "id1".into(),
lines: vec![
("Email".into(), "alice@example.org".into()),
("Phone".into(), "+1 555 0100".into()),
],
});
let text = draw(&app);
assert!(text.contains("alice@example.org"), "email missing:\n{text}");
assert!(text.contains("+1 555 0100"), "phone missing:\n{text}");
}
}
Loading