diff --git a/Cargo.lock b/Cargo.lock index 406aa21..2fed9e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3702,6 +3702,7 @@ dependencies = [ "ledger-core", "ledger-import", "ledger-store", + "ledger-vault", "notify", "proptest", "rust_decimal", diff --git a/crates/ledger-gui/Cargo.toml b/crates/ledger-gui/Cargo.toml index 9dbab5a..15cfd20 100644 --- a/crates/ledger-gui/Cargo.toml +++ b/crates/ledger-gui/Cargo.toml @@ -15,6 +15,7 @@ ledger-core = { path = "../ledger-core" } ledger-store = { path = "../ledger-store" } ledger-config = { path = "../ledger-config" } ledger-import = { path = "../ledger-import" } +ledger-vault = { path = "../ledger-vault" } eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] } egui = "0.36" egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] } diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 466aa14..e9ad1e0 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -15,7 +15,7 @@ use ledger_store::{Attachment, JournalLine, Reconciliation, Snapshot, Statement, use rust_decimal::Decimal; use serde_json::Value; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::nav::Route; @@ -1470,6 +1470,58 @@ impl Books { ) } + /// The books dir: the folder holding `books-data/` (and `vault/`). + fn books_dir(&self) -> PathBuf { + let d = self + .data_dir + .canonicalize() + .unwrap_or_else(|_| self.data_dir.clone()); + d.parent().map(Path::to_path_buf).unwrap_or(d) + } + + /// A party's full TIN from the encrypted vault, formatted + /// ("123-45-6789" for an SSN or ITIN, "12-3456789" for an EIN), for the + /// clipboard only: never drawn on screen. + /// + /// ```text + /// INVARIANT 1: read only on demand (a click), never on load or paint. + /// INVARIANT 2: never creates a key file; a missing key is a locked + /// vault ("restore it from your password manager"). + /// INVARIANT 3: the key file in force is the server's: [vault] + /// key_file, else ~/.config/wanderbooks/vault.key; one inside the + /// books dir is refused, as the server refuses it. + /// INVARIANT 4: an error never carries the digits (the vault's errors + /// are redacted); a party with no record is "not in the vault". + /// ``` + pub fn tin_from_vault(&self, party: PartyId) -> Result { + let key = self + .settings + .vault_key_file + .clone() + .unwrap_or_else(ledger_vault::default_key_file); + let books = self.books_dir(); + if resolved(&key).starts_with(resolved(&books)) { + return Err(format!( + "The vault key file {} is inside the books folder: move it out and set [vault] key_file.", + key.display() + )); + } + let vault = ledger_vault::Vault::open(&books.join("vault"), &key, false).map_err( + |e| match e { + ledger_vault::Error::NoKey(k) => format!( + "The vault is locked: its key file {} is missing. Restore it from your password manager.", + k.display() + ), + other => other.to_string(), + }, + )?; + match vault.tin(party) { + Ok(Some(t)) => Ok(t.formatted()), + Ok(None) => Err("Not in the vault on this computer.".into()), + Err(e) => Err(e.to_string()), + } + } + /// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT /// 3): its fraction of the entry's entry_flow what_for, summing to its /// money share exactly (zero when the entry moves no money, like a @@ -1895,3 +1947,28 @@ fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec { ledger_store::reconcilable::cash_accounts(ledger, &settings.cash_accounts) } + +/// `path` made absolute through its nearest existing ancestor (symlinks +/// resolved), so a path that doesn't exist yet can still be placed: the +/// server's rule for where the vault key may live. +fn resolved(path: &Path) -> PathBuf { + let abs = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().unwrap_or_default().join(path) + }; + let mut base = abs.as_path(); + let mut rest = Vec::new(); + while !base.exists() { + match (base.file_name(), base.parent()) { + (Some(name), Some(up)) => { + rest.push(name.to_os_string()); + base = up; + } + _ => return abs, + } + } + let mut out = base.canonicalize().unwrap_or_else(|_| base.to_path_buf()); + out.extend(rest.into_iter().rev()); + out +} diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 6cfa2eb..bed2a5a 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -157,6 +157,9 @@ pub fn party_icon(p: &Party) -> &'static str { } } +/// What a TIN kept in the vault says on hover. +const TIN_COPY_HOVER: &str = "Click to copy the full number (right-click: digits only). It's read from the encrypted vault and never shown; the books keep only the last four."; + /// The last four of a TIN, masked the way a truncated TIN is printed: /// "•••-••-1234". The books never hold more than these four digits. pub fn masked_tin(last4: &str) -> String { @@ -774,7 +777,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { }); super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| { if col == 0 { - facts_card(ui, party, &mut go); + facts_card(ui, b, party, &mut go); } else { let mut any = false; if b.files_1099(party) || party.w9.is_some() { @@ -861,7 +864,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { } /// Who they are: names, website, address, notes, ids. -fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option) { +fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option) { let p = pal(ui.ctx()); widgets::titled_card( ui, @@ -908,10 +911,18 @@ fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option) { ui.end_row(); k(ui, "Taxpayer ID"); match &party.tin { + Some(t) if t.vault_record.is_some() => { + widgets::secret_copyable( + ui, + RichText::new(tin_on_file(t)).monospace(), + TIN_COPY_HOVER, + || b.tin_from_vault(party.id), + ); + } Some(t) => { ui.label(RichText::new(tin_on_file(t)).monospace()) .on_hover_text( - "Only the last four digits are in the books; the full number is kept in the encrypted TIN vault, for the CPA package.", + "Only the last four digits are in the books; the full number isn't in the vault.", ); } None => { @@ -1008,10 +1019,19 @@ fn w9_card(ui: &mut Ui, b: &Books, party: &Party) { ui.label(w.fatca_code.as_deref().unwrap_or("none")); ui.end_row(); k(ui, "TIN"); - ui.label(RichText::new(masked_tin(&w.tin_last4)).monospace()) - .on_hover_text( - "Only the last four digits are in the books; the full number is kept in the vault.", + if w.vault_record.is_some() { + widgets::secret_copyable( + ui, + RichText::new(masked_tin(&w.tin_last4)).monospace(), + TIN_COPY_HOVER, + || b.tin_from_vault(party.id), ); + } else { + ui.label(RichText::new(masked_tin(&w.tin_last4)).monospace()) + .on_hover_text( + "Only the last four digits are in the books; the full number isn't in the vault (the form is kept elsewhere).", + ); + } ui.end_row(); k(ui, "Kept at"); ui.label(&w.kept_at); diff --git a/crates/ledger-gui/src/widgets.rs b/crates/ledger-gui/src/widgets.rs index 39c2297..7fd75b6 100644 --- a/crates/ledger-gui/src/widgets.rs +++ b/crates/ledger-gui/src/widgets.rs @@ -308,6 +308,90 @@ pub fn copyable(ui: &mut Ui, text: RichText, value: &str) -> Response { .inner } +/// A secret that copies itself when clicked but is never drawn: `text` is +/// its masked form; `fetch` reads the real value, only on a click (or the +/// right-click menu's "Copy digits only"). A fetch that fails shows why, +/// beside it, for a few seconds. +pub fn secret_copyable( + ui: &mut Ui, + text: RichText, + hover: &str, + fetch: impl Fn() -> Result, +) -> Response { + let id = ui.id().with(("secret-copy", text.text())); + let err_id = id.with("error"); + let p = pal(ui.ctx()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let r = ui.add(egui::Label::new(text).sense(Sense::click())); + let recent = recently_copied(ui, id); + let (slot, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + if r.hovered() || recent { + ui.painter().text( + slot.center(), + egui::Align2::CENTER_CENTER, + if recent { + egui_phosphor::regular::CHECK + } else { + egui_phosphor::regular::COPY + }, + egui::FontId::proportional(12.0), + if recent { p.positive } else { p.muted }, + ); + } + if r.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + let copy = |ui: &mut Ui, digits_only: bool| match fetch() { + Ok(v) => { + let v: String = if digits_only { + v.chars().filter(char::is_ascii_digit).collect() + } else { + v + }; + ui.ctx().data_mut(|d| d.remove::<(f64, String)>(err_id)); + copy_now(ui, id, &v); + } + Err(e) => { + let now = ui.input(|i| i.time); + ui.ctx().data_mut(|d| d.insert_temp(err_id, (now, e))); + } + }; + let r = r.on_hover_text(if recent { "Copied" } else { hover }); + if r.clicked() { + copy(ui, false); + } + r.context_menu(|ui| { + if ui + .button(format!("{} Copy", egui_phosphor::regular::COPY)) + .clicked() + { + copy(ui, false); + ui.close(); + } + if ui + .button(format!("{} Copy digits only", egui_phosphor::regular::COPY)) + .clicked() + { + copy(ui, true); + ui.close(); + } + }); + let failed: Option<(f64, String)> = ui.ctx().data(|d| d.get_temp(err_id)); + let now = ui.input(|i| i.time); + if let Some((at, e)) = failed.filter(|(at, _)| now - at < 6.0) { + ui.ctx() + .request_repaint_after(std::time::Duration::from_secs_f64(6.0 - (now - at))); + ui.add( + egui::Label::new(RichText::new(e).font(theme::regular(12.0)).color(p.warning)) + .wrap(), + ); + } + r + }) + .inner +} + /// A "Copy …" item for a right-click menu. pub fn copy_item(ui: &mut Ui, label: &str, value: &str) { if ui diff --git a/crates/ledger-gui/tests/tin_copy.rs b/crates/ledger-gui/tests/tin_copy.rs new file mode 100644 index 0000000..9ae1acd --- /dev/null +++ b/crates/ledger-gui/tests/tin_copy.rs @@ -0,0 +1,179 @@ +//! A TIN in the vault copies from the party page, and is never drawn. +//! +//! ```text +//! GOAL: people can use a member's or payee's full TIN (paste it into a +//! form) without the viewer ever showing it. +//! SOURCE: DECISION (Stephen, 2026-10-16: "the TIN on the party page +//! should have a click to copy too ... people still need to +//! actually access these things"). +//! INVARIANTS: Books::tin_from_vault 1-4, and +//! 5. Clicking the masked TIN puts the full, formatted number on the +//! clipboard; nothing the viewer draws holds more than the last four. +//! 6. A failed read (locked vault) copies nothing and says why. +//! ``` + +use egui::OutputCommand; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::{PartyId, Role, TinFacts, TinType}; +use ledger_gui::books::Books; +use ledger_gui::nav::Route; +use ledger_gui::{App, Source, data}; +use ledger_store::Store; +use ledger_vault::{Tin, Vault}; +use std::path::{Path, PathBuf}; + +const SSN: &str = "123-45-6789"; + +struct Fixture { + root: PathBuf, + books: PathBuf, + key: PathBuf, + party: PartyId, +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// Books with one member whose SSN is in the vault; the key lives outside +/// the books dir, as the server requires. +fn fixture(tag: &str) -> Fixture { + let root = std::env::temp_dir().join(format!("ledger-gui-tin-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let books = root.join("books"); + let key = root.join("keys/vault.key"); + std::fs::create_dir_all(&books).unwrap(); + let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap(); + let party = s.add_party("Pat Lee", &[Role::Member]).unwrap(); + let v = Vault::open(&books.join("vault"), &key, true).unwrap(); + let hash = v + .put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap()) + .unwrap(); + s.set_party_tin( + party, + Some(TinFacts { + tin_type: TinType::Ssn, + last4: "6789".into(), + vault_record: Some(hash), + purged: None, + }), + ) + .unwrap(); + Fixture { + root, + books, + key, + party, + } +} + +fn load(f: &Fixture, key: Option<&Path>) -> Books { + let mut b = data::load_once(&Source { + data_dir: f.books.join("books-data"), + books_toml: None, + today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()), + }) + .unwrap(); + b.settings.vault_key_file = key.map(Path::to_path_buf); + b +} + +fn copied(h: &Harness<'_, App>) -> Vec { + h.output() + .platform_output + .commands + .iter() + .filter_map(|c| match c { + OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .collect() +} + +fn no_digits(s: &str) { + let digits: String = s.chars().filter(char::is_ascii_digit).collect(); + assert!(!digits.contains("12345"), "{s}"); +} + +/// INVARIANTS 1, 3, 4: the vault's record, formatted; a party without +/// one is "not in the vault". +#[test] +fn reads_the_vault_with_the_key_in_force() { + let f = fixture("read"); + let b = load(&f, Some(&f.key)); + assert_eq!(b.tin_from_vault(f.party).unwrap(), SSN); + let e = b.tin_from_vault(f.party + 1).unwrap_err(); + assert!(e.contains("Not in the vault"), "{e}"); +} + +/// INVARIANT 2: a missing key is a locked vault, and none is created. +#[test] +fn a_missing_key_is_locked_and_never_created() { + let f = fixture("locked"); + let gone = f.root.join("elsewhere/vault.key"); + let b = load(&f, Some(&gone)); + let e = b.tin_from_vault(f.party).unwrap_err(); + assert!(e.contains("locked"), "{e}"); + no_digits(&e); + assert!(!gone.exists(), "reading must never create a key"); +} + +/// INVARIANT 3: a key file inside the books dir is refused. +#[test] +fn a_key_inside_the_books_is_refused() { + let f = fixture("inside"); + let inside = f.books.join("vault.key"); + std::fs::copy(&f.key, &inside).unwrap(); + let e = load(&f, Some(&inside)).tin_from_vault(f.party).unwrap_err(); + assert!(e.contains("inside the books folder"), "{e}"); +} + +/// INVARIANT 4: a tampered record is unreadable, and says nothing of it. +#[test] +fn a_bad_record_is_an_error_without_digits() { + let f = fixture("tamper"); + let rec = f.books.join(format!("vault/tin-{}.bin", f.party)); + let mut raw = std::fs::read(&rec).unwrap(); + let last = raw.len() - 1; + raw[last] ^= 1; + std::fs::write(&rec, raw).unwrap(); + let e = load(&f, Some(&f.key)).tin_from_vault(f.party).unwrap_err(); + no_digits(&e); +} + +fn party_page(b: Books, party: PartyId) -> Harness<'static, App> { + let mut h = Harness::builder() + .with_size(egui::vec2(1280.0, 1000.0)) + .build_eframe(move |cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(party))); + h.run_steps(3); + h +} + +/// INVARIANTS 5, 6, through the party page. +#[test] +fn clicking_the_masked_tin_copies_the_full_number() { + let f = fixture("click"); + let shown = "TIN on file ••••6789 (SSN)"; + let mut h = party_page(load(&f, Some(&f.key)), f.party); + assert!( + h.query_by_label_contains("12345").is_none(), + "the full number is never drawn" + ); + h.get_by_label(shown).click(); + h.step(); + assert_eq!(copied(&h), [SSN]); + h.run_steps(2); + assert!(h.query_by_label_contains("12345").is_none()); + + // Locked: nothing copied; the reason shows beside it. + let gone = f.root.join("elsewhere/vault.key"); + let mut h = party_page(load(&f, Some(&gone)), f.party); + h.get_by_label(shown).click(); + h.step(); + assert!(copied(&h).is_empty()); + h.run_steps(2); + assert!(h.query_by_label_contains("The vault is locked").is_some()); +} diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index 3751677..41b1eb1 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -235,13 +235,18 @@ damn CPA package." - **Where:** `vault/` in the books dir, listed in `.gitignore`. Never in the hash-chained journal, the SQLite projection, git, an export other than the 1099 worksheet and the CPA package (saved outside the books - dir), or the viewer. Nothing outside the vault holds more than the last - four digits. + dir), or anything the viewer draws. Nothing outside the vault holds + more than the last four digits. - **Who:** any party, whatever its roles: every member (their K-1 needs it) and every 1099 payee. The books keep the party's TIN facts (type, last four, the vault record's hash; `party.tin_set`), which never disagree with its W-9 facts' last four. The viewer shows "TIN on file - ••••1234 (SSN)" on the party's page, never more. + ••••1234 (SSN)" on the party's page, never more; clicking it copies the + full number (right-click: digits only), read from the vault at that + moment with the key in force, so people can paste it into a form + (Stephen, 2026-10-16: "people still need to actually access these + things"). The viewer never creates a key: without one it says the vault + is locked. - **What:** per party, the full TIN, its type (SSN, EIN, ITIN), and the W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random nonce per record; the file format carries a version and the party id as