Files
wanderbooks/crates/ledger-store/src/access.rs
T
swaits 9e67c8819d fix(tin): mask and refuse a comma-separated TIN (P7)
The scanner now finds a comma-grouped TIN, so the two places that hand
raw text out or let it in must follow: the access log's digest and its
64-character client texts masked hyphens but not commas, and a filed
file's printed-shape check refused 123-45-6789 but let 123,45,6789 in.

The access log uses the shared redactor, so it inherits the fix; the
filing scan keeps its own deliberately narrow shapes (a bank export's
routing numbers and amounts must still be filed, B-26) and gains the
comma renderings of an SSN's 3-2-4 and an EIN's 2-7. A comma-grouped
amount is still no TIN and still files.

No book, journal line or figure changes.
2026-10-26 20:28:41 -06:00

572 lines
21 KiB
Rust

//! A book's access log (plan I-9, 3.4: `books/<id>/access.jsonl`): one
//! record for every call that reaches the book through the server,
//! refusals included.
//!
//! ```text
//! GOAL: the owner can see who reached the books, when, what they asked
//! for and what came of it, and a record can't be changed or
//! dropped unseen.
//! SOURCE: plan I-9 ("every access through the server is logged, and
//! logging fails closed"), 3.4 (hash-chained, own genesis
//! "wanderbooks-access-v1"), the access-log record in section 7.
//! INVARIANTS:
//! 1. The log is a chain (chain.rs) with its own genesis: a record is
//! acknowledged only once it is on disk, and a changed or dropped
//! one is found when the log is read.
//! 2. A record holds what the call was, never what it carried: the
//! arguments are a SHA-256 of their canonical JSON with what may be a
//! TIN masked first (digest: the TIN shapes it knows), and every text a client gave (its
//! name and version, a request id, the tool or resource it named)
//! has any full TIN masked and is then cut to 64 characters.
//! 3. Records go in one at a time, in the order they are made.
//! ```
use crate::chain::{self, ChainError, ChainLog, Head};
use crate::journal::{canonical_json, sha256_hex};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::path::Path;
use std::sync::Mutex;
/// The access log, in a book's folder.
pub const ACCESS_FILE: &str = "access.jsonl";
/// Its genesis text.
pub const ACCESS_GENESIS: &str = "wanderbooks-access-v1";
/// The longest text a client asserts that a record keeps.
pub const MAX_ASSERTED: usize = 64;
/// Who made a call.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Principal {
/// The token's id; empty when there was no token, or none that
/// matched.
pub id: String,
/// `agent`, or `unknown` when no token matched.
pub kind: String,
/// The token's name.
pub label: String,
}
impl Principal {
/// A caller with no token that matched.
pub fn unknown() -> Self {
Self {
id: String::new(),
kind: "unknown".into(),
label: String::new(),
}
}
}
/// The client program, as it named itself (asserted, never checked).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Client {
/// Its name.
pub name: String,
/// Its version.
pub version: String,
}
/// What came of a call.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
/// It ran and answered.
Ok,
/// It ran and answered with an error (a refusal of its own: an entry
/// that doesn't balance, say).
Error,
/// It wasn't run, with the reason's code.
Refused(String),
}
/// One access, as recorded (`seq`, `ts` and `prev` are the chain's).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Access {
/// Who.
pub principal: Principal,
/// With which program, if it said.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<Client>,
/// The request's own id, if it had one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
/// `tool`, `resource`, or `connect` (a request refused before it
/// reached the books).
pub action: String,
/// The tool's name, the resource's address, or what was refused.
pub subject: String,
/// The SHA-256 of the arguments' canonical JSON.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args_digest: Option<String>,
/// What came of it.
pub result: Outcome,
/// The journal's head after the call's last write, when it wrote
/// (whatever it answered).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_after: Option<Head>,
}
/// Mask any full TIN in a client's text, then cut it to
/// [`MAX_ASSERTED`] characters (INVARIANT 2).
pub fn asserted(text: &str) -> String {
// Masked first: a TIN cut in two at the limit would keep most of its
// digits (review of slice 4).
let masked = ledger_core::parties::redact_full_tins(text);
masked.chars().take(MAX_ASSERTED).collect()
}
/// The digest of a call's arguments: the SHA-256 of their canonical JSON,
/// with every TIN masked first (INVARIANT 2), so that the digest can't be
/// matched against guesses of one. Without this a TIN whose last four the
/// books show is found among 10^5 guesses (review of slice 4). Masked:
/// any value under a key naming a TIN; any key or text holding one; any
/// number of eight digits or more (whatever its sign or fraction: a TIN
/// may start with a zero). No argument is a file's bytes (D-46: files
/// come by upload), so every text is scanned.
pub fn digest(args: &Value) -> String {
sha256_hex(canonical_json(&masked(args)).as_bytes())
}
/// `v` with every TIN masked (see [`digest`]).
fn masked(v: &Value) -> Value {
const MASK: &str = "[TIN]";
match v {
Value::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| {
let k_lower = k.to_ascii_lowercase();
let names_a_tin = k_lower == "tin"
|| k_lower.ends_with("_tin")
|| k_lower == "ssn"
|| k_lower == "ein"
|| k_lower == "itin";
let v = if names_a_tin && !v.is_null() {
Value::from(MASK)
} else {
masked(v)
};
(ledger_core::parties::redact_full_tins(k).into_owned(), v)
})
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(masked).collect()),
Value::String(s) => Value::from(ledger_core::parties::redact_full_tins(s).into_owned()),
Value::Number(n) if n.as_f64().is_none_or(|f| f.abs() >= 1e7) => Value::from(MASK),
other => other.clone(),
}
}
/// A book's access log, open for recording.
#[derive(Debug)]
pub struct AccessLog {
log: Mutex<ChainLog>,
}
impl AccessLog {
/// Open the access log in a book's folder (made if missing), checking
/// it whole (INVARIANT 1).
pub fn open(book_dir: &Path) -> Result<Self, ChainError> {
let (log, records) = ChainLog::open(&book_dir.join(ACCESS_FILE), ACCESS_GENESIS)?;
// Every record an access, as read would take it: a log that reads
// only as a chain isn't whole.
for (i, record) in records.into_iter().enumerate() {
as_recorded(i, record)?;
}
Ok(Self {
log: Mutex::new(log),
})
}
/// Record one access; Ok only once it is on disk (INVARIANTS 1, 3).
pub fn record(&self, access: &Access) -> Result<Head, ChainError> {
let mut access = access.clone();
access.principal.label = asserted(&access.principal.label);
if let Some(c) = &mut access.client {
c.name = asserted(&c.name);
c.version = asserted(&c.version);
}
access.request_id = access.request_id.as_deref().map(asserted);
access.subject = asserted(&access.subject);
let Value::Object(fields) = serde_json::to_value(&access).expect("a record serializes")
else {
unreachable!("a record is an object")
};
let mut log = self.log.lock().unwrap_or_else(|e| e.into_inner());
log.append(fields).map(|(_, head)| head)
}
/// Where the log ends now.
pub fn head(&self) -> Head {
self.log
.lock()
.unwrap_or_else(|e| e.into_inner())
.head()
.clone()
}
}
/// One record as read back: the access and the chain's fields.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recorded {
/// Its number.
pub seq: u64,
/// When (UTC, RFC 3339).
pub ts: String,
/// The access.
#[serde(flatten)]
pub access: Access,
}
/// Read a book's access log whole, checking it (INVARIANT 1). An
/// unfinished last line (a record being written, or cut short by a
/// crash) is left out.
pub fn read(book_dir: &Path) -> Result<(Vec<Recorded>, Head), ChainError> {
let read = chain::read(&book_dir.join(ACCESS_FILE), ACCESS_GENESIS)?;
let mut out = Vec::with_capacity(read.records.len());
for (i, record) in read.records.into_iter().enumerate() {
out.push(as_recorded(i, record)?);
}
Ok((out, read.head))
}
/// The `i`th record (from 0) read as an access.
fn as_recorded(i: usize, mut record: Map<String, Value>) -> Result<Recorded, ChainError> {
record.remove("prev");
record.remove("hash");
serde_json::from_value(Value::Object(record)).map_err(|e| ChainError::Damaged {
line: i as u64 + 1,
why: format!("it isn't an access record ({e})"),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn access(n: u64) -> Access {
Access {
principal: Principal {
id: "tok_abc".into(),
kind: "agent".into(),
label: "claude-code".into(),
},
client: Some(Client {
name: "claude-code".into(),
version: "2.1".into(),
}),
request_id: Some(n.to_string()),
action: "tool".into(),
subject: "books.trial_balance".into(),
args_digest: Some(digest(&serde_json::json!({"as_of": "2026-10-19"}))),
result: Outcome::Ok,
head_after: None,
}
}
#[test]
fn records_read_back_as_written() {
let dir = tempfile::tempdir().unwrap();
let log = AccessLog::open(dir.path()).unwrap();
let mut refused = access(2);
refused.result = Outcome::Refused("insufficient_scope".into());
refused.principal = Principal::unknown();
let mut posted = access(3);
posted.subject = "books.post_entry".into();
posted.head_after = Some(Head {
seq: 41,
hash: "ab".repeat(32),
});
let mut errored = access(4);
errored.result = Outcome::Error;
errored.client = None;
errored.request_id = None;
errored.args_digest = None;
let all = [access(1), refused, posted, errored];
for (i, a) in all.iter().enumerate() {
assert_eq!(log.record(a).unwrap().seq, i as u64 + 1);
}
assert_eq!(log.head().seq, 4);
let (back, head) = read(dir.path()).unwrap();
assert_eq!(head, log.head());
assert_eq!(back.len(), 4);
for (i, r) in back.iter().enumerate() {
assert_eq!(r.seq, i as u64 + 1);
assert_eq!(r.access, all[i]);
}
// The wire form the plan names.
let text = std::fs::read_to_string(dir.path().join(ACCESS_FILE)).unwrap();
let second: Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap();
assert_eq!(
second["result"],
serde_json::json!({"refused": "insufficient_scope"})
);
let first: Value = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!(first["result"], "ok");
assert_eq!(first["principal"]["kind"], "agent");
}
#[test]
fn what_a_client_asserts_is_cut_and_masked() {
let dir = tempfile::tempdir().unwrap();
let log = AccessLog::open(dir.path()).unwrap();
let mut a = access(1);
a.client = Some(Client {
name: "x".repeat(500),
version: "123-45-6789".into(),
});
a.request_id = Some("ssn 123-45-6789".into());
a.principal.label = "y".repeat(100);
a.subject = "books.123-45-6789".into();
log.record(&a).unwrap();
let text = std::fs::read_to_string(dir.path().join(ACCESS_FILE)).unwrap();
assert!(!text.contains("123-45-6789"), "{text}");
assert!(!text.contains(&"x".repeat(MAX_ASSERTED + 1)));
let (back, _) = read(dir.path()).unwrap();
let client = back[0].access.client.as_ref().unwrap();
assert_eq!(client.name.chars().count(), MAX_ASSERTED);
assert_eq!(back[0].access.principal.label.len(), MAX_ASSERTED);
assert!(!ledger_core::parties::has_full_tin(&client.version));
}
/// INVARIANT 2 (P7): a TIN written with commas between its groups
/// never reaches a record or a digest, as one written with hyphens
/// never did: a client's texts are masked, and two calls that differ
/// only in such a TIN give one digest.
#[test]
fn access_log_masks_a_comma_separated_tin() {
let dir = tempfile::tempdir().unwrap();
let log = AccessLog::open(dir.path()).unwrap();
let mut a = access(1);
a.client = Some(Client {
name: "claude-code".into(),
version: "187,05,1907".into(),
});
a.request_id = Some("ssn 187,05,1907".into());
a.principal.label = "agent 187,05,1907".into();
a.subject = "books.187,05,1907".into();
a.args_digest = Some(digest(
&serde_json::json!({"note": "SSN 187,05,1907 on file"}),
));
log.record(&a).unwrap();
let text = std::fs::read_to_string(dir.path().join(ACCESS_FILE)).unwrap();
assert!(!text.contains("187,05,1907"), "{text}");
let (back, _) = read(dir.path()).unwrap();
let client = back[0].access.client.as_ref().unwrap();
assert_eq!(client.version, "•••,••,••••");
assert_eq!(back[0].access.subject, "books.•••,••,••••");
assert_eq!(
back[0].access.request_id.as_deref(),
Some("ssn •••,••,••••")
);
// The same call with another comma-separated TIN: the same digest,
// so no guess of the TIN can be checked against it.
assert_eq!(
digest(&serde_json::json!({"note": "SSN 187,05,1907 on file"})),
digest(&serde_json::json!({"note": "SSN 604,71,7433 on file"}))
);
assert_ne!(
digest(&serde_json::json!({"note": "SSN 187,05,1907 on file"})),
digest(&serde_json::json!({"note": "SSN 187,05,1907 is gone"}))
);
}
#[test]
fn a_digest_gives_no_tin_away() {
// The same call with another TIN: the same digest, so no guess
// of the TIN can be checked against it.
for (a, b) in [
(
serde_json::json!({"party": 4, "tin": "123-45-6789", "tin_type": "ssn"}),
serde_json::json!({"party": 4, "tin": "987-81-4321", "tin_type": "ssn"}),
),
(
serde_json::json!({"party": 4, "tin": 123456789}),
serde_json::json!({"party": 4, "tin": 987814321}),
),
(
serde_json::json!({"w9": {"payee_tin": "12-3456789"}}),
serde_json::json!({"w9": {"payee_tin": "98-7814321"}}),
),
(
serde_json::json!({"note": "SSN 123-45-6789 on file"}),
serde_json::json!({"note": "SSN 987-81-4321 on file"}),
),
(
serde_json::json!({"memo": [123456789]}),
serde_json::json!({"memo": [987814321]}),
),
// Round 2: as a key, as a float, negative, with a leading zero
// lost, or run into another digit.
(
serde_json::json!({"structured": {"123-45-6789": "6100"}}),
serde_json::json!({"structured": {"987-81-4321": "6100"}}),
),
(
serde_json::json!({"x": 123456789.0}),
serde_json::json!({"x": 987814321.0}),
),
(
serde_json::json!({"x": -123456789}),
serde_json::json!({"x": -987814321}),
),
(
serde_json::json!({"x": 12345678}),
serde_json::json!({"x": 98765432}),
),
(
serde_json::json!({"x": 1123456789}),
serde_json::json!({"x": 1987814321}),
),
] {
assert_eq!(digest(&a), digest(&b), "{a}");
}
// Everything else still counts.
assert_ne!(
digest(&serde_json::json!({"party": 4, "tin": "123-45-6789"})),
digest(&serde_json::json!({"party": 5, "tin": "123-45-6789"}))
);
assert_ne!(
digest(&serde_json::json!({"amount": "12.34"})),
digest(&serde_json::json!({"amount": "12.35"}))
);
assert_ne!(
digest(&serde_json::json!({"entry_id": 1234567})),
digest(&serde_json::json!({"entry_id": 1234568}))
);
// Text with no TIN in it counts as it is.
assert_ne!(
digest(&serde_json::json!({"upload_id": "up_MTIzNDU2Nzg5"})),
digest(&serde_json::json!({"upload_id": "up_OTg3NjU0MzIx"}))
);
}
#[test]
fn a_value_under_a_key_naming_a_tin_is_masked_whatever_it_looks_like() {
// Even a value that doesn't look like a TIN (a last four, a typo):
// the key says what it is.
for key in [
"tin",
"TIN",
"payee_tin",
"Member_TIN",
"ssn",
"ein",
"itin",
] {
assert_eq!(
digest(&serde_json::json!({ key: "6789" })),
digest(&serde_json::json!({ key: "1234" })),
"{key}"
);
assert_eq!(
digest(&serde_json::json!({ key: {"last4": 6789} })),
digest(&serde_json::json!({ key: ["anything"] })),
"{key}"
);
// A TIN not given isn't one: null stays null.
assert_ne!(
digest(&serde_json::json!({ key: null })),
digest(&serde_json::json!({ key: "6789" })),
"{key}"
);
}
// Only those keys: one that merely holds the letters counts.
assert_ne!(
digest(&serde_json::json!({"tint": "6789"})),
digest(&serde_json::json!({"tint": "1234"}))
);
}
#[test]
fn digits_inside_any_text_are_masked() {
// No argument is a file's bytes any more (D-46): a run of nine
// digits is masked wherever it is, under any key.
for key in ["memo", "description", "upload_id"] {
assert_eq!(
digest(&serde_json::json!({ key: "QUJD123456789QUJD" })),
digest(&serde_json::json!({ key: "QUJD987814321QUJD" })),
"{key}"
);
}
}
#[test]
fn a_tin_at_the_cut_is_masked_whole() {
// The TIN straddles the 64-character limit.
let text = format!("{}123-45-6789", "x".repeat(58));
let kept = asserted(&text);
assert!(!kept.contains("123-45-67"), "{kept}");
assert!(kept.chars().count() <= MAX_ASSERTED);
}
#[test]
fn the_digest_is_of_canonical_arguments() {
let a = serde_json::json!({"b": 1, "a": [2, {"d": 3, "c": 4}]});
let b: Value = serde_json::from_str(r#"{"a":[2,{"c":4,"d":3}],"b":1}"#).unwrap();
assert_eq!(digest(&a), digest(&b));
assert_ne!(digest(&a), digest(&serde_json::json!({"b": 2})));
assert_eq!(digest(&a).len(), 64);
}
#[test]
fn another_chains_records_dont_pass() {
let dir = tempfile::tempdir().unwrap();
let (mut journal_like, _) =
ChainLog::open(&dir.path().join(ACCESS_FILE), "wanderbooks-principals-v1").unwrap();
let Value::Object(fields) = serde_json::to_value(access(1)).unwrap() else {
unreachable!()
};
journal_like.append(fields).unwrap();
assert!(read(dir.path()).is_err());
assert!(AccessLog::open(dir.path()).is_err());
}
#[test]
fn a_record_that_isnt_an_access_is_refused() {
let dir = tempfile::tempdir().unwrap();
let (mut log, _) = ChainLog::open(&dir.path().join(ACCESS_FILE), ACCESS_GENESIS).unwrap();
let mut m = Map::new();
m.insert("what".into(), Value::from("nothing"));
log.append(m).unwrap();
assert!(matches!(
read(dir.path()).unwrap_err(),
ChainError::Damaged { line: 1, .. }
));
// Nor is such a log opened (its book stays closed).
assert!(matches!(
AccessLog::open(dir.path()).unwrap_err(),
ChainError::Damaged { line: 1, .. }
));
}
#[test]
fn records_from_many_threads_all_land_in_one_chain() {
let dir = tempfile::tempdir().unwrap();
let log = std::sync::Arc::new(AccessLog::open(dir.path()).unwrap());
let threads: Vec<_> = (0..8)
.map(|t| {
let log = log.clone();
std::thread::spawn(move || {
for n in 0..25 {
log.record(&access(t * 100 + n)).unwrap();
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let (back, head) = read(dir.path()).unwrap();
assert_eq!(back.len(), 200);
assert_eq!(head.seq, 200);
let mut ids: Vec<String> = back
.iter()
.map(|r| r.access.request_id.clone().unwrap())
.collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 200);
}
}