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:
1 parent
9927e56431
commit
a44a8873df
7 files changed
+377
-10
No files matched your search
Generated
+1
@@ -3702,6 +3702,7 @@ dependencies = [
|
||||
"ledger-core",
|
||||
"ledger-import",
|
||||
"ledger-store",
|
||||
"ledger-vault",
|
||||
"notify",
|
||||
"proptest",
|
||||
"rust_decimal",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user