Viewer: click a vault TIN to copy the full number
check / check (push) Canceled after 0s

The party page's "TIN on file" and the W-9 card's TIN copy the full,
formatted number on click (right-click: digits only). The viewer reads
it from the vault only at that moment, with the server's key file in
force, never creates a key, refuses a key inside the books dir, and
never draws more than the last four. A locked vault copies nothing and
says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-16 08:27:13 -06:00
1 parent 9927e56431
commit a44a8873df
7 files changed
+377 -10

No files matched your search

Generated
+1
View File
@@ -3702,6 +3702,7 @@ dependencies = [
"ledger-core",
"ledger-import",
"ledger-store",
"ledger-vault",
"notify",
"proptest",
"rust_decimal",
+1
View File
@@ -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"] }
+78 -1
View File
@@ -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<String, String> {
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<AccountId> {
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
}
+26 -6
View File
@@ -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<Route>) {
fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option<Route>) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
@@ -908,10 +911,18 @@ fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option<Route>) {
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);
+84
View File
@@ -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<String, String>,
) -> 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
+179
View File
@@ -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<String> {
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());
}
+8 -3
View File
@@ -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