Parties, attachment links, statements, reconciliations (store), TIN vault crate

Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

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-15 14:43:40 -06:00
1 parent 2a188bd039
commit a299077991
54 files changed
+10961 -178

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
[package]
name = "ledger-vault"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "wanderbooks' encrypted TIN vault: full taxpayer identification numbers and W-9 files, outside the journal"
[dependencies]
chacha20poly1305 = "0.11"
sha2 = "0.10"
getrandom = { version = "0.2", features = ["std"] }
hex = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
zeroize = { version = "1", features = ["serde"] }
base64 = "0.22"
[lints]
workspace = true
[dev-dependencies]
proptest = "1"
+403
View File
@@ -0,0 +1,403 @@
//! The TIN vault: full taxpayer identification numbers and W-9 files,
//! encrypted, outside the journal.
//!
//! ```text
//! GOAL: hand the CPA complete 1099 data (full TINs) without the books ever
//! holding one: the journal is permanent and replicated in git, so a
//! TIN written there could never be purged.
//! SOURCE: docs/parties-and-attachments.md "The TIN vault"; DECISIONS
//! (owner, 2026-10-15): wanderbooks holds full TINs in an
//! encrypted vault; no external dependencies (no OS keyring).
//! INVARIANTS:
//! 1. Records live in the vault directory (the books' `vault/`, which git
//! ignores), each encrypted with XChaCha20-Poly1305 under a 256-bit key
//! from a key file outside the books dir, a fresh random nonce per
//! write.
//! 2. A record is bound to its party and kind (associated data): moved,
//! renamed, or altered, it fails to open rather than yield another
//! party's TIN.
//! 3. A TIN is exactly nine digits (hyphens allowed when given); an ITIN
//! starts with 9 with fourth and fifth digits in 50-65, 70-88, 90-92,
//! or 94-99, and an SSN or EIN doesn't start with 9 (IRS Pub. 1915;
//! docs/spec/sources/irs-tin-formats.md).
//! 4. The vault never writes a TIN or a key anywhere but its own files;
//! errors never contain one.
//! 5. `record_hash` is the SHA-256 of a record's bytes on disk, so the
//! journal can prove a record existed without holding it.
//! 6. Purging deletes the record's file; nothing else keeps a copy.
//! SCENARIOS:
//! 1. IF the key file is missing: `open` creates one (mode 0600) only when
//! asked to (`create_key`), else the vault is locked (Error::NoKey).
//! 2. IF a record is missing: `tin` returns None.
//! 3. IF a record fails to decrypt (wrong key, tampered, moved): SHOULD
//! refuse with Error::Unreadable, naming the party, never guessing.
//! ```
use base64::Engine as _;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use thiserror::Error;
use zeroize::Zeroizing;
/// What can go wrong.
#[derive(Debug, Error)]
pub enum Error {
/// The key file isn't there (the vault is locked).
#[error("the vault key file {0} is missing; the vault is locked")]
NoKey(PathBuf),
/// The key file isn't a 256-bit key.
#[error("the vault key file {0} is not a vault key (64 hex digits)")]
BadKey(PathBuf),
/// A TIN of the wrong form.
#[error("bad TIN: {0}")]
BadTin(String),
/// A record that fails to decrypt or parse.
#[error(
"the vault record for party {party} ({kind}) can't be read: wrong key, altered, or moved"
)]
Unreadable {
/// The party.
party: u64,
/// Its kind.
kind: &'static str,
},
/// Filesystem trouble.
#[error("vault io: {0}")]
Io(#[from] std::io::Error),
}
/// A TIN's type.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TinType {
/// Social Security number (an individual).
Ssn,
/// Employer identification number (a business).
Ein,
/// IRS individual taxpayer identification number.
Itin,
}
/// A full TIN: nine digits and its type. Its digits are wiped from memory
/// when dropped, and its Debug never prints them.
#[derive(Clone, Serialize, Deserialize)]
pub struct Tin {
digits: Zeroizing<String>,
/// Its type.
pub kind: TinType,
}
impl std::fmt::Debug for Tin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Tin({:?}, last4 {})", self.kind, self.last4())
}
}
impl Tin {
/// A TIN from its digits (hyphens allowed), checked against its type
/// (INVARIANT 3).
pub fn new(text: &str, kind: TinType) -> Result<Tin, Error> {
let digits: String = text.chars().filter(|c| *c != '-').collect();
if digits.len() != 9 || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(Error::BadTin("a TIN is nine digits".into()));
}
let starts_9 = digits.starts_with('9');
match kind {
TinType::Itin if !starts_9 => {
return Err(Error::BadTin("an ITIN begins with 9".into()));
}
TinType::Itin => {
let group: u8 = digits[3..5].parse().expect("two digits");
if !matches!(group, 50..=65 | 70..=88 | 90..=92 | 94..=99) {
return Err(Error::BadTin(
"an ITIN's fourth and fifth digits are 50-65, 70-88, 90-92, or 94-99"
.into(),
));
}
}
TinType::Ssn | TinType::Ein if starts_9 => {
return Err(Error::BadTin(
"a nine-digit number beginning with 9 is an ITIN, not an SSN or EIN".into(),
));
}
_ => {}
}
Ok(Tin {
digits: Zeroizing::new(digits),
kind,
})
}
/// The nine digits, for the 1099 worksheet only.
pub fn digits(&self) -> &str {
&self.digits
}
/// The last four digits (what the books keep).
pub fn last4(&self) -> &str {
&self.digits[5..]
}
/// Formatted as the IRS writes it: 123-45-6789 for an SSN or ITIN,
/// 12-3456789 for an EIN.
pub fn formatted(&self) -> String {
let d = self.digits();
match self.kind {
TinType::Ein => format!("{}-{}", &d[..2], &d[2..]),
_ => format!("{}-{}-{}", &d[..3], &d[3..5], &d[5..]),
}
}
}
/// A W-9 file kept in the vault.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct W9File {
/// Original file name.
pub file_name: String,
/// MIME type.
pub mime_type: String,
/// The bytes.
#[serde(with = "b64")]
pub bytes: Vec<u8>,
}
mod b64 {
use base64::Engine as _;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(b: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(b))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
base64::engine::general_purpose::STANDARD
.decode(s)
.map_err(serde::de::Error::custom)
}
}
const MAGIC: &[u8; 4] = b"WBV1";
const NONCE_LEN: usize = 24;
/// The two kinds of record.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Tin,
W9,
}
impl Kind {
fn name(self) -> &'static str {
match self {
Kind::Tin => "tin",
Kind::W9 => "w9",
}
}
}
/// An open vault.
pub struct Vault {
dir: PathBuf,
cipher: XChaCha20Poly1305,
}
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("dir", &self.dir)
.finish_non_exhaustive()
}
}
/// The default key file: `$XDG_CONFIG_HOME/wanderbooks/vault.key`, else
/// `~/.config/wanderbooks/vault.key`.
pub fn default_key_file() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
base.join("wanderbooks").join("vault.key")
}
impl Vault {
/// Open the vault in `dir` with the key in `key_file`. With
/// `create_key`, a missing key file is created (a fresh random key,
/// mode 0600); without, a missing one is Error::NoKey. The directory is
/// created on first write.
pub fn open(dir: &Path, key_file: &Path, create_key: bool) -> Result<Vault, Error> {
let key = match std::fs::read_to_string(key_file) {
Ok(text) => {
let text = Zeroizing::new(text);
let bytes = Zeroizing::new(
hex::decode(text.trim()).map_err(|_| Error::BadKey(key_file.to_path_buf()))?,
);
if bytes.len() != 32 {
return Err(Error::BadKey(key_file.to_path_buf()));
}
let mut k = Zeroizing::new([0u8; 32]);
k.copy_from_slice(&bytes);
k
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && create_key => {
let mut k = Zeroizing::new([0u8; 32]);
getrandom::getrandom(&mut k[..])
.map_err(|e| Error::Io(std::io::Error::other(e)))?;
write_private(key_file, hex::encode(&k[..]).as_bytes())?;
k
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::NoKey(key_file.to_path_buf()));
}
Err(e) => return Err(Error::Io(e)),
};
let cipher = XChaCha20Poly1305::new_from_slice(&key[..]).expect("32-byte key");
Ok(Vault {
dir: dir.to_path_buf(),
cipher,
})
}
fn path(&self, party: u64, kind: Kind) -> PathBuf {
self.dir.join(format!("{}-{party}.bin", kind.name()))
}
fn aad(party: u64, kind: Kind) -> Vec<u8> {
format!("wanderbooks-vault-v1|{}|party:{party}", kind.name()).into_bytes()
}
fn seal(&self, party: u64, kind: Kind, plain: &[u8]) -> Result<String, Error> {
let mut nonce = [0u8; NONCE_LEN];
getrandom::getrandom(&mut nonce).map_err(|e| Error::Io(std::io::Error::other(e)))?;
let aad = Self::aad(party, kind);
let ct = self
.cipher
.encrypt(
&XNonce::from(nonce),
Payload {
msg: plain,
aad: &aad,
},
)
.expect("encryption of an in-memory buffer");
let mut out = Vec::with_capacity(4 + NONCE_LEN + ct.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
std::fs::create_dir_all(&self.dir)?;
write_private(&self.path(party, kind), &out)?;
Ok(hex::encode(Sha256::digest(&out)))
}
fn open_record(&self, party: u64, kind: Kind) -> Result<Option<Zeroizing<Vec<u8>>>, Error> {
let bytes = match std::fs::read(self.path(party, kind)) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(Error::Io(e)),
};
let unreadable = Error::Unreadable {
party,
kind: kind.name(),
};
if bytes.len() < 4 + NONCE_LEN || &bytes[..4] != MAGIC {
return Err(unreadable);
}
let aad = Self::aad(party, kind);
let plain = self
.cipher
.decrypt(
&XNonce::try_from(&bytes[4..4 + NONCE_LEN]).expect("length checked"),
Payload {
msg: &bytes[4 + NONCE_LEN..],
aad: &aad,
},
)
.map_err(|_| unreadable)?;
Ok(Some(Zeroizing::new(plain)))
}
/// Store a party's TIN; returns the record's hash (INVARIANT 5).
pub fn put_tin(&self, party: u64, tin: &Tin) -> Result<String, Error> {
let plain = Zeroizing::new(serde_json::to_vec(tin).expect("serializes"));
self.seal(party, Kind::Tin, &plain)
}
/// A party's TIN, if the vault holds one.
pub fn tin(&self, party: u64) -> Result<Option<Tin>, Error> {
match self.open_record(party, Kind::Tin)? {
None => Ok(None),
Some(plain) => serde_json::from_slice(&plain)
.map(Some)
.map_err(|_| Error::Unreadable { party, kind: "tin" }),
}
}
/// Store a party's W-9 file; returns the record's hash.
pub fn put_w9(&self, party: u64, file: &W9File) -> Result<String, Error> {
let plain = Zeroizing::new(serde_json::to_vec(file).expect("serializes"));
self.seal(party, Kind::W9, &plain)
}
/// A party's W-9 file, if the vault holds one.
pub fn w9(&self, party: u64) -> Result<Option<W9File>, Error> {
match self.open_record(party, Kind::W9)? {
None => Ok(None),
Some(plain) => serde_json::from_slice(&plain)
.map(Some)
.map_err(|_| Error::Unreadable { party, kind: "w9" }),
}
}
/// The SHA-256 of a party's TIN record as stored, if any.
pub fn record_hash(&self, party: u64) -> Result<Option<String>, Error> {
match std::fs::read(self.path(party, Kind::Tin)) {
Ok(b) => Ok(Some(hex::encode(Sha256::digest(&b)))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::Io(e)),
}
}
/// Delete everything the vault holds for a party (INVARIANT 6). Returns
/// whether anything was there.
pub fn purge(&self, party: u64) -> Result<bool, Error> {
let mut any = false;
for kind in [Kind::Tin, Kind::W9] {
match std::fs::remove_file(self.path(party, kind)) {
Ok(()) => any = true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::Io(e)),
}
}
Ok(any)
}
}
/// Write a file readable by its owner only (0600 on Unix), creating its
/// directory.
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), Error> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
{
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
use std::io::Write;
let mut f = opts.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
/// Base64 of bytes (for callers handing a W-9 to a tool).
pub fn to_base64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
+182
View File
@@ -0,0 +1,182 @@
//! The vault against its spec (crates/ledger-vault/src/lib.rs).
use ledger_vault::{Error, Tin, TinType, Vault, W9File};
use std::path::PathBuf;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wb-vault-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn vault(d: &std::path::Path) -> Vault {
Vault::open(&d.join("vault"), &d.join("keys/vault.key"), true).unwrap()
}
/// INVARIANT 3: nine digits, hyphens allowed; ITINs start with 9 and SSNs
/// and EINs don't.
#[test]
fn tins_are_nine_digits_of_their_type() {
assert_eq!(
Tin::new("123-45-6789", TinType::Ssn).unwrap().digits(),
"123456789"
);
assert_eq!(
Tin::new("12-3456789", TinType::Ein).unwrap().last4(),
"6789"
);
// Pub. 1915: fourth and fifth digits 50-65, 70-88, 90-92, 94-99.
for ok in [
"900-50-0000",
"903-53-0000",
"900-70-0000",
"950-57-0000",
"900-90-0000",
"923-51-0000",
"982-95-0000",
"992-55-0000",
] {
assert!(Tin::new(ok, TinType::Itin).is_ok(), "{ok}");
}
for bad in [
"938-69-0000",
"931-43-0000",
"907-43-0000",
"941-35-0000",
"900-19-0000",
"900-00-0000",
] {
assert!(Tin::new(bad, TinType::Itin).is_err(), "{bad}");
}
for (bad, kind) in [
("12345678", TinType::Ssn),
("1234567890", TinType::Ssn),
("12345678a", TinType::Ein),
("123 45 6789", TinType::Ssn),
("937-57-1234", TinType::Ssn),
("985196789", TinType::Ein),
("123456789", TinType::Itin),
] {
assert!(Tin::new(bad, kind).is_err(), "{bad} as {kind:?}");
}
assert_eq!(
Tin::new("123456789", TinType::Ssn).unwrap().formatted(),
"123-45-6789"
);
assert_eq!(
Tin::new("123456789", TinType::Ein).unwrap().formatted(),
"12-3456789"
);
}
/// INVARIANT 4: Debug and errors never show the digits.
#[test]
fn a_tin_never_prints() {
let tin = Tin::new("123-45-6789", TinType::Ssn).unwrap();
let shown = format!("{tin:?}");
assert!(
!shown.contains("12345") && shown.contains("6789"),
"{shown}"
);
let err = Tin::new("123-45-678", TinType::Ssn)
.unwrap_err()
.to_string();
assert!(!err.contains("12345678"), "{err}");
}
/// INVARIANT 1, 5: a TIN and a W-9 round-trip; the record on disk holds
/// neither in the clear; record_hash matches the bytes.
#[test]
fn records_round_trip_encrypted() {
let d = dir("roundtrip");
let v = vault(&d);
let tin = Tin::new("12-3456789", TinType::Ein).unwrap();
let hash = v.put_tin(7, &tin).unwrap();
let back = v.tin(7).unwrap().unwrap();
assert_eq!(back.digits(), "123456789");
assert_eq!(back.kind, TinType::Ein);
let raw = std::fs::read(d.join("vault/tin-7.bin")).unwrap();
assert!(
!raw.windows(9).any(|w| w == b"123456789"),
"TIN in the clear"
);
assert_eq!(v.record_hash(7).unwrap().as_deref(), Some(hash.as_str()));
let w9 = W9File {
file_name: "w9.pdf".into(),
mime_type: "application/pdf".into(),
bytes: b"%PDF secret 123456789".to_vec(),
};
v.put_w9(7, &w9).unwrap();
assert_eq!(v.w9(7).unwrap().unwrap(), w9);
assert!(v.tin(8).unwrap().is_none(), "SCENARIO 2: missing is None");
// A fresh nonce each write: the same TIN twice is two different records.
let again = v.put_tin(7, &tin).unwrap();
assert_ne!(again, hash);
}
/// INVARIANT 2 and SCENARIO 3: a record moved to another party, altered,
/// or opened with another key is unreadable, never another party's TIN.
#[test]
fn moved_altered_or_foreign_records_refuse() {
let d = dir("tamper");
let v = vault(&d);
v.put_tin(1, &Tin::new("710-77-3333", TinType::Ssn).unwrap())
.unwrap();
std::fs::copy(d.join("vault/tin-1.bin"), d.join("vault/tin-2.bin")).unwrap();
assert!(matches!(v.tin(2), Err(Error::Unreadable { party: 2, .. })));
let mut raw = std::fs::read(d.join("vault/tin-1.bin")).unwrap();
let last = raw.len() - 1;
raw[last] ^= 1;
std::fs::write(d.join("vault/tin-1.bin"), &raw).unwrap();
assert!(matches!(v.tin(1), Err(Error::Unreadable { party: 1, .. })));
let other = Vault::open(&d.join("vault"), &d.join("other.key"), true).unwrap();
v.put_tin(3, &Tin::new("710-77-3333", TinType::Ssn).unwrap())
.unwrap();
assert!(matches!(other.tin(3), Err(Error::Unreadable { .. })));
}
/// SCENARIO 1: no key file and no permission to create one is a locked
/// vault; a created key file is 0600 and survives reopening.
#[test]
fn the_key_file() {
let d = dir("key");
assert!(matches!(
Vault::open(&d.join("vault"), &d.join("missing.key"), false),
Err(Error::NoKey(_))
));
let v = vault(&d);
v.put_tin(1, &Tin::new("123456789", TinType::Ein).unwrap())
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(d.join("keys/vault.key"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
let reopened = Vault::open(&d.join("vault"), &d.join("keys/vault.key"), false).unwrap();
assert_eq!(reopened.tin(1).unwrap().unwrap().digits(), "123456789");
std::fs::write(d.join("bad.key"), "not hex").unwrap();
assert!(matches!(
Vault::open(&d.join("vault"), &d.join("bad.key"), false),
Err(Error::BadKey(_))
));
}
/// INVARIANT 6: purge deletes the party's records and only theirs.
#[test]
fn purge_removes_a_party() {
let d = dir("purge");
let v = vault(&d);
let tin = Tin::new("123456789", TinType::Ein).unwrap();
v.put_tin(1, &tin).unwrap();
v.put_tin(2, &tin).unwrap();
assert!(v.purge(1).unwrap());
assert!(v.tin(1).unwrap().is_none());
assert!(!d.join("vault/tin-1.bin").exists());
assert!(v.tin(2).unwrap().is_some());
assert!(!v.purge(1).unwrap(), "nothing left to purge");
}