P2 slice 4: access
Agent tokens (wb_ and 64 hex digits), one book and one name each, kept only as SHA-256 in a chain log and in the OS keyring; made, listed and revoked only through the admin socket (`wanderbooks token ...`). The door checks them, scopes cover every tool, every call is in the access log (fail closed), and the owner passphrase is an Argon2id hash. Setup's terminal test waits for its prompts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
a298d154b6
commit
d1f3ad891a
28 files changed
+5504
-116
No files matched your search
@@ -0,0 +1,484 @@
|
||||
//! 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). A file's base64 bytes are hashed as they are:
|
||||
/// base64 never spells a TIN in the clear, and scanning megabytes of it
|
||||
/// costs more than it could protect.
|
||||
pub fn digest(args: &Value) -> String {
|
||||
sha256_hex(canonical_json(&masked(args, false)).as_bytes())
|
||||
}
|
||||
|
||||
/// Keys whose values are a file's bytes, base64 (see [`digest`]).
|
||||
const FILE_BYTES: [&str; 2] = ["content_base64", "w9_content_base64"];
|
||||
|
||||
/// `v` with every TIN masked (see [`digest`]); `bytes`: a file's base64.
|
||||
fn masked(v: &Value, bytes: bool) -> 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, FILE_BYTES.contains(&k_lower.as_str()))
|
||||
};
|
||||
(ledger_core::parties::redact_full_tins(k).into_owned(), v)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(items.iter().map(|v| masked(v, false)).collect()),
|
||||
Value::String(_) if bytes => v.clone(),
|
||||
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));
|
||||
}
|
||||
|
||||
#[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}))
|
||||
);
|
||||
// A file's bytes count as they are.
|
||||
assert_ne!(
|
||||
digest(&serde_json::json!({"content_base64": "MTIzNDU2Nzg5"})),
|
||||
digest(&serde_json::json!({"content_base64": "OTg3NjU0MzIx"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
//! Who may reach the books through the server: the root's principals
|
||||
//! (plan 3.3, 3.4), kept in `auth/principals.jsonl`.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent reaches a book through the server only with a token
|
||||
//! minted for that book, and only for what its scopes allow; a
|
||||
//! revoked token stops working at once.
|
||||
//! SOURCE: plan 3.3 ("a static bearer wb_ + 32 random bytes, stored only
|
||||
//! as a sha256, compared in constant time ... revocable"), 3.4
|
||||
//! (auth/principals.jsonl, hash-chained), 3.5.
|
||||
//! INVARIANTS:
|
||||
//! 1. A token's text is never written: the log holds its SHA-256. Its
|
||||
//! text leaves create once, to the caller, and nothing else returns
|
||||
//! it.
|
||||
//! 2. The live tokens are exactly the log replayed: every create adds
|
||||
//! one (and ends the one it replaces), every revoke ends one. The
|
||||
//! log is a chain (chain.rs), so a changed record is found when it
|
||||
//! is read; one this version can't read is refused whole.
|
||||
//! 3. Per book, a name belongs to at most one live token.
|
||||
//! 4. check compares the presented token's SHA-256 with every live
|
||||
//! token's, in constant time, with no early exit, and answers the
|
||||
//! token it matched.
|
||||
//! 5. Nothing here names a path.
|
||||
//! ```
|
||||
|
||||
use crate::chain::{ChainError, ChainLog};
|
||||
use crate::root::BookId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
/// The principals' log, in the root's `auth/`.
|
||||
pub const PRINCIPALS_FILE: &str = "principals.jsonl";
|
||||
/// Its genesis text.
|
||||
pub const PRINCIPALS_GENESIS: &str = "wanderbooks-principals-v1";
|
||||
/// How every token begins.
|
||||
pub const TOKEN_PREFIX: &str = "wb_";
|
||||
|
||||
/// What a token may do (plan 3.3).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum Scope {
|
||||
/// Reads, reports, `start`, the handbook, receipts.
|
||||
#[serde(rename = "books:read")]
|
||||
Read,
|
||||
/// Drafts, lines, attachments, notes: what changes nothing posted.
|
||||
#[serde(rename = "books:write")]
|
||||
Write,
|
||||
/// Posting, closing, reconciling, disposing, running schedules,
|
||||
/// committing settings.
|
||||
#[serde(rename = "books:post")]
|
||||
Post,
|
||||
/// The access log (later: backups, invitations).
|
||||
#[serde(rename = "books:admin")]
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Every scope, in order.
|
||||
pub const ALL: [Scope; 4] = [Scope::Read, Scope::Write, Scope::Post, Scope::Admin];
|
||||
|
||||
/// Its name, `books:read` and so on.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Scope::Read => "books:read",
|
||||
Scope::Write => "books:write",
|
||||
Scope::Post => "books:post",
|
||||
Scope::Admin => "books:admin",
|
||||
}
|
||||
}
|
||||
|
||||
/// A scope by its name, with or without `books:`.
|
||||
pub fn parse(text: &str) -> Option<Scope> {
|
||||
let short = text.strip_prefix("books:").unwrap_or(text);
|
||||
Scope::ALL
|
||||
.into_iter()
|
||||
.find(|s| s.as_str().strip_prefix("books:") == Some(short))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Scope {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A live token, as the log records it (never its text: INVARIANT 1).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Token {
|
||||
/// Its id, `tok_` and 12 hex digits.
|
||||
pub id: String,
|
||||
/// The book it opens.
|
||||
pub book: String,
|
||||
/// Its name, unique among the book's live tokens.
|
||||
pub name: String,
|
||||
/// What it may do.
|
||||
pub scopes: BTreeSet<Scope>,
|
||||
/// When it was made (UTC, RFC 3339).
|
||||
pub created: String,
|
||||
/// The hex SHA-256 of its text.
|
||||
#[serde(skip_serializing)]
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// A token's text, which is shown once (INVARIANT 1). Debug hides it.
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
/// The text itself.
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Secret(…)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the principals can't do what was asked. Never names a path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
/// The log can't be read or written.
|
||||
#[error("the record of agent tokens can't be used: {0}")]
|
||||
Log(ChainError),
|
||||
/// A record this version doesn't know (a newer wanderbooks wrote it).
|
||||
#[error("the record of agent tokens was written by a newer wanderbooks (record {0})")]
|
||||
Newer(u64),
|
||||
/// A record that contradicts the ones before it.
|
||||
#[error("the record of agent tokens is inconsistent at record {0}: {1}")]
|
||||
Inconsistent(u64, String),
|
||||
/// A name that can't name a token.
|
||||
#[error(
|
||||
"{0:?} can't name a token: use 1 to 32 lowercase letters, digits and hyphens, starting with a letter or digit"
|
||||
)]
|
||||
BadName(String),
|
||||
/// The book already has a live token by that name.
|
||||
#[error(
|
||||
"the books {book} already have a token called {name:?}: replace it, or revoke it first"
|
||||
)]
|
||||
Exists {
|
||||
/// The book.
|
||||
book: String,
|
||||
/// The name.
|
||||
name: String,
|
||||
},
|
||||
/// No live token by that name.
|
||||
#[error("the books {book} have no token called {name:?}")]
|
||||
NoSuch {
|
||||
/// The book.
|
||||
book: String,
|
||||
/// The name.
|
||||
name: String,
|
||||
},
|
||||
/// A token with no scopes would open nothing.
|
||||
#[error("a token needs at least one scope")]
|
||||
NoScopes,
|
||||
/// The system gave no randomness.
|
||||
#[error("the system gave no randomness for a token")]
|
||||
NoRandom,
|
||||
}
|
||||
|
||||
impl From<ChainError> for AuthError {
|
||||
fn from(e: ChainError) -> Self {
|
||||
AuthError::Log(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a token's name: 1 to 32 lowercase letters, digits and hyphens,
|
||||
/// starting with a letter or digit.
|
||||
pub fn check_name(name: &str) -> Result<(), AuthError> {
|
||||
let ok = (1..=32).contains(&name.len())
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& !name.starts_with('-');
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthError::BadName(name.chars().take(64).collect()))
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(bytes).into()
|
||||
}
|
||||
|
||||
fn random_hex(n: usize) -> Result<String, AuthError> {
|
||||
let mut buf = vec![0u8; n];
|
||||
getrandom::getrandom(&mut buf).map_err(|_| AuthError::NoRandom)?;
|
||||
Ok(hex::encode(buf))
|
||||
}
|
||||
|
||||
/// The root's principals: the log and the live tokens it replays to.
|
||||
#[derive(Debug)]
|
||||
pub struct Principals {
|
||||
log: ChainLog,
|
||||
live: Vec<Token>,
|
||||
/// Every token id ever made, live or not: a new one is never one of
|
||||
/// them (the log refuses an id made twice).
|
||||
ids: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Principals {
|
||||
/// Open the principals in an `auth/` folder (made empty if missing),
|
||||
/// replaying the log (INVARIANT 2).
|
||||
pub fn open(auth_dir: &Path) -> Result<Self, AuthError> {
|
||||
let (log, records) = ChainLog::open(&auth_dir.join(PRINCIPALS_FILE), PRINCIPALS_GENESIS)?;
|
||||
let mut live: Vec<Token> = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for record in records {
|
||||
let seq = record.get("seq").and_then(Value::as_u64).unwrap_or(0);
|
||||
let bad = |why: &str| AuthError::Inconsistent(seq, why.to_string());
|
||||
let event = record.get("event").and_then(Value::as_str);
|
||||
let text = |key: &str| -> Result<String, AuthError> {
|
||||
record
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| bad(&format!("no {key}")))
|
||||
};
|
||||
match event {
|
||||
Some("token.created") => {
|
||||
let id = text("id")?;
|
||||
if !seen.insert(id.clone()) {
|
||||
return Err(bad("a token id made twice"));
|
||||
}
|
||||
let scopes: BTreeSet<Scope> = record
|
||||
.get("scopes")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.ok_or_else(|| bad("its scopes can't be read"))?;
|
||||
if scopes.is_empty() {
|
||||
return Err(bad("a token with no scopes"));
|
||||
}
|
||||
let token = Token {
|
||||
id,
|
||||
book: text("book")?,
|
||||
name: text("name")?,
|
||||
scopes,
|
||||
created: text("ts")?,
|
||||
sha256: text("sha256")?,
|
||||
};
|
||||
if let Some(old) = record.get("replaces") {
|
||||
let old = old.as_str().ok_or_else(|| bad("replaces no token"))?;
|
||||
let at = live
|
||||
.iter()
|
||||
.position(|t| {
|
||||
t.id == old && t.book == token.book && t.name == token.name
|
||||
})
|
||||
.ok_or_else(|| bad("it replaces a token that isn't live"))?;
|
||||
live.remove(at);
|
||||
}
|
||||
if live
|
||||
.iter()
|
||||
.any(|t| t.book == token.book && t.name == token.name)
|
||||
{
|
||||
return Err(bad("a name given twice"));
|
||||
}
|
||||
live.push(token);
|
||||
}
|
||||
Some("token.revoked") => {
|
||||
let id = text("id")?;
|
||||
let at = live
|
||||
.iter()
|
||||
.position(|t| t.id == id)
|
||||
.ok_or_else(|| bad("it revokes a token that isn't live"))?;
|
||||
live.remove(at);
|
||||
}
|
||||
_ => return Err(AuthError::Newer(seq)),
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
log,
|
||||
live,
|
||||
ids: seen,
|
||||
})
|
||||
}
|
||||
|
||||
/// The live tokens, oldest first.
|
||||
pub fn tokens(&self) -> &[Token] {
|
||||
&self.live
|
||||
}
|
||||
|
||||
/// Make a token for `book` (INVARIANTS 1, 3). With `replace`, a live
|
||||
/// token of the same name ends in the same record; without it, one
|
||||
/// is refused.
|
||||
pub fn create(
|
||||
&mut self,
|
||||
book: &BookId,
|
||||
name: &str,
|
||||
scopes: BTreeSet<Scope>,
|
||||
replace: bool,
|
||||
) -> Result<(Token, Secret), AuthError> {
|
||||
check_name(name)?;
|
||||
if scopes.is_empty() {
|
||||
return Err(AuthError::NoScopes);
|
||||
}
|
||||
let old = self
|
||||
.live
|
||||
.iter()
|
||||
.position(|t| t.book == book.as_str() && t.name == name);
|
||||
if old.is_some() && !replace {
|
||||
return Err(AuthError::Exists {
|
||||
book: book.to_string(),
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
let text = format!("{TOKEN_PREFIX}{}", random_hex(32)?);
|
||||
let id = loop {
|
||||
let id = format!("tok_{}", random_hex(6)?);
|
||||
if !self.ids.contains(&id) {
|
||||
break id;
|
||||
}
|
||||
};
|
||||
let sha = hex::encode(sha256(text.as_bytes()));
|
||||
let mut fields = Map::new();
|
||||
fields.insert("event".into(), Value::from("token.created"));
|
||||
fields.insert("id".into(), Value::from(id.clone()));
|
||||
fields.insert("book".into(), Value::from(book.as_str()));
|
||||
fields.insert("name".into(), Value::from(name));
|
||||
fields.insert(
|
||||
"scopes".into(),
|
||||
serde_json::to_value(&scopes).expect("scopes serialize"),
|
||||
);
|
||||
fields.insert("sha256".into(), Value::from(sha.clone()));
|
||||
if let Some(at) = old {
|
||||
fields.insert("replaces".into(), Value::from(self.live[at].id.clone()));
|
||||
}
|
||||
let (record, _) = self.log.append(fields)?;
|
||||
if let Some(at) = old {
|
||||
self.live.remove(at);
|
||||
}
|
||||
let token = Token {
|
||||
id,
|
||||
book: book.to_string(),
|
||||
name: name.to_string(),
|
||||
scopes,
|
||||
created: record["ts"].as_str().unwrap_or_default().to_string(),
|
||||
sha256: sha,
|
||||
};
|
||||
self.ids.insert(token.id.clone());
|
||||
self.live.push(token.clone());
|
||||
Ok((token, Secret(text)))
|
||||
}
|
||||
|
||||
/// End `book`'s token called `name`.
|
||||
pub fn revoke(&mut self, book: &BookId, name: &str) -> Result<Token, AuthError> {
|
||||
self.revoke_if(book, name, None)
|
||||
}
|
||||
|
||||
/// End `book`'s token called `name`, only if its id is `id` when one
|
||||
/// is given (taking back a token just made, which another may have
|
||||
/// replaced meanwhile).
|
||||
pub fn revoke_if(
|
||||
&mut self,
|
||||
book: &BookId,
|
||||
name: &str,
|
||||
id: Option<&str>,
|
||||
) -> Result<Token, AuthError> {
|
||||
let at = self
|
||||
.live
|
||||
.iter()
|
||||
.position(|t| {
|
||||
t.book == book.as_str() && t.name == name && id.is_none_or(|id| t.id == id)
|
||||
})
|
||||
.ok_or_else(|| AuthError::NoSuch {
|
||||
book: book.to_string(),
|
||||
name: name.chars().take(64).collect(),
|
||||
})?;
|
||||
let mut fields = Map::new();
|
||||
fields.insert("event".into(), Value::from("token.revoked"));
|
||||
fields.insert("id".into(), Value::from(self.live[at].id.clone()));
|
||||
self.log.append(fields)?;
|
||||
Ok(self.live.remove(at))
|
||||
}
|
||||
|
||||
/// The live token whose text this is, if any (INVARIANT 4).
|
||||
pub fn check(&self, presented: &str) -> Option<&Token> {
|
||||
use subtle::ConstantTimeEq;
|
||||
let presented = sha256(presented.as_bytes());
|
||||
let mut found = None;
|
||||
for token in &self.live {
|
||||
let Ok(sha) = hex::decode(&token.sha256) else {
|
||||
continue;
|
||||
};
|
||||
if bool::from(sha.as_slice().ct_eq(&presented)) {
|
||||
found = Some(token);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn book(id: &str) -> BookId {
|
||||
BookId::parse(id).unwrap()
|
||||
}
|
||||
|
||||
fn all() -> BTreeSet<Scope> {
|
||||
Scope::ALL.into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_read_by_name() {
|
||||
for s in Scope::ALL {
|
||||
assert_eq!(Scope::parse(s.as_str()), Some(s));
|
||||
assert_eq!(Scope::parse(&s.as_str()[6..]), Some(s));
|
||||
assert_eq!(
|
||||
serde_json::to_value(s).unwrap(),
|
||||
Value::from(s.as_str()),
|
||||
"the log spells it as the plan does"
|
||||
);
|
||||
}
|
||||
assert_eq!(Scope::parse("books:view"), None);
|
||||
assert_eq!(Scope::parse(""), None);
|
||||
assert_eq!(Scope::parse("books:"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_checked() {
|
||||
for good in ["agent", "claude-code", "a", "0x", &"a".repeat(32)] {
|
||||
assert!(check_name(good).is_ok(), "{good}");
|
||||
}
|
||||
for bad in ["", "-a", "Agent", "a b", "a/b", "a.b", &"a".repeat(33), "é"] {
|
||||
assert!(check_name(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_is_checked_until_revoked_and_its_text_is_never_kept() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut p = Principals::open(dir.path()).unwrap();
|
||||
let (token, secret) = p.create(&book("acme"), "agent", all(), false).unwrap();
|
||||
let text = secret.expose().to_string();
|
||||
assert!(text.starts_with(TOKEN_PREFIX));
|
||||
assert_eq!(text.len(), 3 + 64);
|
||||
assert!(token.id.starts_with("tok_"));
|
||||
assert_eq!(p.check(&text), Some(&token));
|
||||
assert_eq!(p.check(&text[..text.len() - 1]), None);
|
||||
assert_eq!(p.check(""), None);
|
||||
assert_eq!(format!("{secret:?}"), "Secret(…)");
|
||||
let log = std::fs::read_to_string(dir.path().join(PRINCIPALS_FILE)).unwrap();
|
||||
assert!(
|
||||
!log.contains(&text[3..]),
|
||||
"the text itself is never written"
|
||||
);
|
||||
assert!(log.contains(&token.sha256));
|
||||
// Another book's token is another token.
|
||||
let (other, other_secret) = p.create(&book("beta"), "agent", all(), false).unwrap();
|
||||
assert_ne!(other.id, token.id);
|
||||
assert_eq!(p.check(other_secret.expose()).unwrap().book, "beta");
|
||||
let revoked = p.revoke(&book("acme"), "agent").unwrap();
|
||||
assert_eq!(revoked.id, token.id);
|
||||
assert_eq!(p.check(&text), None);
|
||||
assert!(p.check(other_secret.expose()).is_some());
|
||||
assert!(matches!(
|
||||
p.revoke(&book("acme"), "agent"),
|
||||
Err(AuthError::NoSuch { .. })
|
||||
));
|
||||
// Taken back only if it is still the one named.
|
||||
assert!(matches!(
|
||||
p.revoke_if(&book("beta"), "agent", Some("tok_000000000000")),
|
||||
Err(AuthError::NoSuch { .. })
|
||||
));
|
||||
assert!(p.revoke_if(&book("beta"), "agent", Some(&other.id)).is_ok());
|
||||
let (other, other_secret) = p.create(&book("beta"), "agent", all(), false).unwrap();
|
||||
// Reopened, the log replays to the same live tokens.
|
||||
drop(p);
|
||||
let p = Principals::open(dir.path()).unwrap();
|
||||
assert_eq!(p.tokens().len(), 1);
|
||||
assert_eq!(p.check(&text), None);
|
||||
assert_eq!(p.check(other_secret.expose()).unwrap().id, other.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_is_taken_until_replaced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut p = Principals::open(dir.path()).unwrap();
|
||||
let (first, first_secret) = p.create(&book("acme"), "agent", all(), false).unwrap();
|
||||
assert!(matches!(
|
||||
p.create(&book("acme"), "agent", all(), false),
|
||||
Err(AuthError::Exists { .. })
|
||||
));
|
||||
let read_only: BTreeSet<Scope> = [Scope::Read].into();
|
||||
let (second, second_secret) = p
|
||||
.create(&book("acme"), "agent", read_only.clone(), true)
|
||||
.unwrap();
|
||||
assert_ne!(first.id, second.id);
|
||||
assert_eq!(p.check(first_secret.expose()), None, "the old one ended");
|
||||
assert_eq!(p.check(second_secret.expose()).unwrap().scopes, read_only);
|
||||
assert_eq!(p.tokens().len(), 1);
|
||||
drop(p);
|
||||
let p = Principals::open(dir.path()).unwrap();
|
||||
assert_eq!(p.tokens(), &[second]);
|
||||
// A replace with nothing to replace is a create.
|
||||
let mut p = p;
|
||||
p.create(&book("acme"), "other", all(), true).unwrap();
|
||||
assert_eq!(p.tokens().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_requests_write_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut p = Principals::open(dir.path()).unwrap();
|
||||
assert!(matches!(
|
||||
p.create(&book("acme"), "Bad Name", all(), false),
|
||||
Err(AuthError::BadName(_))
|
||||
));
|
||||
assert_eq!(
|
||||
p.create(&book("acme"), "agent", BTreeSet::new(), false)
|
||||
.unwrap_err(),
|
||||
AuthError::NoScopes
|
||||
);
|
||||
let len = std::fs::metadata(dir.path().join(PRINCIPALS_FILE))
|
||||
.unwrap()
|
||||
.len();
|
||||
assert_eq!(len, 0);
|
||||
}
|
||||
|
||||
/// Append a raw record to a fresh log, then open it.
|
||||
fn open_with(records: &[Value]) -> Result<Principals, AuthError> {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(PRINCIPALS_FILE);
|
||||
let (mut log, _) = ChainLog::open(&path, PRINCIPALS_GENESIS).unwrap();
|
||||
for r in records {
|
||||
log.append(r.as_object().unwrap().clone()).unwrap();
|
||||
}
|
||||
drop(log);
|
||||
Principals::open(dir.path())
|
||||
}
|
||||
|
||||
fn created(id: &str, name: &str) -> Value {
|
||||
serde_json::json!({"event": "token.created", "id": id, "book": "acme",
|
||||
"name": name, "scopes": ["books:read"], "sha256": "00"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_that_contradicts_itself_is_refused() {
|
||||
assert!(open_with(&[created("tok_1", "a")]).is_ok());
|
||||
let cases = [
|
||||
vec![created("tok_1", "a"), created("tok_1", "b")],
|
||||
vec![created("tok_1", "a"), created("tok_2", "a")],
|
||||
vec![serde_json::json!({"event": "token.revoked", "id": "tok_9"})],
|
||||
vec![{
|
||||
let mut r = created("tok_1", "a");
|
||||
r["replaces"] = "tok_0".into();
|
||||
r
|
||||
}],
|
||||
vec![{
|
||||
let mut r = created("tok_1", "a");
|
||||
r["scopes"] = serde_json::json!([]);
|
||||
r
|
||||
}],
|
||||
vec![{
|
||||
let mut r = created("tok_1", "a");
|
||||
r["scopes"] = serde_json::json!(["books:everything"]);
|
||||
r
|
||||
}],
|
||||
vec![{
|
||||
let mut r = created("tok_1", "a");
|
||||
r.as_object_mut().unwrap().remove("book");
|
||||
r
|
||||
}],
|
||||
];
|
||||
for records in cases {
|
||||
let e = open_with(&records).unwrap_err();
|
||||
assert!(matches!(e, AuthError::Inconsistent(..)), "{records:?}: {e}");
|
||||
}
|
||||
let e = open_with(&[serde_json::json!({"event": "token.renamed"})]).unwrap_err();
|
||||
assert_eq!(e, AuthError::Newer(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_record_is_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut p = Principals::open(dir.path()).unwrap();
|
||||
p.create(&book("acme"), "agent", all(), false).unwrap();
|
||||
p.create(&book("acme"), "other", all(), false).unwrap();
|
||||
drop(p);
|
||||
let path = dir.path().join(PRINCIPALS_FILE);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::write(&path, text.replacen("books:admin", "books:read", 1)).unwrap();
|
||||
assert!(matches!(
|
||||
Principals::open(dir.path()).unwrap_err(),
|
||||
AuthError::Log(ChainError::Damaged { line: 1, .. })
|
||||
));
|
||||
// The last record too.
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{}
|
||||
{}
|
||||
",
|
||||
lines[0],
|
||||
lines[1].replacen("books:admin", "books:read", 1)
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
Principals::open(dir.path()).unwrap_err(),
|
||||
AuthError::Log(ChainError::Damaged { line: 2, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_name_no_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(PRINCIPALS_FILE), "x\n").unwrap();
|
||||
let e = Principals::open(dir.path()).unwrap_err().to_string();
|
||||
assert!(!e.contains(&dir.path().display().to_string()), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
//! Hash-chained logs beside the journal: a book's access log
|
||||
//! (`books/<id>/access.jsonl`, plan I-9) and the root's principals
|
||||
//! (`auth/principals.jsonl`, plan 3.3, 3.4).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a record, once acknowledged, is on disk and chained, and a later
|
||||
//! change to it, or to any record before it, is found when the log
|
||||
//! is read. (Whole records cut off the end, or the file deleted,
|
||||
//! leave a shorter chain that still verifies: only a head kept
|
||||
//! elsewhere finds that, plan I-10.)
|
||||
//! SOURCE: plan 3.4 ("hash-chained access log, own genesis"), I-9
|
||||
//! ("logging fails closed"), I-10.
|
||||
//! INVARIANTS:
|
||||
//! 1. A log is lines of canonical JSON (journal::canonical_json), each
|
||||
//! an object whose `seq` is 1, 2, 3 … with no gap, whose `prev` is
|
||||
//! the SHA-256 of the line before (for the first, of the log's own
|
||||
//! genesis text, so one log never passes for another), whose `ts` is
|
||||
//! a string, and whose `hash` is the SHA-256 of the record without
|
||||
//! it: the last record, which no later `prev` covers, is checked too
|
||||
//! (against damage; a forger who rewrites a record's hash as well is
|
||||
//! found only by a head kept elsewhere, plan I-10).
|
||||
//! 2. append returns Ok only once the whole line is written and
|
||||
//! synced. One that fails leaves the file cut back to where it was,
|
||||
//! and the log takes no more appends: a log whose state is in doubt
|
||||
//! is reopened (and so re-read) before it records anything else.
|
||||
//! 3. read refuses a line that breaks 1, or isn't in canonical form
|
||||
//! byte for byte, naming its seq.
|
||||
//! 4. Only an unfinished last line (no newline: an append cut short by
|
||||
//! a crash, never acknowledged) is set aside: open cuts it away,
|
||||
//! read reports it.
|
||||
//! ```
|
||||
|
||||
use crate::journal::{canonical_json, sha256_hex};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where a chain ends: its last record's seq (0 for an empty log) and the
|
||||
/// SHA-256 the next record's `prev` must be.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Head {
|
||||
/// The last record's seq.
|
||||
pub seq: u64,
|
||||
/// The last record's hash (the genesis hash for an empty log).
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Why a log can't be used. Never names a path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ChainError {
|
||||
/// A record breaks the chain (INVARIANT 3).
|
||||
Damaged {
|
||||
/// The line, counting from 1.
|
||||
line: u64,
|
||||
/// What is wrong with it.
|
||||
why: String,
|
||||
},
|
||||
/// The disk refused.
|
||||
Io(std::io::ErrorKind),
|
||||
/// An earlier append failed: the log must be reopened (INVARIANT 2).
|
||||
Broken,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ChainError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ChainError::Damaged { line, why } => write!(f, "record {line} is damaged: {why}"),
|
||||
ChainError::Io(kind) => write!(f, "the disk refused: {kind}"),
|
||||
ChainError::Broken => f.write_str("an earlier record couldn't be written"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChainError {}
|
||||
|
||||
impl From<std::io::Error> for ChainError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ChainError::Io(e.kind())
|
||||
}
|
||||
}
|
||||
|
||||
/// The `prev` of a log's first record: the SHA-256 of its genesis text.
|
||||
pub fn genesis(text: &str) -> String {
|
||||
sha256_hex(text.as_bytes())
|
||||
}
|
||||
|
||||
/// A log read and checked.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Read {
|
||||
/// Every record, in order, `seq`, `ts`, `prev` and `hash` included.
|
||||
pub records: Vec<Map<String, Value>>,
|
||||
/// Where the chain ends.
|
||||
pub head: Head,
|
||||
/// Whether an unfinished last line follows them (INVARIANT 4).
|
||||
pub torn: bool,
|
||||
/// How many bytes the whole records take: where a torn line begins.
|
||||
pub good_len: u64,
|
||||
}
|
||||
|
||||
/// Read a log and check its chain (INVARIANTS 1, 3, 4). A log that
|
||||
/// doesn't exist is empty.
|
||||
pub fn read(path: &Path, genesis_text: &str) -> Result<Read, ChainError> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
let mut prev = genesis(genesis_text);
|
||||
let mut at = 0usize;
|
||||
let mut torn = false;
|
||||
while at < bytes.len() {
|
||||
let Some(end) = bytes[at..].iter().position(|&b| b == b'\n') else {
|
||||
torn = true;
|
||||
break;
|
||||
};
|
||||
let line = &bytes[at..at + end];
|
||||
let n = records.len() as u64 + 1;
|
||||
let damaged = |why: &str| ChainError::Damaged {
|
||||
line: n,
|
||||
why: why.to_string(),
|
||||
};
|
||||
let value: Value =
|
||||
serde_json::from_slice(line).map_err(|_| damaged("it isn't a JSON record"))?;
|
||||
let canonical = canonical_json(&value);
|
||||
if canonical.as_bytes() != line {
|
||||
return Err(damaged("it was changed after it was written"));
|
||||
}
|
||||
let Value::Object(map) = value else {
|
||||
return Err(damaged("it isn't a JSON record"));
|
||||
};
|
||||
if map.get("seq").and_then(Value::as_u64) != Some(n) {
|
||||
return Err(damaged("its number is out of order"));
|
||||
}
|
||||
if map.get("prev").and_then(Value::as_str) != Some(prev.as_str()) {
|
||||
return Err(damaged("it doesn't follow the record before it"));
|
||||
}
|
||||
if !map.get("ts").is_some_and(Value::is_string) {
|
||||
return Err(damaged("it has no time"));
|
||||
}
|
||||
let mut body = map.clone();
|
||||
let own = body.remove("hash");
|
||||
if own.as_ref().and_then(Value::as_str)
|
||||
!= Some(sha256_hex(canonical_json(&Value::Object(body)).as_bytes()).as_str())
|
||||
{
|
||||
return Err(damaged("it was changed after it was written"));
|
||||
}
|
||||
prev = sha256_hex(canonical.as_bytes());
|
||||
records.push(map);
|
||||
at += end + 1;
|
||||
}
|
||||
let head = Head {
|
||||
seq: records.len() as u64,
|
||||
hash: prev,
|
||||
};
|
||||
Ok(Read {
|
||||
records,
|
||||
head,
|
||||
torn,
|
||||
good_len: at as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// A log open for appending.
|
||||
pub struct ChainLog {
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
head: Head,
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChainLog {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChainLog")
|
||||
.field("head", &self.head)
|
||||
.field("broken", &self.broken)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChainLog {
|
||||
/// Open a log (made, private, if it doesn't exist), checking it whole
|
||||
/// and cutting away an unfinished last line (INVARIANT 4). Returns its
|
||||
/// records too.
|
||||
pub fn open(
|
||||
path: &Path,
|
||||
genesis_text: &str,
|
||||
) -> Result<(Self, Vec<Map<String, Value>>), ChainError> {
|
||||
let read = read(path, genesis_text)?;
|
||||
let new = !path.exists();
|
||||
let mut options = OpenOptions::new();
|
||||
options.create(true).append(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
// One made by an older version, or loosened by hand: private again.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if file.metadata()?.permissions().mode() & 0o077 != 0 {
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
}
|
||||
if read.torn {
|
||||
file.set_len(read.good_len)?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
// A log just made is in its folder for good (its entry synced).
|
||||
#[cfg(unix)]
|
||||
if new && let Some(dir) = path.parent() {
|
||||
File::open(dir)?.sync_all()?;
|
||||
}
|
||||
Ok((
|
||||
Self {
|
||||
path: path.to_path_buf(),
|
||||
file,
|
||||
head: read.head,
|
||||
broken: false,
|
||||
},
|
||||
read.records,
|
||||
))
|
||||
}
|
||||
|
||||
/// Where the chain ends now.
|
||||
pub fn head(&self) -> &Head {
|
||||
&self.head
|
||||
}
|
||||
|
||||
/// The log's file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Append one record: `fields` and the chain's `seq`, `ts`, `prev`
|
||||
/// and `hash` (which `fields` must not hold). Returns the record as
|
||||
/// written and the new head (INVARIANT 2).
|
||||
pub fn append(&mut self, fields: Map<String, Value>) -> Result<(Value, Head), ChainError> {
|
||||
if self.broken {
|
||||
return Err(ChainError::Broken);
|
||||
}
|
||||
debug_assert!(
|
||||
!["seq", "ts", "prev", "hash"]
|
||||
.iter()
|
||||
.any(|k| fields.contains_key(*k)),
|
||||
"the chain sets seq, ts, prev and hash"
|
||||
);
|
||||
let mut record = fields;
|
||||
let seq = self.head.seq + 1;
|
||||
record.insert("seq".into(), Value::from(seq));
|
||||
record.insert(
|
||||
"ts".into(),
|
||||
Value::from(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
|
||||
);
|
||||
record.insert("prev".into(), Value::from(self.head.hash.clone()));
|
||||
let own = sha256_hex(canonical_json(&Value::Object(record.clone())).as_bytes());
|
||||
record.insert("hash".into(), Value::from(own));
|
||||
let record = Value::Object(record);
|
||||
let mut line = canonical_json(&record);
|
||||
let hash = sha256_hex(line.as_bytes());
|
||||
line.push('\n');
|
||||
let before = match self.file.metadata() {
|
||||
Ok(m) => m.len(),
|
||||
Err(e) => {
|
||||
self.broken = true;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let written = self
|
||||
.file
|
||||
.write_all(line.as_bytes())
|
||||
.and_then(|()| self.file.sync_data());
|
||||
if let Err(e) = written {
|
||||
self.broken = true;
|
||||
let _ = self.file.set_len(before);
|
||||
let _ = self.file.sync_all();
|
||||
return Err(e.into());
|
||||
}
|
||||
self.head = Head { seq, hash };
|
||||
Ok((record, self.head.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const G: &str = "wanderbooks-test-v1";
|
||||
|
||||
fn fields(n: u64) -> Map<String, Value> {
|
||||
let mut m = Map::new();
|
||||
m.insert("n".into(), Value::from(n));
|
||||
m.insert("word".into(), Value::from("café ✓"));
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_records_read_back_chained() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("log.jsonl");
|
||||
let (mut log, records) = ChainLog::open(&path, G).unwrap();
|
||||
assert!(records.is_empty());
|
||||
assert_eq!(log.head().seq, 0);
|
||||
assert_eq!(log.head().hash, genesis(G));
|
||||
let mut heads = Vec::new();
|
||||
for n in 1..=3 {
|
||||
let (record, head) = log.append(fields(n)).unwrap();
|
||||
assert_eq!(record["seq"], n);
|
||||
assert_eq!(head.seq, n);
|
||||
heads.push(head);
|
||||
}
|
||||
let back = read(&path, G).unwrap();
|
||||
assert_eq!(back.records.len(), 3);
|
||||
assert_eq!(&back.head, heads.last().unwrap());
|
||||
assert!(!back.torn);
|
||||
assert_eq!(back.records[1]["prev"], heads[0].hash.as_str());
|
||||
assert_eq!(back.records[2]["word"], "café ✓");
|
||||
// Reopened, it carries on from the same head.
|
||||
drop(log);
|
||||
let (mut log, records) = ChainLog::open(&path, G).unwrap();
|
||||
assert_eq!(records.len(), 3);
|
||||
let (_, head) = log.append(fields(4)).unwrap();
|
||||
assert_eq!(head.seq, 4);
|
||||
assert_eq!(read(&path, G).unwrap().head, head);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
// Loosened by hand: private again when next opened.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
drop(log);
|
||||
drop(ChainLog::open(&path, G).unwrap());
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_logs_genesis_doesnt_pass() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("log.jsonl");
|
||||
let (mut log, _) = ChainLog::open(&path, G).unwrap();
|
||||
log.append(fields(1)).unwrap();
|
||||
let e = read(&path, "wanderbooks-other-v1").unwrap_err();
|
||||
assert!(matches!(e, ChainError::Damaged { line: 1, .. }), "{e}");
|
||||
}
|
||||
|
||||
fn rewrite(path: &Path, f: impl FnOnce(&mut Vec<String>)) {
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
|
||||
f(&mut lines);
|
||||
let mut out = lines.join("\n");
|
||||
out.push('\n');
|
||||
std::fs::write(path, out).unwrap();
|
||||
}
|
||||
|
||||
fn three(dir: &Path) -> PathBuf {
|
||||
let path = dir.join("log.jsonl");
|
||||
let (mut log, _) = ChainLog::open(&path, G).unwrap();
|
||||
for n in 1..=3 {
|
||||
log.append(fields(n)).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn damaged_at(path: &Path) -> u64 {
|
||||
match read(path, G).unwrap_err() {
|
||||
ChainError::Damaged { line, .. } => line,
|
||||
e => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_of_change_is_found_at_its_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// A field changed: the record no longer matches its own hash.
|
||||
let path = three(dir.path());
|
||||
rewrite(&path, |l| l[1] = l[1].replace("\"n\":2", "\"n\":9"));
|
||||
assert_eq!(damaged_at(&path), 2);
|
||||
// The last one too, which no later prev covers.
|
||||
let path = three(&dir.path().join("a").tap_mkdir());
|
||||
rewrite(&path, |l| l[2] = l[2].replace("\"n\":3", "\"n\":8"));
|
||||
assert_eq!(damaged_at(&path), 3);
|
||||
// A record and its own hash rewritten together: the next line's
|
||||
// prev catches it.
|
||||
let path = three(&dir.path().join("h").tap_mkdir());
|
||||
rewrite(&path, |l| {
|
||||
let mut v: Map<String, Value> = serde_json::from_str(&l[1]).unwrap();
|
||||
v.insert("n".into(), Value::from(9));
|
||||
v.remove("hash");
|
||||
let own = sha256_hex(canonical_json(&Value::Object(v.clone())).as_bytes());
|
||||
v.insert("hash".into(), Value::from(own));
|
||||
l[1] = canonical_json(&Value::Object(v));
|
||||
});
|
||||
assert_eq!(damaged_at(&path), 3);
|
||||
// A record dropped.
|
||||
let path = three(&dir.path().join("b").tap_mkdir());
|
||||
rewrite(&path, |l| {
|
||||
l.remove(1);
|
||||
});
|
||||
assert_eq!(damaged_at(&path), 2);
|
||||
// Records swapped.
|
||||
let path = three(&dir.path().join("c").tap_mkdir());
|
||||
rewrite(&path, |l| l.swap(0, 1));
|
||||
assert_eq!(damaged_at(&path), 1);
|
||||
// Not canonical (a space added).
|
||||
let path = three(&dir.path().join("d").tap_mkdir());
|
||||
rewrite(&path, |l| l[2] = l[2].replacen(':', ": ", 1));
|
||||
assert_eq!(damaged_at(&path), 3);
|
||||
// Not JSON.
|
||||
let path = three(&dir.path().join("e").tap_mkdir());
|
||||
rewrite(&path, |l| l[0] = "nonsense".into());
|
||||
assert_eq!(damaged_at(&path), 1);
|
||||
// Not an object.
|
||||
let path = three(&dir.path().join("f").tap_mkdir());
|
||||
rewrite(&path, |l| l[0] = "[1]".into());
|
||||
assert_eq!(damaged_at(&path), 1);
|
||||
// A record without its time, or without its own hash.
|
||||
for (sub, key) in [("g", "ts"), ("i", "hash")] {
|
||||
let path = three(&dir.path().join(sub).tap_mkdir());
|
||||
rewrite(&path, |l| {
|
||||
let mut v: Map<String, Value> = serde_json::from_str(&l[2]).unwrap();
|
||||
v.remove(key);
|
||||
l[2] = canonical_json(&Value::Object(v));
|
||||
});
|
||||
assert_eq!(damaged_at(&path), 3, "a record without its {key}");
|
||||
}
|
||||
}
|
||||
|
||||
trait TapMkdir {
|
||||
fn tap_mkdir(self) -> Self;
|
||||
}
|
||||
impl TapMkdir for PathBuf {
|
||||
fn tap_mkdir(self) -> Self {
|
||||
std::fs::create_dir_all(&self).unwrap();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unfinished_last_line_is_cut_away_on_open() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = three(dir.path());
|
||||
let whole = std::fs::read(&path).unwrap();
|
||||
let mut torn = whole.clone();
|
||||
torn.extend_from_slice(br#"{"n":4,"pr"#);
|
||||
std::fs::write(&path, &torn).unwrap();
|
||||
let r = read(&path, G).unwrap();
|
||||
assert!(r.torn);
|
||||
assert_eq!(r.records.len(), 3);
|
||||
assert_eq!(r.good_len, whole.len() as u64);
|
||||
let (mut log, records) = ChainLog::open(&path, G).unwrap();
|
||||
assert_eq!(records.len(), 3);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), whole);
|
||||
let (_, head) = log.append(fields(4)).unwrap();
|
||||
assert_eq!(head.seq, 4);
|
||||
assert!(!read(&path, G).unwrap().torn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_log_isnt_opened() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = three(dir.path());
|
||||
rewrite(&path, |l| l[0] = l[0].replace("\"n\":1", "\"n\":7"));
|
||||
assert!(matches!(
|
||||
ChainLog::open(&path, G).unwrap_err(),
|
||||
ChainError::Damaged { line: 1, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_name_no_path() {
|
||||
for e in [
|
||||
ChainError::Damaged {
|
||||
line: 3,
|
||||
why: "x".into(),
|
||||
},
|
||||
ChainError::Io(std::io::ErrorKind::PermissionDenied),
|
||||
ChainError::Broken,
|
||||
] {
|
||||
assert!(!e.to_string().contains('/'), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_failed_append_leaves_the_log_whole_and_takes_no_more() {
|
||||
// /dev/full refuses every write: an append there fails, and the
|
||||
// log won't take another until reopened.
|
||||
let file = OpenOptions::new().append(true).open("/dev/full").unwrap();
|
||||
let mut log = ChainLog {
|
||||
path: PathBuf::from("/dev/full"),
|
||||
file,
|
||||
head: Head {
|
||||
seq: 0,
|
||||
hash: genesis(G),
|
||||
},
|
||||
broken: false,
|
||||
};
|
||||
assert!(matches!(log.append(fields(1)), Err(ChainError::Io(_))));
|
||||
assert_eq!(log.head().seq, 0, "the head didn't move");
|
||||
assert_eq!(log.append(fields(1)).unwrap_err(), ChainError::Broken);
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,15 @@
|
||||
//! `last_applied_seq`. There is exactly one writer: `Store` is `!Sync` by
|
||||
//! construction (it owns a `rusqlite::Connection`).
|
||||
|
||||
pub mod access;
|
||||
pub mod auth;
|
||||
pub mod blob;
|
||||
pub mod chain;
|
||||
pub mod error;
|
||||
pub mod format;
|
||||
pub mod journal;
|
||||
pub mod meta;
|
||||
pub mod passphrase;
|
||||
pub mod reconcilable;
|
||||
pub mod root;
|
||||
pub mod schema;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! The owner passphrase (plan 3.3): the human factor that the server's
|
||||
//! human pages and the viewer's consents ask for, kept as an Argon2id
|
||||
//! hash in the root's `auth/owner.json`.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: only someone who knows the passphrase gets past a check of it,
|
||||
//! and its text is never kept.
|
||||
//! SOURCE: plan 3.3 ("the owner passphrase (Argon2id, RFC 9106, m=64 MiB,
|
||||
//! t=3, p=1)"), 3.9 (consents carry it), NIST SP 800-63B 3.1.1.2
|
||||
//! (a length floor, no composition rules, any characters).
|
||||
//! INVARIANTS:
|
||||
//! 1. The file holds an Argon2id PHC string (its own random salt) and
|
||||
//! when it was set; never the passphrase.
|
||||
//! 2. A passphrase is 12 to 1024 characters, any characters; nothing
|
||||
//! else is asked of it.
|
||||
//! 3. Changing one that is set needs the one in force; setting the
|
||||
//! first needs none. The file is replaced whole (written aside,
|
||||
//! synced, renamed), so a reader sees the old or the new, never part.
|
||||
//! 4. A check uses the stored hash's own parameters, and refuses a hash
|
||||
//! weaker than the plan's, of another Argon2 version, with a short
|
||||
//! salt or output, or asking more than a check can afford (a record
|
||||
//! changed by hand).
|
||||
//! 5. Nothing here names a path or repeats a passphrase.
|
||||
//! ```
|
||||
|
||||
use argon2::password_hash::phc::PasswordHash;
|
||||
use argon2::password_hash::{PasswordHasher, PasswordVerifier};
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// The owner passphrase's file, in the root's `auth/`.
|
||||
pub const OWNER_FILE: &str = "owner.json";
|
||||
/// The shortest passphrase taken (INVARIANT 2).
|
||||
pub const MIN_CHARS: usize = 12;
|
||||
/// The longest.
|
||||
pub const MAX_CHARS: usize = 1024;
|
||||
/// Argon2id's memory, KiB (plan 3.3: 64 MiB).
|
||||
pub const M_COST: u32 = 64 * 1024;
|
||||
/// Its passes (plan 3.3).
|
||||
pub const T_COST: u32 = 3;
|
||||
/// Its lanes (plan 3.3).
|
||||
pub const P_COST: u32 = 1;
|
||||
/// The most memory a stored hash may ask a check for, KiB (1 GiB).
|
||||
const MAX_M_COST: u32 = 1024 * 1024;
|
||||
/// The most passes.
|
||||
const MAX_T_COST: u32 = 16;
|
||||
/// The most lanes.
|
||||
const MAX_P_COST: u32 = 8;
|
||||
/// The shortest salt taken, in bytes.
|
||||
const MIN_SALT: usize = 16;
|
||||
|
||||
/// Why a passphrase can't be set or checked. Never names a path, never
|
||||
/// repeats a passphrase.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PassError {
|
||||
/// Too short or too long (INVARIANT 2).
|
||||
#[error("a passphrase is {MIN_CHARS} to {MAX_CHARS} characters long")]
|
||||
Length,
|
||||
/// No passphrase is set yet.
|
||||
#[error("no owner passphrase is set yet: set one with `wanderbooks passphrase`")]
|
||||
NotSet,
|
||||
/// Changing it needs the one in force, which wasn't given or didn't
|
||||
/// match.
|
||||
#[error("that isn't the owner passphrase in force")]
|
||||
Wrong,
|
||||
/// The record can't be read, or was changed by hand.
|
||||
#[error("the owner passphrase's record can't be used: {0}")]
|
||||
Record(String),
|
||||
/// The disk refused.
|
||||
#[error("the owner passphrase couldn't be kept: {0}")]
|
||||
Io(std::io::ErrorKind),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for PassError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
PassError::Io(e.kind())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Owner {
|
||||
argon2id: String,
|
||||
set: String,
|
||||
}
|
||||
|
||||
fn argon2() -> Argon2<'static> {
|
||||
Argon2::new(
|
||||
Algorithm::Argon2id,
|
||||
Version::V0x13,
|
||||
Params::new(M_COST, T_COST, P_COST, None).expect("the plan's parameters are valid"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check a passphrase's length (INVARIANT 2).
|
||||
pub fn check_length(passphrase: &str) -> Result<(), PassError> {
|
||||
if (MIN_CHARS..=MAX_CHARS).contains(&passphrase.chars().count()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PassError::Length)
|
||||
}
|
||||
}
|
||||
|
||||
fn read(auth_dir: &Path) -> Result<Option<Owner>, PassError> {
|
||||
match std::fs::read(auth_dir.join(OWNER_FILE)) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map(Some)
|
||||
.map_err(|_| PassError::Record("it isn't what wanderbooks writes".into())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a passphrase is set.
|
||||
pub fn is_set(auth_dir: &Path) -> Result<bool, PassError> {
|
||||
Ok(read(auth_dir)?.is_some())
|
||||
}
|
||||
|
||||
/// Whether `given` is the passphrase in force (INVARIANT 4).
|
||||
pub fn verify(auth_dir: &Path, given: &str) -> Result<bool, PassError> {
|
||||
let owner = read(auth_dir)?.ok_or(PassError::NotSet)?;
|
||||
let hash = PasswordHash::new(&owner.argon2id)
|
||||
.map_err(|_| PassError::Record("its hash can't be read".into()))?;
|
||||
let params =
|
||||
Params::try_from(&hash).map_err(|_| PassError::Record("its hash can't be read".into()))?;
|
||||
// Nothing weaker than the plan's, and nothing a check couldn't afford
|
||||
// (a record changed by hand to ask for terabytes): INVARIANT 4.
|
||||
let version = hash.version.unwrap_or(0);
|
||||
let salt = hash.salt.as_ref().map_or(0, |s| s.len());
|
||||
let out = hash.hash.as_ref().map_or(0, |h| h.len());
|
||||
if hash.algorithm.as_str() != "argon2id"
|
||||
|| version != 0x13
|
||||
|| !(M_COST..=MAX_M_COST).contains(¶ms.m_cost())
|
||||
|| !(T_COST..=MAX_T_COST).contains(¶ms.t_cost())
|
||||
|| !(P_COST..=MAX_P_COST).contains(¶ms.p_cost())
|
||||
|| salt < MIN_SALT
|
||||
|| out != 32
|
||||
{
|
||||
return Err(PassError::Record("it was changed by hand".into()));
|
||||
}
|
||||
Ok(argon2().verify_password(given.as_bytes(), &hash).is_ok())
|
||||
}
|
||||
|
||||
/// Set the passphrase (INVARIANTS 1, 3): the first one with `current`
|
||||
/// None; a new one only with the one in force.
|
||||
pub fn set(auth_dir: &Path, new: &str, current: Option<&str>) -> Result<(), PassError> {
|
||||
check_length(new)?;
|
||||
if is_set(auth_dir)? {
|
||||
match current {
|
||||
Some(c) if verify(auth_dir, c)? => {}
|
||||
_ => return Err(PassError::Wrong),
|
||||
}
|
||||
}
|
||||
let hash = argon2()
|
||||
.hash_password(new.as_bytes())
|
||||
.map_err(|_| PassError::Record("it couldn't be hashed".into()))?
|
||||
.to_string();
|
||||
let owner = Owner {
|
||||
argon2id: hash,
|
||||
set: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
};
|
||||
let mut text = serde_json::to_string_pretty(&owner).expect("the record serializes");
|
||||
text.push('\n');
|
||||
replace(auth_dir, OWNER_FILE, text.as_bytes())
|
||||
}
|
||||
|
||||
/// Replace a file in `dir` whole (INVARIANT 3): written aside, 0600,
|
||||
/// synced, renamed over, the folder synced.
|
||||
fn replace(dir: &Path, name: &str, bytes: &[u8]) -> Result<(), PassError> {
|
||||
use std::io::Write;
|
||||
let aside = dir.join(format!(".{name}.new"));
|
||||
match std::fs::remove_file(&aside) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(&aside)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
std::fs::rename(&aside, dir.join(name))?;
|
||||
#[cfg(unix)]
|
||||
std::fs::File::open(dir)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FIRST: &str = "correct horse battery";
|
||||
const SECOND: &str = "staple ünïcode ✓ phrase";
|
||||
|
||||
#[test]
|
||||
fn lengths_are_the_only_rule() {
|
||||
assert_eq!(check_length("short"), Err(PassError::Length));
|
||||
assert_eq!(check_length(&"a".repeat(11)), Err(PassError::Length));
|
||||
assert!(check_length(&"a".repeat(12)).is_ok());
|
||||
assert!(
|
||||
check_length("ééééééééééé✓").is_ok(),
|
||||
"characters, not bytes"
|
||||
);
|
||||
assert!(check_length(&"a".repeat(1024)).is_ok());
|
||||
assert_eq!(check_length(&"a".repeat(1025)), Err(PassError::Length));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_checked_and_changed_only_with_the_one_in_force() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!is_set(dir.path()).unwrap());
|
||||
assert_eq!(verify(dir.path(), FIRST), Err(PassError::NotSet));
|
||||
set(dir.path(), FIRST, None).unwrap();
|
||||
assert!(is_set(dir.path()).unwrap());
|
||||
assert!(verify(dir.path(), FIRST).unwrap());
|
||||
assert!(!verify(dir.path(), "correct horse batterY").unwrap());
|
||||
assert!(!verify(dir.path(), "").unwrap());
|
||||
// Changing needs the one in force.
|
||||
assert_eq!(set(dir.path(), SECOND, None), Err(PassError::Wrong));
|
||||
assert_eq!(
|
||||
set(dir.path(), SECOND, Some("not the passphrase")),
|
||||
Err(PassError::Wrong)
|
||||
);
|
||||
assert!(verify(dir.path(), FIRST).unwrap(), "unchanged");
|
||||
set(dir.path(), SECOND, Some(FIRST)).unwrap();
|
||||
assert!(verify(dir.path(), SECOND).unwrap());
|
||||
assert!(!verify(dir.path(), FIRST).unwrap());
|
||||
assert_eq!(
|
||||
set(dir.path(), "short", Some(SECOND)),
|
||||
Err(PassError::Length)
|
||||
);
|
||||
// The file holds a hash, never the passphrase, private.
|
||||
let text = std::fs::read_to_string(dir.path().join(OWNER_FILE)).unwrap();
|
||||
assert!(!text.contains("staple"));
|
||||
assert!(text.contains("$argon2id$v=19$m=65536,t=3,p=1$"), "{text}");
|
||||
assert!(!dir.path().join(".owner.json.new").exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.path().join(OWNER_FILE))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_weakened_or_mangled_record_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
set(dir.path(), FIRST, None).unwrap();
|
||||
let path = dir.path().join(OWNER_FILE);
|
||||
let good = std::fs::read_to_string(&path).unwrap();
|
||||
for (from, to) in [
|
||||
("m=65536", "m=8"),
|
||||
("m=65536", "m=2097152"),
|
||||
("t=3", "t=1"),
|
||||
("t=3", "t=99"),
|
||||
("p=1", "p=64"),
|
||||
("v=19", "v=16"),
|
||||
("$argon2id$", "$argon2i$"),
|
||||
("$argon2id$", "nonsense"),
|
||||
] {
|
||||
std::fs::write(&path, good.replace(from, to)).unwrap();
|
||||
assert!(
|
||||
matches!(verify(dir.path(), FIRST), Err(PassError::Record(_))),
|
||||
"{to}"
|
||||
);
|
||||
}
|
||||
std::fs::write(&path, "not json").unwrap();
|
||||
assert!(matches!(
|
||||
verify(dir.path(), FIRST),
|
||||
Err(PassError::Record(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leftover_from_an_interrupted_change_doesnt_stop_the_next() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(".owner.json.new"), "half").unwrap();
|
||||
set(dir.path(), FIRST, None).unwrap();
|
||||
assert!(verify(dir.path(), FIRST).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_name_no_path_and_repeat_no_passphrase() {
|
||||
for e in [
|
||||
PassError::Length,
|
||||
PassError::NotSet,
|
||||
PassError::Wrong,
|
||||
PassError::Record("x".into()),
|
||||
PassError::Io(std::io::ErrorKind::PermissionDenied),
|
||||
] {
|
||||
let text = e.to_string();
|
||||
assert!(!text.contains('/') && !text.contains(FIRST), "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ pub const MARKER_FILE: &str = ".wanderbooks-root";
|
||||
pub const BOOKS_DIR: &str = "books";
|
||||
/// The sockets and runtime state (plan 3.9).
|
||||
pub const RUN_DIR: &str = "run";
|
||||
/// Who may reach the books: principals and the owner passphrase.
|
||||
pub const AUTH_DIR: &str = "auth";
|
||||
/// Old copies moved aside at adoption, never deleted (plan 5.5).
|
||||
pub const LEGACY_DIR: &str = "legacy";
|
||||
/// A book's TIN vault folder, in its own folder.
|
||||
@@ -513,6 +515,14 @@ impl Root {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The root's `auth/` folder (its principals and the owner passphrase),
|
||||
/// made 0700 if missing and refused if it isn't private.
|
||||
pub fn auth_dir(&self) -> Result<PathBuf, RootError> {
|
||||
self.private_subdir(AUTH_DIR, false)
|
||||
.map(|(dir, _)| dir)
|
||||
.map_err(|e| self.hide(e))
|
||||
}
|
||||
|
||||
/// The root's `run/` folder (its sockets and locks), made 0700 if
|
||||
/// missing and refused if it isn't private.
|
||||
pub fn run_dir(&self) -> Result<PathBuf, RootError> {
|
||||
|
||||
Reference in new issue
Block a user