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:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:20 -06:00
1 parent a298d154b6
commit d1f3ad891a
28 files changed
+5504 -116

No files matched your search

+61 -5
View File
@@ -395,7 +395,11 @@ fn print_usage() {
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
eprintln!(" wanderbooks setup [--root PATH] [--home DIR]");
eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID");
eprintln!(" wanderbooks mcp --book ID");
eprintln!(
" wanderbooks token create --book ID | list | revoke --book ID | print --book ID"
);
eprintln!(" wanderbooks mcp --book ID [--token NAME]");
eprintln!(" wanderbooks passphrase");
eprintln!(" wanderbooks serve | close | doctor");
eprintln!();
eprintln!(" new make a books directory and start the agent in it (alias: init)");
@@ -403,7 +407,9 @@ fn print_usage() {
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
eprintln!(" setup make the books' private folder (only your account can open it)");
eprintln!(" books list, start or adopt books (`books --help`)");
eprintln!(" token make, list or revoke an agent's token for books (`token --help`)");
eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)");
eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)");
eprintln!(" serve hold the books open until Ctrl-C");
eprintln!(" close close the books now: everything holding them lets go");
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
@@ -864,7 +870,7 @@ fn run_setup(args: Vec<String>) -> i32 {
None
};
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
Ok((_, report)) => {
Ok((found, report)) => {
let what = if report.created {
"made"
} else if report.tightened {
@@ -876,7 +882,7 @@ fn run_setup(args: Vec<String>) -> i32 {
if report.recorded {
println!("It isn't in the default place, so where it is was noted for next time.");
}
0
owner_passphrase(&found)
}
Err(e) => {
eprintln!("error: {e}");
@@ -885,6 +891,51 @@ fn run_setup(args: Vec<String>) -> i32 {
}
}
/// After setup: the owner passphrase, asked for at a terminal if none is
/// set yet; elsewhere (a script, an agent) only said how to set it.
#[cfg(unix)]
fn owner_passphrase(root: &ledger_store::root::Root) -> i32 {
let auth = match root.auth_dir() {
Ok(dir) => dir,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
match ledger_store::passphrase::is_set(&auth) {
Ok(true) => return 0,
Ok(false) => {}
Err(e) => {
eprintln!("error: {e}");
return 1;
}
}
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
println!("No owner passphrase yet: set one in a terminal with `wanderbooks passphrase`.");
return 0;
}
println!(
"Now the owner passphrase: what the books ask you for before anything only you may do."
);
match ledger_host::commands::set_passphrase_at_terminal(&auth) {
Ok(()) => {
println!("The owner passphrase is set.");
0
}
Err(why) => {
eprintln!(
"error: {why}. The folder is made; set the passphrase with `wanderbooks passphrase`."
);
1
}
}
}
#[cfg(not(unix))]
fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 {
0
}
/// `wanderbooks dev-stdio`: the books server over stdio, in this process.
fn run_dev_stdio(args: Vec<String>) -> i32 {
match ledger_mcp::run("wanderbooks dev-stdio", args) {
@@ -927,6 +978,8 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
"serve" => ledger_host::commands::serve(&prog, args),
"close" => ledger_host::commands::close(&prog, args),
"doctor" => ledger_host::commands::doctor(&prog, args),
"token" => ledger_host::commands::token(&prog, args),
"passphrase" => ledger_host::commands::passphrase(&prog, args),
_ => ledger_host::commands::books(&prog, args),
};
match result {
@@ -963,7 +1016,10 @@ fn main() {
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
Some("setup") => std::process::exit(run_setup(all.collect())),
Some("server") => std::process::exit(run_server(all.collect())),
Some(mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor")) => {
Some(
mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor" | "token"
| "passphrase"),
) => {
let mode = mode.to_string();
std::process::exit(run_local(&mode, all.collect()))
}
@@ -999,7 +1055,7 @@ fn main() {
}
if let Some(
mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve"
| "close" | "books" | "doctor"),
| "close" | "books" | "doctor" | "token" | "passphrase"),
) = cmd.as_deref()
{
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
+713 -9
View File
@@ -22,6 +22,8 @@ type Client = RunningService<RoleClient, ClientConfig>;
/// A home with a data root made by `wanderbooks setup`.
struct Home {
dir: tempfile::TempDir,
/// A token for each book, made on first use (all scopes).
tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
impl Home {
@@ -37,7 +39,32 @@ impl Home {
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self { dir }
Self {
dir,
tokens: Default::default(),
}
}
/// The token tests use for `book`, made through the admin socket the
/// first time.
fn token(&self, book: &str) -> String {
let mut tokens = self.tokens.lock().unwrap();
if let Some(t) = tokens.get(book) {
return t.clone();
}
let text = match self.admin(Request::TokenCreate {
book: book.into(),
name: "test".into(),
scopes: ["read", "write", "post", "admin"]
.map(String::from)
.to_vec(),
replace: true,
}) {
Response::TokenCreated { text, .. } => text.0,
other => panic!("{other:?}"),
};
tokens.insert(book.to_string(), text.clone());
text
}
fn root(&self) -> PathBuf {
@@ -130,22 +157,45 @@ impl Drop for Server {
/// An HTTP client for the server's MCP socket.
fn mcp_http(home: &Home) -> reqwest::Client {
mcp_http_builder(home).build().unwrap()
}
fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
// The short path is good in this process only, while it lives: kept.
let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap();
let path = short.path().to_path_buf();
std::mem::forget(short);
reqwest::Client::builder()
.unix_socket(path)
reqwest::Client::builder().unix_socket(path)
}
/// An HTTP client for the server's MCP socket that sends `book`'s token
/// on every request.
fn mcp_http_for(home: &Home, book: &str) -> reqwest::Client {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", home.token(book)).parse().unwrap(),
);
mcp_http_builder(home)
.default_headers(headers)
.build()
.unwrap()
}
async fn connect(home: &Home, book: &str) -> Result<Client, String> {
let token = home.token(book);
connect_with(home, book, Some(token)).await
}
/// [`connect`], with this token (or none).
async fn connect_with(home: &Home, book: &str, token: Option<String>) -> Result<Client, String> {
let http = mcp_http(home);
let transport = StreamableHttpClientTransport::with_client(
http,
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp")),
);
let mut config =
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"));
if let Some(token) = token {
config = config.auth_header(token);
}
let transport = StreamableHttpClientTransport::with_client(http, config);
ClientConfig::default()
.serve(transport)
.await
@@ -266,7 +316,9 @@ async fn every_book_is_served_on_its_own_route() {
home.assert_hidden(&start.to_string());
assert!(start.to_string().contains("EUR"), "{start}");
// A route for no books says so, and names no folder.
let err = connect(&home, "nobody").await.unwrap_err();
let err = connect_with(&home, "nobody", Some(home.token("acme")))
.await
.unwrap_err();
home.assert_hidden(&err);
drop((acme, beta));
assert!(server.close().success());
@@ -714,7 +766,7 @@ async fn a_call_whose_client_hung_up_finishes_before_the_close() {
let home = Home::new("hangup");
let mut server = Server::start(&home);
home.create("acme", "USD");
let http = mcp_http(&home);
let http = mcp_http_for(&home, "acme");
let session = raw_session(&http, "acme").await;
// A large document: its filing takes long enough to close mid-way.
let mut bytes = b"%PDF-1.4\n".to_vec();
@@ -1063,3 +1115,655 @@ fn adoption_checks_the_vault_key_and_the_settings_file() {
assert!(book.join("vault/tin-1.bin").is_file());
assert!(server.close().success());
}
// P2 slice 4: tokens, scopes and the access log (local.rs INVARIANT 8,
// ledger-mcp access.rs, scopes.rs; ledger-store auth.rs, access.rs).
impl Home {
/// A token for `book` named `name` with `scopes`, made (or remade).
fn token_with(&self, book: &str, name: &str, scopes: &[&str]) -> String {
match self.admin(Request::TokenCreate {
book: book.into(),
name: name.into(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
replace: true,
}) {
Response::TokenCreated { text, .. } => text.0,
other => panic!("{other:?}"),
}
}
fn book_dir(&self, book: &str) -> PathBuf {
self.root().join("books").join(book)
}
}
/// A raw initialize on `book`'s route with `bearer` (if any): its status,
/// its refusal header, its session id and its body.
async fn raw_initialize(
home: &Home,
book: &str,
bearer: Option<&str>,
) -> (u16, Option<String>, Option<String>, String) {
let mut r = mcp_http(home)
.post(format!("http://localhost/b/{book}/mcp"))
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "raw", "version": "0"}}})
.to_string(),
);
if let Some(b) = bearer {
r = r.bearer_auth(b);
}
let r = r.send().await.unwrap();
let header = |name: &str| {
r.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let refused = header("x-wanderbooks-refused");
let session = header("mcp-session-id");
let status = r.status().as_u16();
if status == 401 {
assert_eq!(
header("www-authenticate").as_deref(),
Some("Bearer realm=\"wanderbooks\"")
);
}
(status, refused, session, r.text().await.unwrap())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_token_opens_only_its_own_books_until_revoked() {
let home = Home::new("tokens");
let server = Server::start(&home);
home.create("acme", "USD");
home.create("beta", "USD");
// None, or one that isn't a token: 401, recorded, nothing named.
for bearer in [None, Some("wb_nonsense"), Some("")] {
let (status, refused, session, body) = raw_initialize(&home, "acme", bearer).await;
assert_eq!(status, 401, "{bearer:?}: {body}");
assert_eq!(refused.as_deref(), Some("unauthorized"));
assert!(session.is_none());
assert!(body.contains("token"), "{body}");
home.assert_hidden(&body);
}
// Another book's token: 403.
let beta = home.token("beta");
let (status, refused, _, body) = raw_initialize(&home, "acme", Some(&beta)).await;
assert_eq!(status, 403, "{body}");
assert_eq!(refused.as_deref(), Some("wrong_books"));
assert!(body.contains("beta"), "{body}");
// Its own: in.
let acme = connect(&home, "acme").await.unwrap();
chart(&acme).await;
// Revoked: the very next call is refused, on the same session.
match home.admin(Request::TokenRevoke {
book: "acme".into(),
name: "test".into(),
id: None,
}) {
Response::TokenRevoked(t) => assert_eq!(t.name, "test"),
other => panic!("{other:?}"),
}
assert!(call(&acme, "books.chart", json!({})).await.is_err());
let (status, ..) = raw_initialize(&home, "acme", Some(&home.token("acme"))).await;
assert_eq!(status, 401, "the remembered token was revoked");
// The tokens listed carry no text.
match home.admin(Request::TokenList) {
Response::Tokens(tokens) => {
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].book, "beta");
let text = serde_json::to_string(&tokens).unwrap();
assert!(!text.contains(&beta[3..]));
}
other => panic!("{other:?}"),
}
// Every refusal at the door is in acme's access log.
let (records, _) = ledger_store::access::read(&home.book_dir("acme")).unwrap();
let refused: Vec<&str> = records
.iter()
.filter_map(|r| match &r.access.result {
ledger_store::access::Outcome::Refused(code) => Some(code.as_str()),
_ => None,
})
.collect();
assert_eq!(
refused,
[
"unauthorized",
"unauthorized",
"unauthorized",
"wrong_books",
"unauthorized",
"unauthorized"
],
"{records:?}"
);
drop(acme);
assert!(server.close().success());
}
/// The tool names a client is offered.
async fn offered(c: &Client) -> Vec<String> {
c.list_all_tools()
.await
.unwrap()
.into_iter()
.map(|t| t.name.to_string())
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_token_does_only_what_its_scopes_allow() {
let home = Home::new("scopes");
let server = Server::start(&home);
home.create("acme", "USD");
let full = connect(&home, "acme").await.unwrap();
chart(&full).await;
let entry = call(
&full,
"books.draft_entry",
json!({"date": "2026-03-01", "description": "lunch"}),
)
.await
.unwrap()["entry_id"]
.as_u64()
.unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
call(
&full,
"books.add_line",
json!({"entry_id": entry, "account": account, "side": side, "amount": "5.00"}),
)
.await
.unwrap();
}
let head = || ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap();
let before = head().head_hash().to_string();
// Read only.
let read = home.token_with("acme", "reader", &["read"]);
let reader = connect_with(&home, "acme", Some(read)).await.unwrap();
let tools = offered(&reader).await;
assert!(tools.contains(&"books.chart".to_string()));
for hidden in ["books.add_line", "books.post_entry", "books.access_log"] {
assert!(!tools.contains(&hidden.to_string()), "{hidden} offered");
}
assert!(call(&reader, "books.chart", json!({})).await.is_ok());
let err = call(
&reader,
"books.add_account",
json!({"code": "1100", "name": "Bank", "account_type": "asset"}),
)
.await
.unwrap_err();
assert!(err.contains("needs books:write"), "{err}");
assert!(err.contains("reader"), "{err}");
// Write: a preview, never the post.
let write = home.token_with("acme", "writer", &["read", "write"]);
let writer = connect_with(&home, "acme", Some(write)).await.unwrap();
let tools = offered(&writer).await;
assert!(
tools.contains(&"books.post_entry".to_string()),
"it may preview"
);
assert!(!tools.contains(&"books.create_invoice".to_string()));
let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await;
if let Err(e) = &preview {
assert!(
!e.contains("needs books"),
"a preview is refused by scope: {e}"
);
}
let err = call(
&writer,
"books.post_entry",
json!({"entry_id": entry, "confirmed": true}),
)
.await
.unwrap_err();
assert!(err.contains("needs books:post"), "{err}");
assert_eq!(head().head_hash(), before, "nothing posted");
// No admin: no access log.
let poster = home.token_with("acme", "poster", &["read", "write", "post"]);
let poster = connect_with(&home, "acme", Some(poster)).await.unwrap();
let err = call(&poster, "books.access_log", json!({}))
.await
.unwrap_err();
assert!(err.contains("needs books:admin"), "{err}");
// A token without books:read reads no resource.
let blind = home.token_with("acme", "blind", &["post"]);
let blind = connect_with(&home, "acme", Some(blind)).await.unwrap();
assert!(
blind
.read_resource(rmcp::model::ReadResourceRequestParams::new(
"wanderbooks://handbook"
))
.await
.is_err()
);
assert!(offered(&blind).await.iter().all(|t| t != "books.chart"));
drop((full, reader, writer, poster, blind));
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_call_is_recorded_once_refusals_included() {
let home = Home::new("accesslog");
let server = Server::start(&home);
home.create("acme", "USD");
let admin_text = home.token("acme");
let full = connect(&home, "acme").await.unwrap();
chart(&full).await; // 3 ok
let id = post_one(&full, 7).await; // draft, 2 lines, post: ok or an error
let _ = id;
// A call that errs (bad arguments) and one refused by scope.
assert!(
call(&full, "books.get_entry", json!({"entry_id": 999_999}))
.await
.is_err()
);
let reader = home.token_with("acme", "reader", &["read"]);
let reader = connect_with(&home, "acme", Some(reader)).await.unwrap();
let marker = "a-distinctive-argument-1234";
assert!(
call(
&reader,
"books.add_account",
json!({"code": "1100", "name": marker, "account_type": "asset"})
)
.await
.is_err()
);
// A request with no token at all.
assert_eq!(raw_initialize(&home, "acme", None).await.0, 401);
// Read through the tool (admin): newest first, all of them.
let log = call(&full, "books.access_log", json!({"limit": 500}))
.await
.unwrap();
let records = log["records"].as_array().unwrap();
assert_eq!(log["total"].as_u64().unwrap() as usize, records.len());
assert_eq!(log["more"], false);
assert_eq!(log["head"]["seq"].as_u64().unwrap() as usize, records.len());
let seqs: Vec<u64> = records.iter().map(|r| r["seq"].as_u64().unwrap()).collect();
let mut sorted = seqs.clone();
sorted.sort_unstable_by(|a, b| b.cmp(a));
assert_eq!(seqs, sorted, "newest first");
let tools: Vec<&Value> = records.iter().filter(|r| r["action"] == "tool").collect();
// Every tool call made so far, one record each: 3 accounts, the
// posting (draft, two lines, post: up to four), the failed lookup,
// the refusal.
let by = |subject: &str| tools.iter().filter(|r| r["subject"] == subject).count();
assert_eq!(by("books.add_account"), 4);
assert_eq!(by("books.get_entry"), 1);
assert_eq!(by("books.draft_entry"), 1);
assert_eq!(by("books.add_line"), 2);
assert_eq!(by("books.post_entry"), 1);
assert_eq!(by("books.access_log"), 0, "recorded once answered");
let refused: Vec<&&Value> = tools
.iter()
.filter(|r| r["result"]["refused"] == "insufficient_scope")
.collect();
assert_eq!(refused.len(), 1);
assert_eq!(refused[0]["principal"]["label"], "reader");
assert!(
refused[0]["args_digest"]
.as_str()
.is_some_and(|d| d.len() == 64)
);
let errored = tools.iter().filter(|r| r["result"] == "error").count();
assert!(errored >= 1);
// Writes carry the head after them; reads don't.
let added = tools
.iter()
.find(|r| r["subject"] == "books.add_account" && r["result"] == "ok")
.unwrap();
assert!(added["head_after"]["seq"].as_u64().unwrap() >= 1);
assert!(refused[0].get("head_after").is_none());
// Each write's head is its own: oldest first, the heads climb, and
// the last is the journal's head now; a call that wrote nothing has
// none (review of slice 4, round 2).
let mut heads: Vec<(u64, String)> = Vec::new();
for r in records.iter().rev() {
let wrote = r.get("head_after").is_some();
let reads = ["books.get_entry", "books.access_log", "books.chart"]
.contains(&r["subject"].as_str().unwrap_or_default());
if reads || r["action"] != "tool" || r["result"] != "ok" {
assert!(!wrote, "{r}");
}
if wrote {
heads.push((
r["head_after"]["seq"].as_u64().unwrap(),
r["head_after"]["hash"].as_str().unwrap().to_string(),
));
}
}
assert!(heads.windows(2).all(|w| w[0].0 < w[1].0), "{heads:?}");
let snap = ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap();
assert_eq!(heads.last().unwrap().1, snap.head_hash());
assert_eq!(added["client"]["name"], "rmcp");
let door: Vec<&Value> = records
.iter()
.filter(|r| r["action"] == "connect")
.collect();
assert_eq!(door.len(), 1);
assert_eq!(door[0]["result"]["refused"], "unauthorized");
assert_eq!(door[0]["principal"]["kind"], "unknown");
// The access_log call itself is the next record.
let next = call(&full, "books.access_log", json!({"limit": 1}))
.await
.unwrap();
assert_eq!(next["records"][0]["subject"], "books.access_log");
assert_eq!(next["more"], true);
// Filters and paging.
let mine = call(&full, "books.access_log", json!({"token": "reader"}))
.await
.unwrap();
assert!(
mine["records"]
.as_array()
.unwrap()
.iter()
.all(|r| r["principal"]["label"] == "reader")
);
let only_refused = call(&full, "books.access_log", json!({"outcome": "refused"}))
.await
.unwrap();
assert_eq!(only_refused["records"].as_array().unwrap().len(), 2);
let top = seqs[0];
let older = call(
&full,
"books.access_log",
json!({"before": top, "limit": 2}),
)
.await
.unwrap();
assert!(
older["records"]
.as_array()
.unwrap()
.iter()
.all(|r| r["seq"].as_u64().unwrap() < top)
);
for bad in [
json!({"limit": 0}),
json!({"limit": 501}),
json!({"outcome": "maybe"}),
] {
assert!(call(&full, "books.access_log", bad).await.is_err());
}
// On disk: a chain that verifies, holding no token and no argument.
let (on_disk, head) = ledger_store::access::read(&home.book_dir("acme")).unwrap();
assert_eq!(head.seq as usize, on_disk.len());
let text = std::fs::read_to_string(home.book_dir("acme").join("access.jsonl")).unwrap();
assert!(
!text.contains(&admin_text[3..]),
"a token's text was logged"
);
assert!(!text.contains(marker), "an argument was logged");
assert_eq!(mode(&home.book_dir("acme").join("access.jsonl")), 0o600);
drop((full, reader));
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_session_is_used_only_with_the_token_that_opened_it() {
let home = Home::new("sessions");
let server = Server::start(&home);
home.create("acme", "USD");
let first = home.token("acme");
let second = home.token_with("acme", "other", &["read", "write", "post", "admin"]);
let (status, _, session, _) = raw_initialize(&home, "acme", Some(&first)).await;
assert_eq!(status, 200);
let session = session.unwrap();
let list = |bearer: String| {
let session = session.clone();
let http = mcp_http(&home);
async move {
http.post("http://localhost/b/acme/mcp")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-session-id", session)
.header("mcp-protocol-version", "2025-06-18")
.bearer_auth(bearer)
.body(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).to_string())
.send()
.await
.unwrap()
}
};
let r = list(second).await;
assert_eq!(r.status().as_u16(), 403);
assert_eq!(r.headers()["x-wanderbooks-refused"], "session_token");
let r = list(first).await;
assert_eq!(r.status().as_u16(), 200, "{:?}", r.text().await);
assert!(server.close().success());
}
#[test]
fn a_damaged_access_log_keeps_its_books_closed() {
let home = Home::new("damagedlog");
let server = Server::start(&home);
home.create("acme", "USD");
home.create("beta", "USD");
assert!(server.close().success());
std::fs::write(home.book_dir("acme").join("access.jsonl"), "not a record\n").unwrap();
let server = Server::start(&home);
match home.admin(Request::List) {
Response::Books(books) => {
let acme = books.iter().find(|b| b.id == "acme").unwrap();
assert!(!acme.open);
let why = acme.problem.as_deref().unwrap();
assert!(why.contains("access log"), "{why}");
home.assert_hidden(why);
assert!(books.iter().find(|b| b.id == "beta").unwrap().open);
}
other => panic!("{other:?}"),
}
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn damaged_token_records_refuse_every_agent_but_not_the_list() {
let home = Home::new("damagedauth");
let server = Server::start(&home);
home.create("acme", "USD");
let token = home.token("acme");
assert!(server.close().success());
let path = home.root().join("auth/principals.jsonl");
let text = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, text.replace("books:admin", "books:read")).unwrap();
let server = Server::start(&home);
assert!(matches!(home.admin(Request::List), Response::Books(_)));
let (status, _, _, body) = raw_initialize(&home, "acme", Some(&token)).await;
assert_eq!(status, 401);
assert!(body.contains("record of agent tokens"), "{body}");
home.assert_hidden(&body);
match home.admin(Request::TokenList) {
Response::Refused(why) => assert!(why.contains("record of agent tokens"), "{why}"),
other => panic!("{other:?}"),
}
assert!(server.close().success());
}
/// Review of slice 4, finding 1: the access log gives no TIN away, not
/// in its text and not through its digest of the arguments (one with the
/// TIN masked, so no guess at it can be checked).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_access_log_gives_no_tin_away() {
let home = Home::new("tincanary");
let server = Server::start(&home);
home.create("acme", "USD");
let full = connect(&home, "acme").await.unwrap();
call(
&full,
"books.add_party",
json!({"name": "Pat Example", "roles": ["contractor"]}),
)
.await
.unwrap();
let tin = "123-45-6789";
let args = json!({"party": "Pat Example", "tin": tin, "tin_type": "ssn"});
// Sealed or not (the vault may refuse in a fresh root), the call is
// recorded; and once more by a token that may not, refused.
let _ = call(&full, "books.set_tin", args.clone()).await;
let reader = home.token_with("acme", "reader", &["read"]);
let reader = connect_with(&home, "acme", Some(reader)).await.unwrap();
assert!(call(&reader, "books.set_tin", args.clone()).await.is_err());
let text = std::fs::read_to_string(home.book_dir("acme").join("access.jsonl")).unwrap();
assert!(!text.contains(tin) && !text.contains("123456789"), "{text}");
let other = json!({"party": "Pat Example", "tin": "987-81-4321", "tin_type": "ssn"});
let want = ledger_store::access::digest(other.as_object().map(|_| &other).unwrap());
let (records, _) = ledger_store::access::read(&home.book_dir("acme")).unwrap();
let set: Vec<_> = records
.iter()
.filter(|r| r.access.subject == "books.set_tin")
.collect();
assert_eq!(set.len(), 2, "{records:?}");
for r in set {
assert_eq!(
r.access.args_digest.as_deref(),
Some(want.as_str()),
"{r:?}"
);
}
drop((full, reader));
assert!(server.close().success());
}
/// Review of slice 4, finding 6: a session id the server never opened is
/// bound to nothing (no flood of made-up ids binds or unbinds anything),
/// and an ended session is forgotten.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn only_sessions_the_server_opened_are_bound() {
let home = Home::new("sessbind");
let server = Server::start(&home);
home.create("acme", "USD");
let first = home.token("acme");
let second = home.token_with("acme", "other", &["read", "write", "post", "admin"]);
let (_, _, session, _) = raw_initialize(&home, "acme", Some(&first)).await;
let session = session.unwrap();
let request = |bearer: String, sid: String, method: reqwest::Method| {
let http = mcp_http(&home);
async move {
http.request(method, "http://localhost/b/acme/mcp")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-session-id", sid)
.header("mcp-protocol-version", "2025-06-18")
.bearer_auth(bearer)
.body(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).to_string())
.send()
.await
.unwrap()
.status()
.as_u16()
}
};
// Made-up ids: not this server's sessions, so not found, and bound to
// nothing.
for n in 0..50 {
let made_up = format!("not-a-session-{n}");
assert_eq!(
request(second.clone(), made_up, reqwest::Method::POST).await,
404
);
}
// The real one is still bound to the token that opened it.
assert_eq!(
request(second.clone(), session.clone(), reqwest::Method::POST).await,
403
);
assert_eq!(
request(first.clone(), session.clone(), reqwest::Method::POST).await,
200
);
// Ended: forgotten (the next use of its id is simply not found).
let ended = request(first.clone(), session.clone(), reqwest::Method::DELETE).await;
assert!((200..300).contains(&ended), "{ended}");
assert_eq!(
request(second.clone(), session, reqwest::Method::POST).await,
404
);
// "bearer" in any case.
let (status, ..) = raw_initialize(&home, "acme", None).await;
assert_eq!(status, 401);
let r = mcp_http(&home)
.post("http://localhost/b/acme/mcp")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("authorization", format!("bEaReR {first}"))
.body(
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "raw", "version": "0"}}})
.to_string(),
)
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
assert!(server.close().success());
}
/// Review of slice 4, findings 2 to 4: no token below books:admin gets a
/// full TIN out, and none below books:post changes the vault or the
/// approval controls.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_vault_and_the_approval_controls_need_their_scopes() {
let home = Home::new("vaultscope");
let server = Server::start(&home);
home.create("acme", "USD");
let writer = home.token_with("acme", "writer", &["read", "write", "post"]);
let w = connect_with(&home, "acme", Some(writer)).await.unwrap();
for (tool, args) in [
(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true}),
),
("books.export_cpa_package", json!({"as_of": "2026-12-31"})),
("books.get_w9", json!({"party": "x"})),
] {
let err = call(&w, tool, args).await.unwrap_err();
assert!(err.contains("needs books:admin"), "{tool}: {err}");
}
let plain = home.token_with("acme", "plain", &["read", "write"]);
let p = connect_with(&home, "acme", Some(plain)).await.unwrap();
for (tool, args) in [
(
"books.set_tin",
json!({"party": "x", "tin": "123-45-6789", "tin_type": "ssn"}),
),
(
"books.retire_convention",
json!({"id": "x", "decided_by": "Pat"}),
),
(
"books.approve_entry",
json!({"entry_id": 1, "approver": "Pat"}),
),
(
"books.set_convention",
json!({"id": "x", "kind": "approval_rule", "title": "x", "body_md": "x",
"decided_by": "Pat", "structured": {"approver": "Pat"}}),
),
] {
let err = call(&p, tool, args).await.unwrap_err();
assert!(err.contains("needs books:post"), "{tool}: {err}");
}
// Another kind of convention is a write.
let note = call(
&p,
"books.set_convention",
json!({"id": "a-note", "kind": "note", "title": "A note", "body_md": "Hello.",
"decided_by": "Pat"}),
)
.await;
assert!(note.is_ok(), "{note:?}");
drop((w, p));
assert!(server.close().success());
}
+118 -7
View File
@@ -17,8 +17,10 @@ use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// Short timings for tests (the defaults are the plan's).
const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000";
/// Short timings for tests (the defaults are the plan's), and agents'
/// tokens kept in files in the test root, not this computer's keyring.
const KNOBS: &str =
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,file_keyring=1";
/// A home with a data root, and the program the tests run (possibly a
/// copy, for restarts).
@@ -91,9 +93,17 @@ impl Home {
c.output().unwrap()
}
/// New books, and the agents' token the bridges use for them.
fn create(&self, id: &str) {
let out = self.books(&["create", id]);
assert!(out.status.success(), "{out:?}");
let out = self
.cmd("token")
.args(["create", "--book", id])
.stdin(Stdio::null())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
}
/// The pids running `mode` for this root.
@@ -569,7 +579,7 @@ fn the_bridge_answers_initialize_itself_during_a_slow_start() {
"--book",
"acme",
"--test-knobs",
"close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000",
"close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000,file_keyring=1",
]);
let mut a = Agent::from(c);
let started = Instant::now();
@@ -638,7 +648,7 @@ fn the_bridge_gives_up_on_books_that_never_open() {
"--book",
"acme",
"--test-knobs",
"close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000",
"close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000,file_keyring=1",
]);
let mut a = Agent::from(c);
a.initialize();
@@ -1247,7 +1257,7 @@ fn a_call_cancelled_at_once_is_never_made() {
"--book",
"acme",
"--test-knobs",
"close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000",
"close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000,file_keyring=1",
]);
let mut a = Agent::from(c);
a.initialize();
@@ -1317,7 +1327,7 @@ fn a_signalled_bridge_goes_at_once() {
"--book",
"acme",
"--test-knobs",
"close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000",
"close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000,file_keyring=1",
]);
let mut a = Agent::from(c);
a.initialize();
@@ -1755,7 +1765,7 @@ fn books_made_mid_session_and_bad_requests() {
"--book",
"acme",
"--test-knobs",
"close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000",
"close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000,file_keyring=1",
]);
let mut d = Agent::from(c);
d.initialize();
@@ -1939,3 +1949,104 @@ fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() {
assert_eq!(fd2, Path::new("/dev/null"));
assert!(a.end().is_some_and(|s| s.success()));
}
/// P2 slice 4: `wanderbooks token` keeps the token where the bridge finds
/// it (commands.rs INVARIANT 4, bridge.rs INVARIANT 11): made, used,
/// limited by its scopes, listed without its text, revoked at once.
#[test]
fn the_token_commands_and_the_bridge() {
let home = Home::new("tokencmd");
let out = home.books(&["create", "acme"]);
assert!(out.status.success(), "{out:?}");
let token = |args: &[&str]| {
let out = home
.cmd("token")
.args(args)
.stdin(Stdio::null())
.output()
.unwrap();
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
};
// A session begun before any token: told how to make one.
let mut a = Agent::start(&home, "acme");
a.initialize();
let err = a.call("books.start", json!({})).unwrap_err();
assert!(err.contains("wanderbooks token create"), "{err}");
// A read-only token, made: the same session uses it from now on.
let (code, made, _) = token(&["create", "--book", "acme", "--scopes", "read"]);
assert_eq!(code, Some(0), "{made}");
assert!(made.contains("kept in this computer's keyring"), "{made}");
assert!(
made.contains("books:read") && !made.contains("wb_"),
"{made}"
);
assert!(!made.contains(&home.root().display().to_string()));
assert!(a.call("books.start", json!({})).is_ok());
let err = a
.call(
"books.add_account",
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
)
.unwrap_err();
assert!(err.contains("needs books:write"), "{err}");
// A name taken; replaced on request, and the running session takes
// the new token at its next call (the old one refused at the door, the
// keyring read again: review of slice 4).
let (code, _, err) = token(&["create", "--book", "acme"]);
assert_eq!(code, Some(1));
assert!(err.contains("already have a token"), "{err}");
let (code, _, _) = token(&["create", "--book", "acme", "--replace"]);
assert_eq!(code, Some(0));
a.call(
"books.add_account",
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
)
.unwrap();
assert!(a.end().is_some());
let mut b = Agent::start(&home, "acme");
b.initialize();
for (code, name, ty) in [
("3000", "Capital", "equity"),
("6000", "Supplies", "expense"),
] {
b.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
)
.unwrap();
}
// Listed without its text; printed only by `print`.
let (_, listed, _) = token(&["list"]);
assert!(
listed.contains("acme agent books:read,books:write,books:post,books:admin"),
"{listed}"
);
assert!(!listed.contains("wb_"));
let (code, printed, _) = token(&["print", "--book", "acme"]);
assert_eq!(code, Some(0));
assert!(
printed.trim().starts_with("wb_") && printed.trim().len() == 67,
"{printed}"
);
// Revoked: the next call is refused, and the keyring forgets it.
let (code, said, _) = token(&["revoke", "--book", "acme"]);
assert_eq!(code, Some(0), "{said}");
assert!(b.call("books.chart", json!({})).is_err());
let (code, _, err) = token(&["print", "--book", "acme"]);
assert_eq!(code, Some(1));
assert!(err.contains("keeps no token"), "{err}");
let (_, listed, _) = token(&["list"]);
assert!(listed.contains("No tokens yet"), "{listed}");
// Asked of books there aren't, or with a scope there isn't.
let (code, _, err) = token(&["create", "--book", "nobody"]);
assert_eq!(code, Some(1));
assert!(err.contains("no books called"), "{err}");
let (code, _, err) = token(&["create", "--book", "acme", "--scopes", "read,everything"]);
assert_eq!(code, Some(2));
assert!(err.contains("isn't a scope"), "{err}");
assert!(b.end().is_some());
}
+153
View File
@@ -152,3 +152,156 @@ fn another_home_ignores_this_accounts_data_home() {
"XDG_DATA_HOME was used"
);
}
/// Run `args` at a terminal (a pseudo-terminal, through util-linux
/// `script`), typing `lines` one at a time: its exit code and what the
/// terminal showed.
fn at_terminal(args: &[String], lines: &[&str]) -> (i32, String) {
use std::io::Write;
let command = std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>()
.join(" ");
let mut child = Command::new("script")
.args(["-qefc", &command, "/dev/null"])
.env_remove("XDG_DATA_HOME")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("util-linux script");
let mut stdin = child.stdin.take().unwrap();
// What it shows, as it shows it: each line is typed only once it has
// asked for it (a prompt ending ": "), never before it stops echoing.
let shown_so_far = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let mut stdout = child.stdout.take().unwrap();
let reader = {
let shown = shown_so_far.clone();
std::thread::spawn(move || {
use std::io::Read;
let mut buf = [0u8; 4096];
while let Ok(n) = stdout.read(&mut buf) {
if n == 0 {
break;
}
shown.lock().unwrap().extend_from_slice(&buf[..n]);
}
})
};
let prompts = |shown: &[u8]| {
String::from_utf8_lossy(shown)
.split(['\r', '\n'])
.filter(|l| l.ends_with(": ") || l.ends_with(':'))
.count()
};
for (i, line) in lines.iter().enumerate() {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while prompts(&shown_so_far.lock().unwrap()) <= i {
if child.try_wait().unwrap().is_some() || std::time::Instant::now() > deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
// It turns its echo off just after asking.
std::thread::sleep(std::time::Duration::from_millis(150));
let _ = stdin.write_all(format!("{line}\n").as_bytes());
}
// Kept open until it ends: script stops when its input does.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while child.try_wait().unwrap().is_none() {
assert!(std::time::Instant::now() < deadline, "it never ended");
std::thread::sleep(std::time::Duration::from_millis(50));
}
drop(stdin);
let out = child.wait_with_output().unwrap();
let _ = reader.join();
let shown = format!(
"{}{}",
String::from_utf8_lossy(&shown_so_far.lock().unwrap()),
String::from_utf8_lossy(&out.stderr)
);
(out.status.code().unwrap_or(-1), shown)
}
const FIRST: &str = "correct horse battery";
const SECOND: &str = "a different staple phrase";
#[test]
fn setup_at_a_terminal_sets_the_owner_passphrase() {
let home = tempfile::Builder::new()
.prefix("wb-pass-")
.tempdir()
.unwrap();
let args = vec![
"setup".to_string(),
"--home".into(),
home.path().display().to_string(),
];
let (code, shown) = at_terminal(&args, &[FIRST, FIRST]);
assert_eq!(code, 0, "{shown}");
assert!(shown.contains("The owner passphrase is set."), "{shown}");
assert!(!shown.contains(FIRST), "the passphrase was shown: {shown}");
assert!(!shown.contains(home.path().to_str().unwrap()), "{shown}");
let auth = home.path().join(".local/share/wanderbooks/auth");
assert!(ledger_store::passphrase::verify(&auth, FIRST).unwrap());
assert_eq!(mode(&auth), 0o700);
assert_eq!(mode(&auth.join("owner.json")), 0o600);
// Run again, it asks for nothing more.
let (code, shown) = at_terminal(&args, &[]);
assert_eq!(code, 0, "{shown}");
assert!(!shown.contains("passphrase"), "{shown}");
}
#[test]
fn the_passphrase_is_typed_by_a_person_and_changed_only_with_the_one_in_force() {
let home = tempfile::Builder::new()
.prefix("wb-pass-")
.tempdir()
.unwrap();
// Set up without a terminal: the folder, and a note.
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
assert!(String::from_utf8_lossy(&out.stdout).contains("wanderbooks passphrase"));
let root = home.path().join(".local/share/wanderbooks");
let auth = root.join("auth");
assert!(!ledger_store::passphrase::is_set(&auth).unwrap());
let args = vec![
"passphrase".to_string(),
"--root".into(),
root.display().to_string(),
];
// No terminal (a script, an agent): refused, nothing set.
let out = wanderbooks()
.args(&args)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("typed by a person"));
assert!(!ledger_store::passphrase::is_set(&auth).unwrap());
// Too short, or the two not matching: nothing set.
for lines in [["short", "short"], [FIRST, SECOND]] {
let (code, shown) = at_terminal(&args, &lines);
assert_eq!(code, 1, "{shown}");
assert!(!ledger_store::passphrase::is_set(&auth).unwrap(), "{shown}");
}
let (code, shown) = at_terminal(&args, &[FIRST, FIRST]);
assert_eq!(code, 0, "{shown}");
// A change needs the one in force.
let (code, shown) = at_terminal(&args, &["not the passphrase at all"]);
assert_eq!(code, 1, "{shown}");
assert!(shown.contains("isn't the owner passphrase"), "{shown}");
assert!(ledger_store::passphrase::verify(&auth, FIRST).unwrap());
let (code, shown) = at_terminal(&args, &[FIRST, SECOND, SECOND]);
assert_eq!(code, 0, "{shown}");
assert!(!shown.contains(FIRST) && !shown.contains(SECOND), "{shown}");
assert!(ledger_store::passphrase::verify(&auth, SECOND).unwrap());
assert!(!ledger_store::passphrase::verify(&auth, FIRST).unwrap());
let kept = std::fs::read_to_string(auth.join("owner.json")).unwrap();
assert!(!kept.contains("staple"), "the text itself was kept");
}
+10
View File
@@ -16,10 +16,20 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io
reqwest = "0.13"
base64 = "0.22"
tokio-util = { version = "0.7", features = ["rt"] }
# Agents' tokens are kept in the OS keyring (plan 3.3, 3.5): the Secret
# Service on Linux. macOS and Windows stores come with P11.
keyring-core = "1"
# The owner passphrase is typed at the terminal, unechoed.
rpassword = "7"
# Token texts and passphrases are wiped from memory when dropped.
zeroize = "1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus-secret-service-keyring-store = { version = "1", features = ["rt-async-io-crypto-rust"] }
[dev-dependencies]
tempfile = "3"
+152 -18
View File
@@ -39,6 +39,13 @@
//! are not passed on, in initialize or in a request's own metadata.
//! 10. Ended by its stdin or by a signal, the bridge ends its upstream
//! session.
//! 11. Every request upstream carries its token, read from the keyring
//! (tokens.rs) before each new session and each request until it is
//! found, and again when the door refuses it (the call then sent once
//! more if another was found), so a token made or replaced
//! mid-session is used from the next call; without one, calls are
//! answered with the server's refusal, which says how the human makes
//! one. The token is never written to the agent or to stderr.
//! ```
use crate::knobs::{FLAG, Knobs};
@@ -61,6 +68,9 @@ const OWN_ID: &str = "wanderbooks-bridge-";
const SERVER_ERROR: i64 = -32000;
/// What the server says when it is closing and didn't run a request.
const CLOSING: &str = "the books are closing";
/// The refusal code the server's door gives a request it didn't run
/// because the books are closing.
const REFUSED_CLOSING: &str = "closing";
/// What rmcp says, as the whole body, for an unknown session.
const NO_SESSION: &str = "Not Found: Session not found";
@@ -124,6 +134,12 @@ enum NotRun {
struct Bridge {
book: String,
/// The name of the token it uses (tokens.rs).
token_name: String,
/// The token's text, once read from the keyring (INVARIANT 11).
token: Mutex<Option<String>>,
/// Where it is kept: its account, and the root's `run/`.
keyring: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>,
http: reqwest::Client,
knobs: Knobs,
to_agent: mpsc::UnboundedSender<Value>,
@@ -309,6 +325,69 @@ impl Bridge {
format!("http://localhost/b/{}/mcp", self.book)
}
/// Read the token from the keyring if it isn't known yet (INVARIANT
/// 11): a token made after the session began is found on the next
/// try. A keyring that fails is said on stderr; the server's refusal
/// then tells the agent what to do.
async fn find_token(&self) {
if self
.token
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none()
{
self.read_token().await;
}
}
/// Read the token from the keyring, keeping it if found: whether a
/// token other than the one held was found (one made since, or one
/// that replaced it).
async fn read_token(&self) -> bool {
let Some((account, run_dir)) = self.keyring.get().cloned() else {
return false;
};
let keeper = crate::tokens::keeper(&run_dir, &self.knobs);
let read = tokio::time::timeout(
self.knobs.queue_wait,
tokio::task::spawn_blocking(move || keeper.get(&account)),
)
.await;
match read {
Ok(Ok(Ok(Some(token)))) => {
let mut held = self.token.lock().unwrap_or_else(|e| e.into_inner());
let new = held.as_deref() != Some(token.as_str());
*held = Some(token);
new
}
Ok(Ok(Ok(None))) => false,
Ok(Ok(Err(why))) => {
eprintln!("wanderbooks mcp: the token for these books couldn't be read: {why}");
false
}
Ok(Err(_)) | Err(_) => {
eprintln!(
"wanderbooks mcp: the keyring didn't give the token for these books in time: is it locked?"
);
false
}
}
}
/// A request upstream, carrying the token (INVARIANT 11).
fn upstream(&self, method: reqwest::Method) -> reqwest::RequestBuilder {
let req = self.http.request(method, self.url());
match self
.token
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_deref()
{
Some(token) => req.bearer_auth(token),
None => req,
}
}
fn own_id(&self) -> Value {
let n = self
.next_id
@@ -322,9 +401,28 @@ impl Bridge {
/// Send one message upstream on `up`'s session.
async fn send(&self, message: &Value, up: &Upstream) -> Sent {
// A client of the 2026-07-28 era opens no session: its token is
// looked for here (INVARIANT 11).
self.find_token().await;
let (sent, unauthorized) = self.send_once(message, up).await;
// Refused at the door for its token (not run): one made or
// replaced since is read from the keyring (INVARIANT 11). A
// session belongs to the token that opened it, so with a session
// the call waits for a new one (opened with the new token); with
// none it is sent once more at once.
if unauthorized && self.read_token().await {
if up.session.is_some() {
return Sent::NotRun(NotRun::NoSession);
}
return self.send_once(message, up).await.0;
}
sent
}
/// [`Bridge::send`], once: and whether the door refused its token.
async fn send_once(&self, message: &Value, up: &Upstream) -> (Sent, bool) {
let mut req = self
.http
.post(self.url())
.upstream(reqwest::Method::POST)
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(serde_json::to_vec(message).unwrap_or_default());
@@ -336,15 +434,30 @@ impl Bridge {
}
let response = match req.send().await {
Ok(r) => r,
Err(e) if e.is_connect() => return Sent::NotRun(NotRun::Unreachable),
Err(_) => return Sent::MaybeRan,
Err(e) if e.is_connect() => return (Sent::NotRun(NotRun::Unreachable), false),
Err(_) => return (Sent::MaybeRan, false),
};
let status = response.status();
let unauthorized = status == reqwest::StatusCode::UNAUTHORIZED;
// Refused before it could run (books that didn't open, or none
// so named): the server's words, for the agent (INVARIANT 4).
let sent = self.read_answer(response).await;
(sent, unauthorized)
}
/// What an answer from upstream says.
async fn read_answer(&self, response: reqwest::Response) -> Sent {
let status = response.status();
let refused = response
.headers()
.contains_key(ledger_mcp::local::REFUSED_HEADER);
.get(ledger_mcp::local::REFUSED_HEADER)
.map(|v| v.as_bytes().to_vec());
// Refused at the door because the books are closing: not run, so
// sent again once they're open (INVARIANT 4).
if refused.as_deref() == Some(REFUSED_CLOSING.as_bytes()) {
return Sent::NotRun(NotRun::Closing);
}
let refused = refused.is_some();
let is_stream = response
.headers()
.get("content-type")
@@ -406,16 +519,21 @@ impl Bridge {
params: &Value,
epoch: u64,
) -> Result<Upstream, Option<String>> {
self.find_token().await;
let request = json!({"jsonrpc": "2.0", "id": self.own_id(), "method": "initialize", "params": params});
let response = self
.http
.post(self.url())
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(request.to_string())
.send()
.await
.map_err(|_| None)?;
let post = || {
self.upstream(reqwest::Method::POST)
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(request.to_string())
.send()
};
let mut response = post().await.map_err(|_| None)?;
// Refused for its token: once more with one made or replaced
// since (INVARIANT 11).
if response.status() == reqwest::StatusCode::UNAUTHORIZED && self.read_token().await {
response = post().await.map_err(|_| None)?;
}
let session = response
.headers()
.get("mcp-session-id")
@@ -719,6 +837,12 @@ impl Bridge {
/// Keep the lease, and a session whenever the server is ready
/// (INVARIANTS 1, 2, 5, 6).
async fn keep(self: Arc<Self>, mut opener: Opener) {
// INVARIANT 11: where its token is kept; it is read before each
// new session until one is found.
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &self.book, &self.token_name);
let _ = self.keyring.set((account, run_dir));
let mut epoch = 0u64;
loop {
let mut lease = match opener.hold().await {
@@ -857,8 +981,7 @@ impl Bridge {
{
let _ = tokio::time::timeout(
Duration::from_secs(2),
self.http
.delete(self.url())
self.upstream(reqwest::Method::DELETE)
.header("mcp-session-id", session)
.send(),
)
@@ -1076,9 +1199,11 @@ async fn skip_line<R: tokio::io::AsyncBufRead + Unpin>(input: &mut R) -> bool {
fn usage(prog: &str) {
// (`--root DIR` too, for tests: the bridge never needs a folder.)
eprintln!("usage: {prog} --book ID");
eprintln!("usage: {prog} --book ID [--token NAME]");
eprintln!(" Connects an agent (over stdio) to the books called ID, opening them if");
eprintln!(" they are closed and holding them open for the session.");
eprintln!(" they are closed and holding them open for the session. It uses the");
eprintln!(" token NAME (default: agent) that `wanderbooks token create` kept in");
eprintln!(" this computer's keyring.");
}
/// `wanderbooks mcp --book ID`.
@@ -1087,12 +1212,14 @@ pub fn run(
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut book: Option<String> = None;
let mut token_name = crate::tokens::DEFAULT_NAME.to_string();
let mut root: Option<PathBuf> = None;
let mut knobs = Knobs::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--book" => book = Some(args.next().ok_or("--book needs an id")?),
"--token" => token_name = args.next().ok_or("--token needs a name")?,
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
"--help" | "-h" => {
@@ -1143,6 +1270,9 @@ pub fn run(
let (link, _) = watch::channel(Link::Opening);
let bridge = Arc::new(Bridge {
book,
token_name,
token: Mutex::new(None),
keyring: std::sync::OnceLock::new(),
http,
knobs,
to_agent,
@@ -1485,6 +1615,10 @@ mod against_a_server {
let (link, _) = watch::channel(Link::Opening);
let bridge = Arc::new(Bridge {
book: "acme".into(),
// A token the fake server doesn't check; no keyring is read.
token_name: crate::tokens::DEFAULT_NAME.into(),
token: Mutex::new(Some("wb_test".into())),
keyring: std::sync::OnceLock::new(),
http,
knobs,
to_agent,
+335 -5
View File
@@ -1,5 +1,5 @@
//! The owner's commands: `wanderbooks serve`, `close` and `books` (plan
//! 3.9, 5.5).
//! The owner's commands: `wanderbooks serve`, `close`, `books` and
//! `token` (plan 3.3, 3.9, 5.5).
//!
//! ```text
//! GOAL: from a terminal the owner holds the books open, closes them, and
@@ -17,6 +17,10 @@
//! 3. Adoption is previewed first and goes ahead only on a yes at the
//! terminal, or --yes; a vault beside the books must be named (or
//! --no-vault given): it is never silently left behind.
//! 4. A token made is kept in the keyring before anything is said of it,
//! and its text is printed only by `token print`; one that can't be
//! kept is revoked again. A token revoked is forgotten by the keyring
//! too.
//! ```
use crate::knobs::{FLAG, Knobs};
@@ -104,6 +108,12 @@ async fn open(opener: &mut Opener) -> Result<Lease, HoldError> {
/// Ask the server's admin socket, holding the books open meanwhile.
fn with_server(common: &Common, request: Request) -> Result<Response, String> {
with_server_in(common, request).map(|(answer, _)| answer)
}
/// [`with_server`], with the root's `run/` folder too (where the
/// keyring's test stand-in lives).
fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBuf), String> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
@@ -122,16 +132,17 @@ fn with_server(common: &Common, request: Request) -> Result<Response, String> {
}
HoldError::Failed(why) => why,
})?;
let socket = opener.run_dir().join(ledger_mcp::local::ADMIN_SOCKET);
let run_dir = opener.run_dir().to_path_buf();
let socket = run_dir.join(ledger_mcp::local::ADMIN_SOCKET);
// A list is quick; a create or an adoption takes what it takes.
let wait = matches!(request, Request::List).then_some(LIST_WAIT);
let wait = matches!(request, Request::List | Request::TokenList).then_some(LIST_WAIT);
let answer =
tokio::task::spawn_blocking(move || admin::call_within(&socket, request, wait))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string());
drop(lease);
answer
answer.map(|a| (a, run_dir))
});
// Past its deadline, the command ends: nothing pending holds it.
rt.shutdown_timeout(Duration::from_secs(1));
@@ -773,6 +784,17 @@ pub fn doctor(
};
println!("The books' private folder: there, and only your account can open it.");
println!("Isolation: same_user (advisory): hidden from agents, not locked against them.");
match root
.auth_dir()
.map_err(|e| e.to_string())
.and_then(|a| ledger_store::passphrase::is_set(&a).map_err(|e| e.to_string()))
{
Ok(true) => println!("The owner passphrase: set."),
Ok(false) => println!(
"The owner passphrase: not set yet: set it in a terminal with `wanderbooks passphrase`."
),
Err(why) => println!("The owner passphrase: {why}"),
}
let run = root.run_dir().map_err(|e| e.to_string())?;
match crate::opener::ask_once_within(&run, Ask::Leases, DOCTOR_WAIT) {
Ok(None) => println!("{}", no_host(crate::opener::server_lock_held(&run))),
@@ -793,6 +815,20 @@ pub fn doctor(
Some(why) => println!(" {} didn't open: {why}", b.id),
}
}
// Agents' tokens: how many each book has.
match admin::call_within(&socket, Request::TokenList, Some(DOCTOR_WAIT)) {
Ok(admin::Response::Tokens(tokens)) if tokens.is_empty() => println!(
"Agents' tokens: none yet (`wanderbooks token create --book ID`)."
),
Ok(admin::Response::Tokens(tokens)) => {
println!("Agents' tokens:");
for t in tokens {
println!(" {} {} {}", t.book, t.name, t.scopes.join(","));
}
}
Ok(admin::Response::Refused(why)) => println!("Agents' tokens: {why}"),
Ok(_) | Err(_) => {}
}
}
} else {
println!("The books are opening (the books server isn't ready yet).");
@@ -828,6 +864,300 @@ pub fn doctor(
Ok(())
}
fn token_usage(prog: &str) {
eprintln!("usage: {prog} create --book ID [--name NAME] [--scopes LIST] [--replace]");
eprintln!(" {prog} list");
eprintln!(" {prog} revoke --book ID [--name NAME]");
eprintln!(" {prog} print --book ID [--name NAME]");
eprintln!(" create make an agent's token for the books ID and keep it in this");
eprintln!(" computer's keyring, where `wanderbooks mcp --book ID` finds it");
eprintln!(" (NAME: agent unless given; LIST: read,write,post,admin unless");
eprintln!(" given; --replace ends a token of the same name)");
eprintln!(" list the tokens there are (never their text)");
eprintln!(" revoke end a token, at once, and forget it here");
eprintln!(" print the token's text, for a client that sends it itself");
}
/// `wanderbooks token create|list|revoke|print`.
pub fn token(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
use ledger_store::auth::Scope;
let mut common = Common::default();
let mut words: Vec<String> = Vec::new();
let mut book: Option<String> = None;
let mut name = crate::tokens::DEFAULT_NAME.to_string();
let mut scopes: Vec<String> = Scope::ALL.iter().map(|s| s.as_str().to_string()).collect();
let mut replace = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
let mut value = |what: &str| args.next().ok_or_else(|| format!("{arg} needs {what}"));
match arg.as_str() {
"--book" => book = Some(value("an id")?),
"--name" => name = value("a name")?,
"--scopes" => {
let list = value("a list such as read,write")?;
let mut out = Vec::new();
for part in list.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match Scope::parse(part) {
Some(s) => out.push(s.as_str().to_string()),
None => {
eprintln!(
"error: {part:?} isn't a scope: use read, write, post or admin"
);
return Err(exit(2));
}
}
}
scopes = out;
}
"--replace" => replace = true,
"--help" | "-h" => {
token_usage(prog);
return Ok(());
}
s if s.starts_with('-') => {
eprintln!("unknown flag: {s}");
token_usage(prog);
return Err(exit(2));
}
_ => words.push(arg),
}
}
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
let need_book = || -> Result<String, Box<dyn std::error::Error>> {
let Some(book) = book.clone() else {
eprintln!("error: which books? give --book ID");
return Err(exit(2));
};
ledger_store::root::BookId::parse(&book)
.map(|id| id.to_string())
.map_err(|e| fail(e.to_string()))
};
let words: Vec<&str> = words.iter().map(String::as_str).collect();
match words.as_slice() {
["create"] => {
let book = need_book()?;
let request = Request::TokenCreate {
book: book.clone(),
name: name.clone(),
scopes,
replace,
};
let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?;
let (info, text) = match answer {
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
Response::Refused(why) => return Err(fail(why)),
other => return Err(fail(format!("the books server answered oddly: {other:?}"))),
};
// INVARIANT 4: kept first, or taken back.
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
drop(text);
if let Err(why) = kept {
let undone = with_server(
&common,
Request::TokenRevoke {
book: book.clone(),
name: name.clone(),
// This one only: another may have replaced it.
id: Some(info.id.clone()),
},
);
let after = match undone {
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
};
let replaced = if replace {
" The token it replaced had already ended."
} else {
""
};
return Err(fail(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
)));
}
println!(
"Made the token {:?} for the books {} ({}), kept in this computer's keyring.",
info.name,
info.book,
info.scopes.join(", ")
);
println!(
"An agent reaches the books with `wanderbooks mcp --book {}{}`.",
info.book,
if info.name == crate::tokens::DEFAULT_NAME {
String::new()
} else {
format!(" --token {}", info.name)
}
);
Ok(())
}
["list"] => match with_server(&common, Request::TokenList).map_err(fail)? {
Response::Tokens(tokens) if tokens.is_empty() => {
println!("No tokens yet: `wanderbooks token create --book ID` makes one.");
Ok(())
}
Response::Tokens(tokens) => {
for t in tokens {
println!(
"{} {} {} made {}",
t.book,
t.name,
t.scopes.join(","),
t.created
);
}
Ok(())
}
Response::Refused(why) => Err(fail(why)),
other => Err(fail(format!("the books server answered oddly: {other:?}"))),
},
["revoke"] => {
let book = need_book()?;
let request = Request::TokenRevoke {
book: book.clone(),
name: name.clone(),
id: None,
};
let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?;
match answer {
Response::TokenRevoked(t) => {
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
let forgot = crate::tokens::keeper(&run_dir, &common.knobs).delete(&account);
println!("Revoked the token {:?} for the books {}.", t.name, t.book);
if let Err(why) = forgot {
eprintln!(
"warning: this computer's keyring still holds its text ({why}); it opens nothing now"
);
}
Ok(())
}
Response::Refused(why) => Err(fail(why)),
other => Err(fail(format!("the books server answered oddly: {other:?}"))),
}
}
["print"] => {
let book = need_book()?;
let opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)
.map_err(|e| fail(e.to_string()))?;
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
match crate::tokens::keeper(&run_dir, &common.knobs).get(&account) {
Ok(Some(text)) => {
println!("{text}");
Ok(())
}
Ok(None) => Err(fail(format!(
"this computer keeps no token {name:?} for the books {book}: `wanderbooks token create --book {book}` makes one"
))),
Err(why) => Err(fail(why)),
}
}
_ => {
token_usage(prog);
Err(exit(2))
}
}
}
/// Whether a person can type a passphrase here: standard input is a
/// terminal, and the terminal itself can be opened (it is read from).
fn can_type_a_passphrase() -> bool {
at_a_terminal() && std::fs::File::open("/dev/tty").is_ok()
}
/// Ask for the owner passphrase at the terminal and set it in `auth_dir`
/// (ledger_store::passphrase): the one in force first, if there is one,
/// then the new one twice. Nothing typed is echoed or repeated.
pub fn set_passphrase_at_terminal(auth_dir: &Path) -> Result<(), String> {
use ledger_store::passphrase;
if !can_type_a_passphrase() {
return Err(
"the owner passphrase is typed by a person, at a terminal: run `wanderbooks passphrase` in one"
.into(),
);
}
// What is typed is wiped from memory when done with.
let ask = |prompt: &str| {
rpassword::prompt_password(prompt)
.map(zeroize::Zeroizing::new)
.map_err(|e| format!("nothing was read ({})", e.kind()))
};
let current = if passphrase::is_set(auth_dir).map_err(|e| e.to_string())? {
let c = ask("The owner passphrase in force: ")?;
if !passphrase::verify(auth_dir, &c).map_err(|e| e.to_string())? {
return Err(passphrase::PassError::Wrong.to_string());
}
Some(c)
} else {
None
};
let new = ask(&format!(
"A new owner passphrase ({} characters or more): ",
passphrase::MIN_CHARS
))?;
passphrase::check_length(&new).map_err(|e| e.to_string())?;
if *ask("The same again: ")? != *new {
return Err("the two didn't match: nothing was changed".into());
}
passphrase::set(auth_dir, &new, current.as_ref().map(|c| c.as_str())).map_err(|e| e.to_string())
}
/// `wanderbooks passphrase`: set or change the owner passphrase.
pub fn passphrase(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
"--help" | "-h" => {
eprintln!("usage: {prog}");
eprintln!(" Set the owner passphrase, or change it (the one in force is asked");
eprintln!(" first). It is what the books ask a person for before anything only");
eprintln!(" the owner may do. Typed at a terminal, never shown.");
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
return Err(exit(2));
}
}
}
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
let root =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| fail(e.to_string()))?;
let auth = root.auth_dir().map_err(|e| fail(e.to_string()))?;
set_passphrase_at_terminal(&auth).map_err(fail)?;
println!("The owner passphrase is set.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+26 -1
View File
@@ -27,6 +27,9 @@ pub struct Knobs {
pub queue_wait: Duration,
/// How long a command waits for the books to open.
pub open_wait: Duration,
/// Keep agents' tokens in files in the root's `run/`, not the OS
/// keyring (tests: tokens.rs INVARIANT 3). `file_keyring=1`.
pub file_keyring: bool,
}
impl Default for Knobs {
@@ -39,6 +42,7 @@ impl Default for Knobs {
init_wait: Duration::from_secs(5),
queue_wait: Duration::from_secs(60),
open_wait: Duration::from_secs(120),
file_keyring: false,
}
}
}
@@ -54,6 +58,14 @@ impl Knobs {
let (name, value) = pair
.split_once('=')
.ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?;
if name == "file_keyring" {
k.file_keyring = match value {
"1" => true,
"0" => false,
_ => return Err(format!("{FLAG}: file_keyring is 1 or 0")),
};
continue;
}
let ms: u64 = value
.parse()
.map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?;
@@ -90,6 +102,9 @@ impl Knobs {
parts.push(format!("{name}={}", now.as_millis()));
}
}
if self.file_keyring {
parts.push("file_keyring=1".into());
}
(!parts.is_empty()).then(|| parts.join(","))
}
@@ -137,13 +152,23 @@ mod tests {
let args = k.args();
assert_eq!(args[0], FLAG);
assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
let keyring = Knobs::parse("file_keyring=1,backoff=5").unwrap();
assert!(keyring.file_keyring);
assert_eq!(Knobs::parse(&keyring.args()[1]).unwrap(), keyring);
assert!(!Knobs::parse("file_keyring=0").unwrap().file_keyring);
let one = Knobs::parse("close_after=1").unwrap();
assert_eq!(one.to_arg().as_deref(), Some("close_after=1"));
}
#[test]
fn a_bad_knob_is_refused() {
for bad in ["close_after", "close_after=x", "nope=1", "close_after=-1"] {
for bad in [
"close_after",
"close_after=x",
"nope=1",
"close_after=-1",
"file_keyring=yes",
] {
assert!(Knobs::parse(bad).is_err(), "{bad}");
}
}
+3 -1
View File
@@ -7,7 +7,8 @@
//! ends. Each part is a mode of the one `wanderbooks` executable:
//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks
//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in
//! [`commands`] (`serve`, `close`, `books`).
//! [`commands`] (`serve`, `close`, `books`, `token`). Agents' tokens are
//! kept in the OS keyring ([`tokens`]).
#[cfg(unix)]
pub mod bridge;
@@ -23,6 +24,7 @@ pub mod lease;
pub mod opener;
#[cfg(unix)]
mod start;
pub mod tokens;
/// How a mode stops when it can't go on: the reason is already printed,
/// and the number is the process's exit code.
+266
View File
@@ -0,0 +1,266 @@
//! Where an agent's token is kept on this computer: the OS keyring (plan
//! 3.3, 3.5, D-23), read by the bridge, so no agent's configuration
//! holds it.
//!
//! ```text
//! GOAL: `wanderbooks token create` keeps the token it was shown where
//! the bridge finds it, and nowhere an agent's configuration or the
//! screen would show it.
//! SOURCE: plan 3.3 ("kept in the OS keyring, minted by wanderbooks
//! connect, revocable"), 2.1 ("Local token: OS keyring, read by
//! the bridge"), 3.5 ("plaintext in the user's keyring, which any
//! process running as that user can read (advisory locally)").
//! INVARIANTS:
//! 1. A token is kept under the service "wanderbooks" and an account
//! naming the root (a hash of its path, never the path), the book
//! and the token's name, so two roots or two books never share one.
//! 2. Nothing here prints or logs a token's text, and no error repeats
//! it.
//! 3. The keyring stands in for nothing else: with no keyring the
//! command says so; only the test knob `file_keyring` keeps tokens
//! in files instead (in the root's `run/`, 0600).
//! ```
use crate::knobs::Knobs;
use std::path::{Path, PathBuf};
/// The keyring service tokens are kept under (INVARIANT 1).
pub const SERVICE: &str = "wanderbooks";
/// A token's name when none is given.
pub const DEFAULT_NAME: &str = "agent";
/// A place tokens are kept.
pub trait Keeper: Send + Sync {
/// The token kept for `account`, if any.
fn get(&self, account: &Account) -> Result<Option<String>, String>;
/// Keep `text` for `account`, replacing any.
fn set(&self, account: &Account, text: &str) -> Result<(), String>;
/// Forget `account`'s token (no matter if there is none).
fn delete(&self, account: &Account) -> Result<(), String>;
}
/// Whose token: a root's book's token of a name (INVARIANT 1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Account {
root_tag: String,
book: String,
name: String,
}
impl Account {
/// The token `name` of `book` in the root at `root`.
pub fn new(root: &Path, book: &str, name: &str) -> Self {
let real = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let hash = ledger_store::journal::sha256_hex(real.as_os_str().as_encoded_bytes());
Self {
root_tag: hash[..16].to_string(),
book: book.to_string(),
name: name.to_string(),
}
}
/// The keyring's account name: `<root tag>/<book>/<name>`.
pub fn key(&self) -> String {
format!("{}/{}/{}", self.root_tag, self.book, self.name)
}
/// What the keyring's own window calls it.
fn label(&self) -> String {
format!("wanderbooks token: the books {} ({})", self.book, self.name)
}
}
/// The keeper in force: the OS keyring, or files for tests (INVARIANT 3).
pub fn keeper(run_dir: &Path, knobs: &Knobs) -> Box<dyn Keeper> {
if knobs.file_keyring {
Box::new(Files(run_dir.join("test-keyring")))
} else {
Box::new(Os)
}
}
/// The OS keyring.
struct Os;
#[cfg(target_os = "linux")]
impl Os {
fn entry(&self, account: &Account) -> Result<keyring_core::Entry, String> {
use keyring_core::api::CredentialStoreApi;
let store = zbus_secret_service_keyring_store::Store::new().map_err(|e| {
format!("this computer's keyring can't be reached ({e}): is a Secret Service (GNOME Keyring, KWallet) running and unlocked?")
})?;
let label = account.label();
let modifiers = std::collections::HashMap::from([("label", label.as_str())]);
store
.build(SERVICE, &account.key(), Some(&modifiers))
.map_err(|e| format!("the keyring refused: {e}"))
}
}
#[cfg(target_os = "linux")]
impl Keeper for Os {
fn get(&self, account: &Account) -> Result<Option<String>, String> {
match self.entry(account)?.get_password() {
Ok(text) => Ok(Some(text)),
Err(keyring_core::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("the keyring refused: {e}")),
}
}
fn set(&self, account: &Account, text: &str) -> Result<(), String> {
self.entry(account)?
.set_password(text)
.map_err(|e| format!("the keyring refused: {e}"))
}
fn delete(&self, account: &Account) -> Result<(), String> {
match self.entry(account)?.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("the keyring refused: {e}")),
}
}
}
#[cfg(not(target_os = "linux"))]
impl Keeper for Os {
fn get(&self, _: &Account) -> Result<Option<String>, String> {
Err(NOT_YET.into())
}
fn set(&self, _: &Account, _: &str) -> Result<(), String> {
Err(NOT_YET.into())
}
fn delete(&self, _: &Account) -> Result<(), String> {
Err(NOT_YET.into())
}
}
#[cfg(not(target_os = "linux"))]
const NOT_YET: &str =
"agents' tokens are kept in the keyring on Linux only, until the macOS and Windows apps (P11)";
/// Files, one a token, for tests (INVARIANT 3).
struct Files(PathBuf);
impl Files {
fn path(&self, account: &Account) -> PathBuf {
self.0
.join(ledger_store::journal::sha256_hex(account.key().as_bytes()))
}
}
impl Keeper for Files {
fn get(&self, account: &Account) -> Result<Option<String>, String> {
match std::fs::read_to_string(self.path(account)) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("the test keyring refused: {}", e.kind())),
}
}
fn set(&self, account: &Account, text: &str) -> Result<(), String> {
use std::io::Write;
let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind());
std::fs::create_dir_all(&self.0).map_err(fail)?;
let path = self.path(account);
let _ = std::fs::remove_file(&path);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options
.open(&path)
.and_then(|mut f| f.write_all(text.as_bytes()))
.map_err(fail)
}
fn delete(&self, account: &Account) -> Result<(), String> {
match std::fs::remove_file(self.path(account)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("the test keyring refused: {}", e.kind())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_account_names_the_root_by_hash_and_the_book_and_token() {
let dir = tempfile::tempdir().unwrap();
let a = Account::new(dir.path(), "acme", "agent");
let key = a.key();
assert!(key.ends_with("/acme/agent"), "{key}");
assert!(!key.contains(&dir.path().display().to_string()));
assert_eq!(key.split('/').next().unwrap().len(), 16);
// The same root by another spelling is the same account.
let again = Account::new(&dir.path().join("."), "acme", "agent");
assert_eq!(again, a);
let other = tempfile::tempdir().unwrap();
assert_ne!(Account::new(other.path(), "acme", "agent").key(), key);
assert_ne!(Account::new(dir.path(), "beta", "agent").key(), key);
assert_ne!(Account::new(dir.path(), "acme", "codex").key(), key);
assert!(!a.label().contains('/'));
}
#[test]
fn the_test_keyring_keeps_and_forgets() {
let dir = tempfile::tempdir().unwrap();
let knobs = Knobs {
file_keyring: true,
..Knobs::default()
};
let k = keeper(dir.path(), &knobs);
let a = Account::new(dir.path(), "acme", "agent");
assert_eq!(k.get(&a).unwrap(), None);
k.set(&a, "wb_one").unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_one"));
k.set(&a, "wb_two").unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_two"));
let b = Account::new(dir.path(), "acme", "codex");
assert_eq!(k.get(&b).unwrap(), None);
k.delete(&a).unwrap();
k.delete(&a).unwrap();
assert_eq!(k.get(&a).unwrap(), None);
#[cfg(unix)]
{
k.set(&a, "wb_three").unwrap();
use std::os::unix::fs::PermissionsExt;
let files: Vec<_> = std::fs::read_dir(dir.path().join("test-keyring"))
.unwrap()
.collect();
assert_eq!(files.len(), 1);
let mode = files[0]
.as_ref()
.unwrap()
.metadata()
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
/// The real keyring, on a desktop with one (`cargo test -p
/// ledger-host -- --ignored os_keyring`): kept, read, replaced and
/// forgotten, under a service no one else uses.
#[cfg(target_os = "linux")]
#[test]
#[ignore]
fn os_keyring_keeps_and_forgets() {
let dir = tempfile::tempdir().unwrap();
let a = Account::new(dir.path(), "ignored-test", "agent");
let k = Os;
k.set(&a, "wb_test_one").unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_test_one"));
k.set(&a, "wb_test_two").unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_test_two"));
k.delete(&a).unwrap();
assert_eq!(k.get(&a).unwrap(), None);
k.delete(&a).unwrap();
}
}
+3
View File
@@ -33,6 +33,8 @@ chrono = "0.4"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] }
tokio-util = { version = "0.7", features = ["rt"] }
# A call that panics is recorded before the panic goes on (access.rs).
futures-util = "0.3"
zip = "9.0.1"
toml = "1"
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
@@ -52,5 +54,6 @@ tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] }
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
jsonschema = { version = "0.58", default-features = false }
proptest = "1"
tempfile = "3"
# read_only_tools_change_nothing compares the database by its rows (spec_mcp.rs).
rusqlite = { version = "0.32", features = ["bundled"] }
+183
View File
@@ -0,0 +1,183 @@
//! Who is calling, what their token allows, and the record of each call
//! (plan 3.3, I-9), for books served from the data root.
//!
//! ```text
//! GOAL: a call reaches a book only with a token for that book and for
//! what the call does, and every call that reaches it is recorded
//! before it is answered.
//! SOURCE: plan 3.3 (scopes), I-9 ("every access through the server is
//! logged, and logging fails closed"), section 7's record.
//! INVARIANTS:
//! 1. The HTTP door (local.rs) puts a Caller on every request it lets
//! through; a call that reaches a book with an access log and has no
//! Caller is refused.
//! 2. A call runs only if its Caller's token has the scope it needs
//! (scopes.rs); otherwise it is refused, unrun.
//! 3. Every call, run or refused, gets one record, written before its
//! answer goes (a call that ran, on its own blocking thread while it
//! still counts as running, so a close waits for its record too);
//! if the record can't be written, the book stops taking requests
//! (SharedStore's stop) and the answer says so, a call refused as not
//! run keeping its "not run" code.
//! 4. A record's head is the journal's head after the call's own last
//! write (taken under the store's lock as it lets go), whatever the
//! call answered; none for a call that wrote nothing.
//! ```
use ledger_store::access::{Access, AccessLog, Client, Outcome, Principal, digest};
use ledger_store::auth::{Scope, Token};
use ledger_store::chain::Head;
use rmcp::service::{RequestContext, RoleServer};
use std::sync::Arc;
/// Who made a request: the token the door matched (INVARIANT 1).
#[derive(Clone, Debug)]
pub struct Caller {
/// The token.
pub token: Token,
}
impl Caller {
/// As the access log names them.
pub fn principal(&self) -> Principal {
Principal {
id: self.token.id.clone(),
kind: "agent".into(),
label: self.token.name.clone(),
}
}
/// Whether the token has `scope`.
pub fn may(&self, scope: Scope) -> bool {
self.token.scopes.contains(&scope)
}
}
/// The caller of a request, as the door left it.
pub fn caller(context: &RequestContext<RoleServer>) -> Option<Caller> {
context
.extensions
.get::<axum::http::request::Parts>()
.and_then(|parts| parts.extensions.get::<Caller>())
.cloned()
}
/// The client program, as it named itself at `initialize`.
fn client(context: &RequestContext<RoleServer>) -> Option<Client> {
context.peer.peer_info().map(|info| Client {
name: info.client_info.name.clone(),
version: info.client_info.version.clone(),
})
}
fn request_id(context: &RequestContext<RoleServer>) -> String {
match &context.id {
rmcp::model::NumberOrString::Number(n) => n.to_string(),
rmcp::model::NumberOrString::String(s) => s.to_string(),
}
}
/// One call on its way through: who, what, and the log it goes into.
#[derive(Clone)]
pub(crate) struct Call {
log: Arc<AccessLog>,
caller: Option<Caller>,
client: Option<Client>,
request_id: String,
action: &'static str,
subject: String,
args_digest: Option<String>,
}
/// Why a call wasn't run, for its record and its answer.
pub(crate) const UNAUTHORIZED: &str = "unauthorized";
/// The token lacks the scope.
pub(crate) const INSUFFICIENT_SCOPE: &str = "insufficient_scope";
/// The books were closing.
pub(crate) const CLOSING: &str = "closing";
/// The server had stopped.
pub(crate) const STOPPED: &str = "stopped";
/// What a refused call is told when its record couldn't be kept, or a
/// call that ran is told instead of its answer (INVARIANT 3).
pub(crate) const NOT_RECORDED: &str = "this call's access record couldn't be written, so the books server stopped taking requests, to keep every access on record: ask the human to restart it";
/// The same, for a call that ran.
pub(crate) const RAN_NOT_RECORDED: &str = "this call ran, but its access record couldn't be written, so the books server stopped taking requests, to keep every access on record: ask the human to restart it, then check whether the call's change is in the books before trying again";
impl Call {
/// A call to `subject` (a tool's name, a resource's address). Its
/// arguments' digest is added later ([`Call::digested`]): hashing a
/// large call's arguments waits until it is counted as running, so a
/// close beginning meanwhile waits for it.
pub(crate) fn new(
log: &Arc<AccessLog>,
context: &RequestContext<RoleServer>,
action: &'static str,
subject: &str,
) -> Self {
Self {
log: log.clone(),
caller: caller(context),
client: client(context),
request_id: request_id(context),
action,
subject: subject.to_string(),
args_digest: None,
}
}
/// The digest of a call's arguments (blocking work: a call's
/// arguments may be megabytes).
pub(crate) fn digest_of(
args: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<String> {
args.map(|a| digest(&serde_json::Value::Object(a.clone())))
}
/// This call, with its arguments' digest.
pub(crate) fn digested(&mut self, args_digest: Option<String>) {
self.args_digest = args_digest;
}
/// The call's caller, if the door named one.
pub(crate) fn caller(&self) -> Option<&Caller> {
self.caller.as_ref()
}
/// Record the call's outcome, on a blocking thread; Err only if it
/// couldn't be written (INVARIANT 3).
pub(crate) async fn record(&self, result: Outcome, head_after: Option<Head>) -> Result<(), ()> {
let me = self.clone();
tokio::task::spawn_blocking(move || me.record_blocking(result, head_after))
.await
.unwrap_or(Err(()))
}
/// [`Call::record`], on this thread (one that may block).
pub(crate) fn record_blocking(
&self,
result: Outcome,
head_after: Option<Head>,
) -> Result<(), ()> {
let access = Access {
principal: self
.caller
.as_ref()
.map_or_else(Principal::unknown, Caller::principal),
client: self.client.clone(),
request_id: Some(self.request_id.clone()),
action: self.action.to_string(),
subject: self.subject.clone(),
args_digest: self.args_digest.clone(),
result,
head_after,
};
match self.log.record(&access) {
Ok(_) => Ok(()),
Err(e) => {
eprintln!("wanderbooks server: an access record couldn't be written: {e}");
Err(())
}
}
}
}
+162 -10
View File
@@ -1,5 +1,6 @@
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.9): how the
//! command line asks the books server to list, create and adopt books.
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.3, 3.9): how
//! the command line asks the books server to list, create and adopt books,
//! and to make, list and revoke agents' tokens.
//!
//! ```text
//! GOAL: the command line makes and adopts books without ever touching
@@ -21,6 +22,9 @@
//! 5. Nothing in a settings file is dropped silently: entries the books
//! don't know are refused unless `skip_unknown`, and its notes and
//! comments are in the preview.
//! 6. A token's text is in exactly one answer, TokenCreated, to the
//! request that made it; nothing else carries it (auth.rs
//! INVARIANT 1).
//! ```
use serde::{Deserialize, Serialize};
@@ -93,6 +97,69 @@ pub enum Request {
#[serde(default)]
skip_unknown: bool,
},
/// Make a token for an agent (plan 3.3).
TokenCreate {
/// The books it opens.
book: String,
/// Its name, unique among the books' tokens ("agent").
name: String,
/// What it may do: books:read, books:write, books:post,
/// books:admin.
scopes: Vec<String>,
/// End a token of the same name in the same step.
#[serde(default)]
replace: bool,
},
/// The live tokens.
TokenList,
/// End a token.
TokenRevoke {
/// Its books.
book: String,
/// Its name.
name: String,
/// Only if it is still this one (a token just made, taken back).
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
}
/// A token's text in an answer: Debug never shows it (INVARIANT 6).
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TokenText(pub String);
impl std::fmt::Debug for TokenText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TokenText(…)")
}
}
/// A token, as listed (never its text).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenInfo {
/// Its id.
pub id: String,
/// The books it opens.
pub book: String,
/// Its name.
pub name: String,
/// What it may do.
pub scopes: Vec<String>,
/// When it was made (UTC).
pub created: String,
}
impl From<&ledger_store::auth::Token> for TokenInfo {
fn from(t: &ledger_store::auth::Token) -> Self {
Self {
id: t.id.clone(),
book: t.book.clone(),
name: t.name.clone(),
scopes: t.scopes.iter().map(|s| s.as_str().to_string()).collect(),
created: t.created.clone(),
}
}
}
/// One book, as listed.
@@ -188,6 +255,17 @@ pub enum Response {
Preview(AdoptPreview),
/// Books adopted and open.
Adopted(Adopted),
/// A token made: its text, shown this once (INVARIANT 6).
TokenCreated {
/// The token.
token: TokenInfo,
/// Its text.
text: TokenText,
},
/// The live tokens.
Tokens(Vec<TokenInfo>),
/// A token ended.
TokenRevoked(TokenInfo),
/// Refused, with why (no path in the data root: INVARIANT 3).
Refused(String),
}
@@ -227,15 +305,20 @@ pub fn call_within(
use std::io::{BufRead, Write};
let not_sent = |why: String| CallError::NotSent(why);
// A list changes nothing: its only question is whether it answered.
let reads_only = matches!(request, Request::List);
let unknown = move |why: String| {
if reads_only {
CallError::Unknown(why)
} else {
CallError::Unknown(format!(
"{why}, so it may or may not have been done: `wanderbooks books list` shows the books there are"
))
let look = match request {
Request::List | Request::TokenList => None,
Request::Create { .. } | Request::Adopt { .. } => {
Some("`wanderbooks books list` shows the books there are")
}
Request::TokenCreate { .. } | Request::TokenRevoke { .. } => {
Some("`wanderbooks token list` shows the tokens there are")
}
};
let unknown = move |why: String| match look {
None => CallError::Unknown(why),
Some(look) => CallError::Unknown(format!(
"{why}, so it may or may not have been done: {look}"
)),
};
let quiet = |e: std::io::Error| match e.kind() {
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
@@ -361,9 +444,78 @@ mod tests {
problem: None,
},
Response::Refused("no".into()),
Response::TokenCreated {
token: TokenInfo {
id: "tok_1".into(),
book: "acme".into(),
name: "agent".into(),
scopes: vec!["books:read".into()],
created: "2026-10-19T00:00:00.000Z".into(),
},
text: TokenText("wb_00".into()),
},
Response::Tokens(vec![]),
] {
let text = serde_json::to_string(&r).unwrap();
assert_eq!(serde_json::from_str::<Response>(&text).unwrap(), r);
}
}
#[test]
fn token_requests_read_as_written() {
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_create",
"book": "acme", "name": "agent", "scopes": ["books:read"]}))
.unwrap();
assert_eq!(
r.request,
Request::TokenCreate {
book: "acme".into(),
name: "agent".into(),
scopes: vec!["books:read".into()],
replace: false,
}
);
let shown = format!(
"{:?}",
Response::TokenCreated {
token: TokenInfo {
id: "tok_1".into(),
book: "acme".into(),
name: "agent".into(),
scopes: vec![],
created: String::new(),
},
text: TokenText("wb_secret".into()),
}
);
assert!(!shown.contains("wb_secret"), "{shown}");
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap();
assert_eq!(r.request, Request::TokenList);
assert!(
serde_json::from_value::<Envelope>(
json!({"proto": 1, "op": "token_revoke", "book": "acme"})
)
.is_err(),
"a revoke names the token"
);
}
#[test]
fn a_listed_token_carries_no_text_or_hash() {
let dir = tempfile::tempdir().unwrap();
let mut p = ledger_store::auth::Principals::open(dir.path()).unwrap();
let book = ledger_store::root::BookId::parse("acme").unwrap();
let (t, secret) = p
.create(
&book,
"agent",
[ledger_store::auth::Scope::Read].into(),
false,
)
.unwrap();
let text = serde_json::to_string(&Response::Tokens(vec![TokenInfo::from(&t)])).unwrap();
assert!(!text.contains(&secret.expose()[3..]));
assert!(!text.contains(&t.sha256));
assert!(text.contains("books:read"));
}
}
+2
View File
@@ -18,6 +18,7 @@
//! `/`, the home folder, or holds the books, the vault or a key is refused
//! at startup.
pub mod access;
pub mod admin;
mod handbook;
#[cfg(unix)]
@@ -25,6 +26,7 @@ pub mod local;
mod local_files;
pub mod opening;
mod policy;
pub mod scopes;
mod server;
mod settings;
mod tin_files;
+399 -35
View File
@@ -6,7 +6,8 @@
//! data root to this account alone, over sockets in the root's
//! private `run/` folder, and lets go of every book cleanly.
//! SOURCE: plan/served.md 3.1 (the server's sockets; the admin socket
//! creates and adopts books and can't read them), 3.4 (data
//! creates and adopts books, mints tokens, and can't read books),
//! 3.3 (bearer tokens, scopes), 3.4 (data
//! layout, run/books.lock), 3.9 (local channels, peer checks,
//! closing: "no new requests, the one in progress finishes, every
//! writer lock is released"), D-21 and D-23 (no service: the host
@@ -31,6 +32,17 @@
//! ones included), then the sessions end, the sockets are removed,
//! and the process exits 0, releasing every lock.
//! 7. Nothing this process sends a client names the data root.
//! 8. An MCP request reaches a book only with a bearer token that is
//! live for that book (auth/principals.jsonl; refused 401 without
//! one, 403 with another book's), and a session only with the token
//! that opened it (403). A request refused at the door is recorded
//! in that book's access log; the token rides with the request to
//! the book's tools, which check its scopes (access.rs). A session is
//! bound to its token when opened, and forgotten when it ends, is
//! found gone, or its token is revoked or replaced; past MAX_SESSIONS
//! bindings a new session is refused (an initialize), never a binding
//! dropped. Tokens are made, listed and revoked only through
//! the admin socket.
//! ```
use crate::admin::{self, Request, Response};
@@ -39,6 +51,8 @@ use crate::policy::ReceiptPolicy;
use crate::server::{BooksServer, Layout, SharedStore};
use crate::settings::Live;
use ledger_store::Store;
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
use ledger_store::auth::{Principals, Scope, Token};
use ledger_store::root::{BookId, Root, RootError};
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
@@ -86,6 +100,9 @@ enum Book {
service: Service,
/// Its store, closed when the server closes (INVARIANT 6).
store: Arc<SharedStore>,
/// Its access log, where requests refused at the door are
/// recorded too (access.rs).
access: Arc<AccessLog>,
},
/// Not opened, with why (INVARIANT 3).
Failed(String),
@@ -119,6 +136,12 @@ struct Books {
started: std::time::Instant,
/// Woken when one finishes.
finished: tokio::sync::Notify,
/// The agents' tokens (auth/principals.jsonl), or why they can't be
/// read: then every MCP request is refused (INVARIANT 8).
principals: RwLock<Result<Principals, String>>,
/// Which token opened each MCP session: a session is used only with
/// the token that opened it (INVARIANT 8).
session_tokens: Mutex<BTreeMap<String, String>>,
}
impl Books {
@@ -133,6 +156,71 @@ impl Books {
text
}
/// Change or read the principals; a log that failed to write is read
/// again before anything else is done with it (chain.rs INVARIANT 2).
fn with_principals(
&self,
f: impl FnOnce(&mut Principals) -> Result<Response, ledger_store::auth::AuthError>,
) -> Response {
let mut guard = self.principals.write().unwrap_or_else(|e| e.into_inner());
let p = match guard.as_mut() {
Ok(p) => p,
Err(why) => return Response::Refused(why.clone()),
};
match f(p) {
Ok(r) => r,
Err(e) => {
if matches!(e, ledger_store::auth::AuthError::Log(_)) {
*guard = self.load_principals();
}
Response::Refused(self.hide(e.to_string()))
}
}
}
/// Forget the sessions a token opened (it was revoked or replaced:
/// they can't be used again, and won't be ended by it).
fn forget_sessions_of(&self, token_id: &str) {
self.session_tokens
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|_, owner| owner != token_id);
}
/// Read the principals from the root's `auth/`.
fn load_principals(&self) -> Result<Principals, String> {
let dir = self.root.auth_dir().map_err(|e| e.to_string())?;
Principals::open(&dir).map_err(|e| self.hide(e.to_string()))
}
/// The live token whose text a request carries as its bearer, or why
/// it is refused (INVARIANT 8): the code for the access log and the
/// words for the client.
fn bearer(&self, headers: &axum::http::HeaderMap) -> Result<Token, (&'static str, String)> {
// The scheme's name in any case (RFC 9110 11.1).
let presented = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split_once(' '))
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
.map(|(_, token)| token.trim());
let Some(presented) = presented else {
return Err((
crate::access::UNAUTHORIZED,
"these books need a token: the human makes one with `wanderbooks token create`"
.into(),
));
};
let guard = self.principals.read().unwrap_or_else(|e| e.into_inner());
match guard.as_ref() {
Err(why) => Err((crate::access::UNAUTHORIZED, why.clone())),
Ok(p) => p.check(presented).cloned().ok_or((
crate::access::UNAUTHORIZED,
"that token isn't one these books know, or it was revoked".into(),
)),
}
}
/// Open the book `id` from disk: served, or failed with why.
fn open(&self, id: &BookId) -> Book {
let opened = match Store::open(&self.root.book_dir(id)) {
@@ -169,8 +257,13 @@ impl Books {
self.import_roots.clone(),
)
.map_err(|e| self.hide(e))?;
let access = Arc::new(
AccessLog::open(store.data_dir())
.map_err(|e| format!("its access log can't be used: {e}"))?,
);
let live = Arc::new(Mutex::new(live));
let store = SharedStore::new(store);
let log = access.clone();
let shared = store.clone();
let config = StreamableHttpServerConfig::default()
.with_json_response(true)
@@ -184,7 +277,8 @@ impl Books {
BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.clone())
.with_import_roots(roots.clone())
.with_layout(layout.clone()),
.with_layout(layout.clone())
.with_access(log.clone()),
)
},
Arc::new(sessions()),
@@ -193,6 +287,7 @@ impl Books {
Ok(Book::Open {
service,
store: shared,
access,
})
}
@@ -235,6 +330,73 @@ impl Books {
})
.collect(),
),
Request::TokenCreate {
book,
name,
scopes,
replace,
} => {
let book = match BookId::parse(&book) {
Ok(id) => id,
Err(e) => return refused(e),
};
if !self.held().contains_key(book.as_str()) {
return Response::Refused(format!(
"there are no books called {book}: `wanderbooks books list` lists them"
));
}
let mut wanted = std::collections::BTreeSet::new();
for s in &scopes {
match Scope::parse(s) {
Some(scope) => {
wanted.insert(scope);
}
None => {
return Response::Refused(format!(
"{:?} isn't a scope: use books:read, books:write, books:post or books:admin",
s.chars().take(64).collect::<String>()
));
}
}
}
let mut replaced: Option<String> = None;
let answer = self.with_principals(|p| {
replaced = p
.tokens()
.iter()
.find(|t| t.book == book.as_str() && t.name == name)
.map(|t| t.id.clone())
.filter(|_| replace);
p.create(&book, &name, wanted, replace)
.map(|(token, text)| Response::TokenCreated {
token: admin::TokenInfo::from(&token),
text: admin::TokenText(text.expose().to_string()),
})
});
if let (Response::TokenCreated { .. }, Some(old)) = (&answer, &replaced) {
self.forget_sessions_of(old);
}
answer
}
Request::TokenList => self.with_principals(|p| {
Ok(Response::Tokens(
p.tokens().iter().map(admin::TokenInfo::from).collect(),
))
}),
Request::TokenRevoke { book, name, id } => {
let book = match BookId::parse(&book) {
Ok(id) => id,
Err(e) => return refused(e),
};
let answer = self.with_principals(|p| {
p.revoke_if(&book, &name, id.as_deref())
.map(|t| Response::TokenRevoked(admin::TokenInfo::from(&t)))
});
if let Response::TokenRevoked(t) = &answer {
self.forget_sessions_of(&t.id);
}
answer
}
Request::Create { id, currency } => {
let id = match BookId::parse(&id) {
Ok(id) => id,
@@ -677,12 +839,67 @@ impl Drop for InFlight {
}
}
/// `/b/{book}/mcp`: the book's MCP service.
/// The MCP session header.
const SESSION_HEADER: &str = "mcp-session-id";
/// The most sessions open at once (each bound to its token): past it a
/// new session is refused until one ends, never a binding forgotten
/// (INVARIANT 8; review of slice 4: clearing them let a flood unbind
/// live sessions).
const MAX_SESSIONS: usize = 100_000;
/// A request refused at the door: recorded in the book's access log
/// (access.rs INVARIANT 3), then answered with `status` and `why`.
async fn refused_at_door(
book: &Book,
principal: Principal,
method: &axum::http::Method,
code: &'static str,
status: axum::http::StatusCode,
why: String,
) -> axum::response::Response {
use axum::response::IntoResponse;
if let Book::Open { access, store, .. } = book {
let access = access.clone();
let record = Access {
principal,
client: None,
request_id: None,
action: "connect".into(),
subject: method.as_str().to_string(),
args_digest: None,
result: Outcome::Refused(code.into()),
head_after: None,
};
let written = tokio::task::spawn_blocking(move || access.record(&record)).await;
if !matches!(written, Ok(Ok(_))) {
store.stop();
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
[(REFUSED_HEADER, "not-recorded")],
crate::access::NOT_RECORDED,
)
.into_response();
}
}
let mut response = (status, [(REFUSED_HEADER, code)], why).into_response();
if status == axum::http::StatusCode::UNAUTHORIZED {
response.headers_mut().insert(
axum::http::header::WWW_AUTHENTICATE,
axum::http::HeaderValue::from_static("Bearer realm=\"wanderbooks\""),
);
}
response
}
/// `/b/{book}/mcp`: the book's MCP service, for a request carrying a token
/// for that book (INVARIANT 8).
async fn mcp(
axum::extract::State(books): axum::extract::State<Arc<Books>>,
axum::extract::Path(book): axum::extract::Path<String>,
request: axum::extract::Request,
mut request: axum::extract::Request,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
use tower::ServiceExt;
// Counted before the closing check, so closing never misses it.
@@ -690,41 +907,179 @@ async fn mcp(
books
.last_arrival
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
if books.closing.is_cancelled() {
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"the books are closing: this request wasn't run",
)
.into_response();
}
let closing = books.closing.is_cancelled();
let found = books.held().get(&book).cloned();
match found {
Some(Book::Open { service, .. }) => match service.oneshot(request).await {
Ok(response) => response.map(|body| {
use http_body_util::BodyExt;
axum::body::Body::new(body.map_frame(move |frame| {
let _held = &counted;
frame
}))
}),
Err(never) => match never {},
let (service, held) = match found {
Some(Book::Failed(why)) if !closing => {
return (
StatusCode::SERVICE_UNAVAILABLE,
[(REFUSED_HEADER, "not-open")],
format!("the books {book} didn't open: {why}"),
)
.into_response();
}
None if !closing => {
return (
StatusCode::NOT_FOUND,
[(REFUSED_HEADER, "no-such-books")],
format!(
"there are no books called {:?}: `wanderbooks books list` lists them",
book.chars().take(64).collect::<String>()
),
)
.into_response();
}
Some(b @ Book::Open { .. }) => match &b {
Book::Open { service, .. } => (service.clone(), b.clone()),
Book::Failed(_) => unreachable!(),
},
Some(Book::Failed(why)) => (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
[(REFUSED_HEADER, "not-open")],
format!("the books {book} didn't open: {why}"),
_ => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"the books are closing: this request wasn't run",
)
.into_response();
}
};
let method = request.method().clone();
let token = books.bearer(request.headers());
let principal = |t: &Token| Principal {
id: t.id.clone(),
kind: "agent".into(),
label: t.name.clone(),
};
if closing {
let who = token
.as_ref()
.map_or_else(|_| Principal::unknown(), principal);
return refused_at_door(
&held,
who,
&method,
crate::access::CLOSING,
StatusCode::SERVICE_UNAVAILABLE,
"the books are closing: this request wasn't run".into(),
)
.into_response(),
None => (
axum::http::StatusCode::NOT_FOUND,
[(REFUSED_HEADER, "no-such-books")],
format!(
"there are no books called {:?}: `wanderbooks books list` lists them",
book.chars().take(64).collect::<String>()
),
)
.into_response(),
.await;
}
let token = match token {
Ok(t) => t,
Err((code, why)) => {
return refused_at_door(
&held,
Principal::unknown(),
&method,
code,
StatusCode::UNAUTHORIZED,
why,
)
.await;
}
};
if token.book != book {
return refused_at_door(
&held,
principal(&token),
&method,
"wrong_books",
StatusCode::FORBIDDEN,
format!("this token is for the books {}, not {book}", token.book),
)
.await;
}
let session = request
.headers()
.get(SESSION_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
// A session is bound when it is opened (below): one bound to another
// token is refused; one never bound isn't one this server opened, and
// is left to the MCP service, which doesn't know it either (and says
// so, which a client takes as "open a new one").
let (elsewhere, full) = {
let bound = books
.session_tokens
.lock()
.unwrap_or_else(|e| e.into_inner());
let elsewhere = session
.as_ref()
.and_then(|sid| bound.get(sid))
.is_some_and(|owner| *owner != token.id);
(elsewhere, session.is_none() && bound.len() >= MAX_SESSIONS)
};
// The cap is on opening sessions: a request opens one only if it is an
// initialize (a 2026-07-28 call without a session opens none).
let full = full && {
let (parts, body) = request.into_parts();
let bytes = axum::body::to_bytes(body, MAX_BODY)
.await
.unwrap_or_default();
let opens = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
request = axum::extract::Request::from_parts(parts, axum::body::Body::from(bytes));
opens
};
if full {
return refused_at_door(
&held,
principal(&token),
&method,
"too_many_sessions",
StatusCode::SERVICE_UNAVAILABLE,
"the books server has too many sessions open: end some, then try again".into(),
)
.await;
}
if elsewhere {
return refused_at_door(
&held,
principal(&token),
&method,
"session_token",
StatusCode::FORBIDDEN,
"this session was opened with another token".into(),
)
.await;
}
let token_id = token.id.clone();
request
.extensions_mut()
.insert(crate::access::Caller { token });
let response = match service.oneshot(request).await {
Ok(response) => response,
Err(never) => match never {},
};
// A session the answer opened is bound to the token that opened it;
// one ended, or found gone, is forgotten.
{
let mut bound = books
.session_tokens
.lock()
.unwrap_or_else(|e| e.into_inner());
if session.is_none()
&& let Some(sid) = response
.headers()
.get(SESSION_HEADER)
.and_then(|v| v.to_str().ok())
{
bound.insert(sid.to_string(), token_id);
}
let ended = method == axum::http::Method::DELETE && response.status().is_success();
let gone = response.status() == StatusCode::NOT_FOUND;
if (ended || gone)
&& let Some(sid) = &session
{
bound.remove(sid);
}
}
response.map(|body| {
use http_body_util::BodyExt;
axum::body::Body::new(body.map_frame(move |frame| {
let _held = &counted;
frame
}))
})
}
/// Anything but `/b/{book}/mcp`.
@@ -900,7 +1255,16 @@ pub fn run(
finished: tokio::sync::Notify::new(),
last_arrival: std::sync::atomic::AtomicU64::new(0),
started: std::time::Instant::now(),
principals: RwLock::new(Err(String::new())),
session_tokens: Mutex::new(BTreeMap::new()),
});
{
let loaded = books.load_principals();
if let Err(why) = &loaded {
eprintln!("wanderbooks server: agents' tokens can't be read: {why}");
}
*books.principals.write().unwrap_or_else(|e| e.into_inner()) = loaded;
}
// INVARIANT 3: every book tried before ready.
for id in root.list_books().map_err(|e| e.to_string())? {
let book = books.open(&id);
+359
View File
@@ -0,0 +1,359 @@
//! What each tool needs of a token (plan 3.3).
//!
//! ```text
//! GOAL: a token does only what its scopes allow: reading needs
//! books:read, changing what isn't posted books:write, posting and
//! what is as final books:post, the access log books:admin.
//! SOURCE: plan 3.3's scope table ("books:post: confirmed post, close,
//! reconcile, dispose, run_*, settings commit").
//! INVARIANTS:
//! 1. Every tool is in exactly one row of TABLE (test).
//! 2. A tool that changes anything (no readOnlyHint) needs more than
//! books:read; a read-only one needs books:read, except reading the
//! access log, which needs books:admin (test).
//! 3. A tool with a `confirmed` argument (a preview, then the act)
//! needs books:write to preview and books:post when confirmed is
//! true.
//! 4. What gates posting needs posting's scope: setting an approval
//! rule, retiring any convention (one might be an approval rule),
//! and approving an entry need books:post. Every other tool needs one
//! scope whatever it is given.
//! 5. No token below books:admin gets a full TIN: until P7 moves TINs
//! to the human pages, the tools that can put one out (vendor_1099
//! with include_tins, export_cpa_package, get_w9) need books:admin,
//! and sealing one into the vault (set_tin, set_w9, which can replace
//! the one there) needs books:post (review of slice 4).
//! ```
use ledger_store::auth::Scope;
/// What a tool needs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Needs {
/// This scope, always.
Always(Scope),
/// books:write to preview, books:post when `confirmed` is true
/// (INVARIANT 3).
PostWhenConfirmed,
/// books:post for an approval rule (the control that gates posting),
/// books:write for any other convention (INVARIANT 4).
PostForApprovalRules,
}
use Needs::{Always, PostForApprovalRules, PostWhenConfirmed};
const R: Needs = Always(Scope::Read);
const W: Needs = Always(Scope::Write);
const P: Needs = Always(Scope::Post);
const A: Needs = Always(Scope::Admin);
const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 109] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
("books.set_setting", P),
("books.import_settings", C),
("books.export_settings", R),
("books.conventions", R),
("books.set_convention", K),
("books.retire_convention", P),
("books.record_decision", W),
("books.decisions", R),
("books.raise_item", W),
("books.resolve_item", W),
("books.open_items", R),
("books.draft_from_template", W),
("books.approve_entry", P),
("books.chart", R),
("books.add_account", W),
("books.trial_balance", R),
("books.profit_and_loss", R),
("books.balance_sheet", R),
("books.cash_flow", R),
("books.draft_entry", W),
("books.add_line", W),
("books.post_entry", C),
("books.reverse_entry", C),
("books.attach_receipt", W),
("books.file_document", W),
("books.link_receipt", W),
("books.unlink_receipt", W),
("books.withdraw_attachment", W),
("books.link_attachment", W),
("books.unlink_attachment", W),
("books.attachments", R),
("books.list_receipts", R),
("books.get_receipt", R),
("books.get_entry", R),
("books.search_entries", R),
("books.show", R),
// It may rebuild the database from the journal: not read-only.
("books.verify", W),
("books.digest", R),
("books.import_bank_csv", W),
("books.create_schedule", W),
("books.list_schedules", R),
("books.run_schedules", C),
("books.create_recurring", W),
("books.list_recurring", R),
("books.run_recurring", P),
("books.record_payout", W),
("books.list_vendors", R),
("books.set_vendor_1099", W),
("books.set_entry_vendor", W),
// Until P7 these can put full TINs out (with include_tins, or a W-9
// itself): only an admin token, never a plain one (plan 3.3, "no
// scope unwraps the vault", holds for every other scope).
("books.vendor_1099", A),
("books.chart_template", R),
("books.tax_categories", R),
("books.renumber_account", P),
("books.label_asset", W),
("books.correct_asset", P),
("books.set_asset_tax_basis", W),
("books.asset_labels", R),
("books.settings", R),
("books.roles", R),
("books.assign_role", P),
("books.set_tax_category", W),
("books.import_chart_csv", W),
("books.import_local_file", W),
("books.export", R),
("books.register_asset", W),
("books.asset_schedule", R),
("books.list_assets", R),
("books.run_depreciation", P),
("books.dispose_asset", C),
("books.create_period", W),
("books.close_period", C),
("books.list_periods", R),
("books.mark_cleared", W),
("books.reconcile", C),
("books.record_statement", W),
("books.statements", R),
("books.reconciliations", R),
("books.void_statement", P),
("books.void_reconciliation", P),
("books.add_customer", W),
("books.add_vendor", W),
("books.add_party", W),
("books.update_party", W),
("books.set_party_roles", W),
// Sealing a TIN can replace the one in the vault.
("books.set_w9", P),
("books.set_tin", P),
("books.get_w9", A),
("books.purge_tin", C),
("books.set_w4", W),
("books.parties", R),
("books.party", R),
("books.set_entry_party", W),
// These post at once, unchecked.
("books.create_invoice", P),
("books.create_bill", P),
("books.receive_payment", P),
("books.pay_bill", P),
("books.ar_aging", R),
("books.ap_aging", R),
("books.set_fx_rate", W),
("books.run_payroll", P),
("books.remit_payroll_taxes", P),
("books.list_payroll_runs", R),
("books.add_class", W),
("books.list_classes", R),
("books.profit_and_loss_by_class", R),
("books.export_cpa_package", A),
("books.access_log", A),
];
/// What `tool` needs, given its arguments; None for a tool this server
/// doesn't have.
pub fn needs(
tool: &str,
args: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<Scope> {
let (_, needs) = TABLE.iter().find(|(name, _)| *name == tool)?;
Some(match needs {
Always(scope) => *scope,
PostWhenConfirmed => {
let confirmed = args
.and_then(|a| a.get("confirmed"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if confirmed { Scope::Post } else { Scope::Write }
}
PostForApprovalRules => {
let kind = args
.and_then(|a| a.get("kind"))
.and_then(serde_json::Value::as_str);
if kind == Some("approval_rule") {
Scope::Post
} else {
Scope::Write
}
}
})
}
/// Whether a token with `scopes` may list `tool` (it can call it in some
/// form: a tool that posts when confirmed shows to a books:write token,
/// which may preview).
pub fn shown(tool: &str, scopes: &std::collections::BTreeSet<Scope>) -> bool {
match TABLE.iter().find(|(name, _)| *name == tool) {
Some((_, Always(scope))) => scopes.contains(scope),
Some((_, PostWhenConfirmed | PostForApprovalRules)) => {
scopes.contains(&Scope::Write) || scopes.contains(&Scope::Post)
}
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::BooksServer;
use serde_json::json;
#[test]
fn every_tool_is_in_the_table_once() {
let tools = BooksServer::tool_router().list_all();
let mut names: Vec<&str> = TABLE.iter().map(|(n, _)| *n).collect();
names.sort_unstable();
let len = names.len();
names.dedup();
assert_eq!(names.len(), len, "a tool is in the table twice");
let mut have: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
have.sort();
assert_eq!(names, have, "the table and the tools differ");
}
#[test]
fn only_read_only_tools_need_no_more_than_read() {
for tool in BooksServer::tool_router().list_all() {
let read_only = tool
.annotations
.as_ref()
.and_then(|a| a.read_only_hint)
.unwrap_or(false);
let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap();
let want = if !read_only {
*needs != R
} else if tool.name == "books.access_log" {
*needs == A
} else {
*needs == R
};
assert!(
want,
"{}: read-only {read_only}, needs {needs:?}",
tool.name
);
}
}
#[test]
fn a_tool_that_takes_confirmed_posts_only_when_confirmed() {
// Exactly the tools whose arguments have `confirmed`.
for tool in BooksServer::tool_router().list_all() {
let takes = tool
.input_schema
.get("properties")
.and_then(|p| p.as_object())
.is_some_and(|p| p.contains_key("confirmed"));
let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap();
assert_eq!(takes, *needs == C, "{}", tool.name);
let takes_kind = tool
.input_schema
.get("properties")
.and_then(|p| p.as_object())
.is_some_and(|p| p.contains_key("kind"));
if *needs == K {
assert!(takes_kind, "{}", tool.name);
}
}
let args = |v: serde_json::Value| v.as_object().cloned();
assert_eq!(needs("books.post_entry", None), Some(Scope::Write));
assert_eq!(
needs(
"books.post_entry",
args(json!({"confirmed": false})).as_ref()
),
Some(Scope::Write)
);
assert_eq!(
needs(
"books.post_entry",
args(json!({"confirmed": true})).as_ref()
),
Some(Scope::Post)
);
// Only a real true confirms.
assert_eq!(
needs(
"books.post_entry",
args(json!({"confirmed": "true"})).as_ref()
),
Some(Scope::Write)
);
assert_eq!(
needs(
"books.create_invoice",
args(json!({"confirmed": false})).as_ref()
),
Some(Scope::Post),
"a tool that posts at once needs books:post whatever it is given"
);
assert_eq!(needs("books.access_log", None), Some(Scope::Admin));
assert_eq!(needs("books.nonesuch", None), None);
// INVARIANT 4.
assert_eq!(
needs(
"books.set_convention",
args(json!({"kind": "approval_rule"})).as_ref()
),
Some(Scope::Post)
);
assert_eq!(
needs(
"books.set_convention",
args(json!({"kind": "policy"})).as_ref()
),
Some(Scope::Write)
);
assert_eq!(needs("books.set_convention", None), Some(Scope::Write));
assert_eq!(needs("books.retire_convention", None), Some(Scope::Post));
assert_eq!(needs("books.approve_entry", None), Some(Scope::Post));
// INVARIANT 5.
for tool in [
"books.vendor_1099",
"books.export_cpa_package",
"books.get_w9",
] {
assert_eq!(needs(tool, None), Some(Scope::Admin), "{tool}");
}
for tool in ["books.set_tin", "books.set_w9", "books.purge_tin"] {
let confirmed = args(json!({"confirmed": true}));
assert_eq!(needs(tool, confirmed.as_ref()), Some(Scope::Post), "{tool}");
}
}
#[test]
fn a_token_is_shown_what_it_can_call() {
let set = |s: &[Scope]| s.iter().copied().collect::<std::collections::BTreeSet<_>>();
let read = set(&[Scope::Read]);
assert!(shown("books.chart", &read));
assert!(!shown("books.post_entry", &read));
assert!(!shown("books.draft_entry", &read));
assert!(!shown("books.access_log", &read));
let write = set(&[Scope::Read, Scope::Write]);
assert!(shown("books.post_entry", &write), "it may preview");
assert!(!shown("books.create_invoice", &write));
let post = set(&[Scope::Post]);
assert!(shown("books.post_entry", &post));
assert!(!shown("books.chart", &post));
assert!(shown("books.access_log", &set(&[Scope::Admin])));
assert!(!shown("books.nonesuch", &set(&Scope::ALL)));
}
}
+324 -20
View File
@@ -71,6 +71,10 @@ pub struct BooksServer {
settings: Arc<Mutex<Live>>,
import_roots: Arc<ImportRoots>,
layout: Arc<Layout>,
/// The book's access log, for books served from the data root: every
/// call is then checked against its caller's token and recorded
/// (access.rs). None for a books dir served over stdio.
access: Option<Arc<ledger_store::access::AccessLog>>,
}
/// Where a book's own files live around its data dir.
@@ -188,7 +192,9 @@ impl SharedStore {
self.stopped.load(std::sync::atomic::Ordering::SeqCst)
}
fn stop(&self) {
/// Stop taking requests (INVARIANT 1): after an internal error, or an
/// access record that couldn't be written (access.rs INVARIANT 3).
pub(crate) fn stop(&self) {
self.stopped
.store(true, std::sync::atomic::Ordering::SeqCst);
}
@@ -219,6 +225,8 @@ const INSIDE_BOOKS: &str = "that path is inside the books' folder, whose files a
pub struct StoreGuard<'a> {
guard: tokio::sync::MutexGuard<'a, Store>,
shared: &'a SharedStore,
/// The journal's head when the lock was taken.
before: (u64, String),
_not_send: std::marker::PhantomData<*const ()>,
}
@@ -227,6 +235,12 @@ impl Drop for StoreGuard<'_> {
if std::thread::panicking() {
self.shared.stop();
}
// A write while it was held: the head after it, for the call's
// access record (taken under the same lock, so it is this call's).
let after = self.guard.head();
if after != self.before {
WROTE.with(|w| *w.borrow_mut() = Some(after));
}
}
}
@@ -256,7 +270,7 @@ where
F: FnOnce() -> std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>> + Send + 'static,
{
if shared.stopped() {
return Err(rmcp::ErrorData::internal_error(STOPPED, None));
return Err(not_run_stopped());
}
// SharedStore INVARIANT 3: counted before the closing check, so a
// close never misses it; the count rides in the blocking task, which
@@ -318,6 +332,21 @@ impl Drop for Running {
/// open (the agents' bridge does).
pub const NOT_RUN_CLOSING_CODE: i32 = -32050;
/// The error code of a request not run because the server had stopped
/// (SharedStore INVARIANT 2), told apart from one that ran and failed.
pub const NOT_RUN_STOPPED_CODE: i32 = -32051;
fn not_run_stopped() -> rmcp::ErrorData {
rmcp::ErrorData::new(rmcp::model::ErrorCode(NOT_RUN_STOPPED_CODE), STOPPED, None)
}
/// A tool's answer that is a refusal in words.
fn tool_error(text: String) -> rmcp::model::CallToolResponse {
rmcp::model::CallToolResponse::Complete(rmcp::model::CallToolResult::error(vec![
rmcp::model::ContentBlock::text(text),
]))
}
/// What a request refused while closing is told.
pub const NOT_RUN_CLOSING: &str = "the books are closing: this call wasn't run";
@@ -528,9 +557,17 @@ impl BooksServer {
}))),
import_roots: Arc::new(ImportRoots::defaults()),
layout: Arc::new(Layout::BooksDir),
access: None,
}
}
/// Check every call against its caller's token, and record it in
/// `log` (access.rs).
pub fn with_access(mut self, log: Arc<ledger_store::access::AccessLog>) -> Self {
self.access = Some(log);
self
}
/// Serve a book kept in the data root (`Layout::Root`) rather than
/// in a books dir.
pub fn with_layout(mut self, layout: Layout) -> Self {
@@ -618,7 +655,7 @@ impl BooksServer {
/// Every tool the server serves.
pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
Self::core_router() + Self::kept_router()
Self::core_router() + Self::kept_router() + Self::access_router()
}
/// The settings in force for this request (reloaded at its start, in
@@ -1064,9 +1101,11 @@ impl BooksServer {
// "Today" from here on is the zone in force now (a change another
// session made while this request waited included).
TIMEZONE.with(|z| *z.borrow_mut() = timezone);
let before = guard.head();
Ok(StoreGuard {
guard,
shared: &self.store,
before,
_not_send: std::marker::PhantomData,
})
}
@@ -2680,6 +2719,9 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa
}
thread_local! {
/// The journal's head after the last write made by the request
/// running on this thread (StoreGuard's drop), for its access record.
static WROTE: std::cell::RefCell<Option<(u64, String)>> = const { std::cell::RefCell::new(None) };
/// The company's time zone for the request running on this thread
/// (set at its start from the settings in force: one version per
/// request), so every "today" in it agrees.
@@ -2848,6 +2890,8 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
// Tools
// ---------------------------------------------------------------------------
#[path = "tools/access.rs"]
mod access_tools;
#[path = "tools/kept.rs"]
mod kept;
pub use kept::SettingKeyOut;
@@ -9560,38 +9604,204 @@ impl ServerHandler for BooksServer {
request: rmcp::model::CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
use crate::access::{self, Call};
use ledger_store::access::Outcome;
let mut call = self
.access
.as_ref()
.map(|log| Call::new(log, &context, "tool", &request.name));
let outcome_of = |out: &Result<rmcp::model::CallToolResponse, rmcp::ErrorData>| match out {
Ok(rmcp::model::CallToolResponse::Complete(r)) if r.is_error != Some(true) => {
Outcome::Ok
}
_ => Outcome::Error,
};
// A refused call's record carries its arguments' digest too,
// worked out off the async threads.
let refused_digest = |args: Option<serde_json::Map<String, serde_json::Value>>| async move {
tokio::task::spawn_blocking(move || Call::digest_of(args.as_ref()))
.await
.ok()
.flatten()
};
// Closing: refused as a protocol error, so a client knows it
// didn't run (SharedStore INVARIANT 3).
if self.store.closing() {
return Err(not_run_closing());
if let Some(c) = &mut call {
c.digested(refused_digest(request.arguments.clone()).await);
}
return self
.refuse(call.as_ref(), access::CLOSING, Err(not_run_closing()))
.await;
}
if self.store.stopped() {
if let Some(c) = &mut call {
c.digested(refused_digest(request.arguments.clone()).await);
}
return self
.refuse(
call.as_ref(),
access::STOPPED,
Ok(tool_error(STOPPED.to_string())),
)
.await;
}
let needs = crate::scopes::needs(&request.name, request.arguments.as_ref());
// access.rs INVARIANTS 1, 2.
if let Some(call) = &mut call {
let refusal = match (call.caller(), needs) {
(None, _) => Some((
access::UNAUTHORIZED,
"this call came without a token for these books".to_string(),
)),
// A tool no table names isn't run (fail closed).
(Some(_), None) => Some((
"unknown_tool",
format!(
"there is no tool {:?}",
request.name.chars().take(64).collect::<String>()
),
)),
(Some(caller), Some(scope)) if !caller.may(scope) => Some((
access::INSUFFICIENT_SCOPE,
format!(
"this agent's token can't do that: {} needs {scope}, and its token ({:?}) has {}. Only the human can give a token more (`wanderbooks token create`).",
request.name,
caller.token.name,
caller
.token
.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
),
)),
_ => None,
};
if let Some((code, text)) = refusal {
call.digested(refused_digest(request.arguments.clone()).await);
return self.refuse(Some(call), code, Ok(tool_error(text))).await;
}
}
// Pick up an edited settings file before the tool runs.
self.live().refresh();
let timezone = self.settings().timezone.clone();
let server = self.clone();
// The journal's head once a call that may change the books has
// run, for its record.
// The call's record is written on its own blocking thread, before
// it counts as done: a close waits for the record too (access.rs
// INVARIANT 3).
let running_call = call.clone();
// Kept for the record of a call the server stopped before running.
let request_args = if call.is_some() {
request.arguments.clone()
} else {
None
};
let result = run_blocking(&self.store, move || {
Box::pin(async move {
WROTE.with(|w| w.borrow_mut().take());
// Counted as running by now (run_blocking): hashed here.
let digest = running_call
.as_ref()
.and_then(|_| Call::digest_of(request.arguments.as_ref()));
let tcc =
rmcp::handler::server::tool::ToolCallContext::new(&server, request, context);
let router = Self::tool_router();
in_timezone_async(timezone, router.call(tcc)).await
use futures_util::FutureExt;
let out =
std::panic::AssertUnwindSafe(in_timezone_async(timezone, router.call(tcc)))
.catch_unwind()
.await;
// The head after this call's own last write, if it wrote,
// whatever it answered.
let wrote = WROTE
.with(|w| w.borrow_mut().take())
.map(|(seq, hash)| ledger_store::chain::Head { seq, hash });
let out = match out {
Ok(out) => out,
Err(panic) => {
// It ran and failed part way: recorded as an error
// with what it wrote, while still counted as
// running; then the panic goes on and stops the
// server (SharedStore INVARIANT 1).
if let Some(mut c) = running_call {
c.digested(digest);
let _ = c.record_blocking(Outcome::Error, wrote);
}
std::panic::resume_unwind(panic)
}
};
let recorded = running_call.map(|mut c| {
c.digested(digest);
c.record_blocking(outcome_of(&out), wrote)
});
(out, recorded)
})
})
.await;
// A failed run (the server stopped, or this request panicked) is a
// plain refusal, redacted like every other.
let result = match result {
Ok(out) => out,
match result {
Ok((out, None)) => redact_refusal(out),
Ok((out, Some(Ok(())))) => redact_refusal(out),
Ok((_, Some(Err(())))) => {
// access.rs INVARIANT 3.
self.store.stop();
Ok(tool_error(crate::access::RAN_NOT_RECORDED.to_string()))
}
// Not run because the books are closing: a protocol error, so
// the client knows (SharedStore INVARIANT 3).
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => return Err(e),
Err(e) => Ok(rmcp::model::CallToolResponse::Complete(
rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text(
e.message.to_string(),
)]),
)),
};
redact_refusal(result)
// the client knows (SharedStore INVARIANT 3). (Its arguments
// went with the request: no digest.)
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => {
self.refuse(call.as_ref(), access::CLOSING, Err(e)).await
}
// Not run: the server had stopped by the time it would have.
Err(e) if e.code.0 == NOT_RUN_STOPPED_CODE => {
if let Some(c) = &mut call {
c.digested(refused_digest(request_args.clone()).await);
}
self.refuse(
call.as_ref(),
access::STOPPED,
Ok(tool_error(STOPPED.to_string())),
)
.await
}
// It panicked part way (recorded as it went): a plain refusal,
// redacted like every other.
Err(e) => redact_refusal(Ok(tool_error(e.message.to_string()))),
}
}
async fn list_tools(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListToolsResult, rmcp::ErrorData> {
let mut tools = Self::tool_router().list_all();
// A token is shown what it can call (scopes.rs); the list then
// differs by caller, so it may be cached only for this one.
if self.access.is_some() {
let scopes = crate::access::caller(&context)
.map(|c| c.token.scopes)
.unwrap_or_default();
tools.retain(|t| crate::scopes::shown(&t.name, &scopes));
} else {
// Books served over stdio keep no access log: not offered.
tools.retain(|t| t.name != "books.access_log");
}
let hints = context
.protocol_version()
.is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28);
Ok(rmcp::model::ListToolsResult {
result_type: Some(rmcp::model::ResultType::COMPLETE),
tools,
meta: None,
next_cursor: None,
ttl_ms: hints.then_some(0),
cache_scope: hints.then_some(rmcp::model::CacheScope::Private),
})
}
fn get_info(&self) -> ServerConfig {
@@ -9625,8 +9835,101 @@ impl ServerHandler for BooksServer {
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
use crate::access::{self, Call};
use ledger_store::access::Outcome;
// Reading a resource is reading the books: books:read, recorded
// (access.rs).
let call = self
.access
.as_ref()
.map(|log| Call::new(log, &context, "resource", &request.uri));
let result = self.resource(request, call.as_ref()).await;
if let Some(call) = &call {
let outcome = match &result {
Ok(_) => Outcome::Ok,
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => {
Outcome::Refused(access::CLOSING.into())
}
Err(e) if e.message == access::INSUFFICIENT_SCOPE => {
Outcome::Refused(access::INSUFFICIENT_SCOPE.into())
}
Err(e) if e.message == access::UNAUTHORIZED => {
Outcome::Refused(access::UNAUTHORIZED.into())
}
Err(_) => Outcome::Error,
};
if call.record(outcome, None).await.is_err() {
self.store.stop();
// Not run stays so; the words say why.
return Err(match result {
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => rmcp::ErrorData::new(
e.code,
format!("{}; {}", e.message, access::NOT_RECORDED),
None,
),
_ => rmcp::ErrorData::internal_error(access::NOT_RECORDED, None),
});
}
}
result
}
}
impl BooksServer {
/// Record a call refused unrun, then give its answer (access.rs
/// INVARIANT 3).
async fn refuse(
&self,
call: Option<&crate::access::Call>,
code: &str,
answer: Result<rmcp::model::CallToolResponse, rmcp::ErrorData>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
if let Some(call) = call
&& call
.record(ledger_store::access::Outcome::Refused(code.into()), None)
.await
.is_err()
{
self.store.stop();
// A call refused as not run stays so (the bridge sends it
// again when the books are back); the words say why.
return match answer {
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => Err(rmcp::ErrorData::new(
e.code,
format!("{}; {}", e.message, crate::access::NOT_RECORDED),
None,
)),
_ => Ok(tool_error(crate::access::NOT_RECORDED.to_string())),
};
}
answer
}
/// A resource's contents, once its caller may read the books.
async fn resource(
&self,
request: ReadResourceRequestParams,
call: Option<&crate::access::Call>,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
if let Some(call) = call {
match call.caller() {
None => {
return Err(rmcp::ErrorData::invalid_request(
crate::access::UNAUTHORIZED,
None,
));
}
Some(c) if !c.may(ledger_store::auth::Scope::Read) => {
return Err(rmcp::ErrorData::invalid_request(
crate::access::INSUFFICIENT_SCOPE,
None,
));
}
Some(_) => {}
}
}
self.live().refresh();
let timezone = self.settings().timezone.clone();
let uri = request.uri.clone();
@@ -10082,7 +10385,7 @@ mod tests {
const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 108] = [
let table: [(&str, Hints); 109] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
@@ -10191,6 +10494,7 @@ mod tests {
("books.list_classes", R),
("books.profit_and_loss_by_class", R),
("books.export_cpa_package", W),
("books.access_log", R),
];
let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len());
+114
View File
@@ -0,0 +1,114 @@
//! `books.access_log`: who reached the books through the server, and what
//! came of it (plan I-9, 4.x ops; books:admin).
//!
//! ```text
//! GOAL: the owner, through their agent, can read the access log, newest
//! first, narrowed to a caller, an action or an outcome.
//! SOURCE: plan I-9 ("readable through books.access_log (admin)").
//! INVARIANTS:
//! 1. It reads the log whole and checks its chain before answering; a
//! damaged log is reported, never shown in part as if whole.
//! 2. It answers at most MAX_RECORDS records, newest first, and says
//! whether there are more.
//! ```
use super::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The most records one call returns (INVARIANT 2).
const MAX_RECORDS: usize = 500;
/// How many it returns when not told.
const DEFAULT_RECORDS: usize = 50;
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct AccessLogParams {
/// How many records, newest first: default 50, at most 500.
#[serde(default)]
pub limit: Option<usize>,
/// Only records before this one (its seq), to page back.
#[serde(default)]
pub before: Option<u64>,
/// Only calls by the token of this name.
#[serde(default)]
pub token: Option<String>,
/// Only this action: tool, resource, or connect (a request refused at
/// the door: no token, another book's, another session's).
#[serde(default)]
pub action: Option<String>,
/// Only this outcome: ok, error or refused.
#[serde(default)]
pub outcome: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AccessLogOut {
/// The records, newest first.
pub records: Vec<serde_json::Value>,
/// Records in the whole log.
pub total: u64,
/// Whether older records match too (page back with `before`).
pub more: bool,
/// Where the log's chain ends: its last seq and hash.
pub head: serde_json::Value,
}
#[tool_router(router = access_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.access_log",
title = "Access log",
description = "Who reached these books through the server, newest first: each call's token, client, tool or resource, a digest of its arguments, and whether it ran, failed or was refused. Narrow it by token name, action (tool, resource, connect: refused at the door) or outcome (ok, error, refused); page back with before. Needs books:admin.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn access_log(
&self,
params: Parameters<AccessLogParams>,
) -> Result<Json<AccessLogOut>, String> {
let p = params.0;
if self.access.is_none() {
return Err("these books keep no access log: only books served from the books' private folder do".into());
}
let limit = p.limit.unwrap_or(DEFAULT_RECORDS);
if limit == 0 || limit > MAX_RECORDS {
return Err(format!("limit is 1 to {MAX_RECORDS}"));
}
if let Some(o) = &p.outcome
&& !["ok", "error", "refused"].contains(&o.as_str())
{
return Err("outcome is ok, error or refused".into());
}
let dir = self.lock()?.data_dir().to_path_buf();
// INVARIANT 1.
let (records, head) = ledger_store::access::read(&dir)
.map_err(|e| format!("the access log can't be read: {e}"))?;
let total = records.len() as u64;
let mut matching = records.into_iter().rev().filter(|r| {
p.before.is_none_or(|b| r.seq < b)
&& p.token
.as_ref()
.is_none_or(|t| &r.access.principal.label == t)
&& p.action.as_ref().is_none_or(|a| &r.access.action == a)
&& p.outcome.as_ref().is_none_or(|o| {
matches!(
(o.as_str(), &r.access.result),
("ok", ledger_store::access::Outcome::Ok)
| ("error", ledger_store::access::Outcome::Error)
| ("refused", ledger_store::access::Outcome::Refused(_))
)
})
});
let out: Vec<serde_json::Value> = matching
.by_ref()
.take(limit)
.map(|r| serde_json::to_value(r).expect("a record serializes"))
.collect();
let more = matching.next().is_some();
Ok(Json(AccessLogOut {
records: out,
total,
more,
head: serde_json::json!({"seq": head.seq, "hash": head.hash}),
}))
}
}
+3
View File
@@ -16,6 +16,9 @@ rust_decimal = "1"
thiserror = "2"
sha2 = "0.10"
hex = "0.4"
getrandom = { version = "0.2", features = ["std"] }
subtle = "2"
argon2 = "0.6"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+484
View File
@@ -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);
}
}
+618
View File
@@ -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}");
}
}
+508
View File
@@ -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);
}
}
+4
View File
@@ -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;
+304
View File
@@ -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(&params.m_cost())
|| !(T_COST..=MAX_T_COST).contains(&params.t_cost())
|| !(P_COST..=MAX_P_COST).contains(&params.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}");
}
}
}
+10
View File
@@ -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> {