diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 42048ce..31f2485 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -20,7 +20,7 @@ fn print_usage() { ); eprintln!(" wanderbooks connect claude|codex --book ID"); eprintln!( - " wanderbooks token create --book ID | list | revoke --book ID | print --book ID" + " wanderbooks token create --book ID | list | revoke --book ID | check --book ID | print --book ID" ); eprintln!(" wanderbooks mcp --book ID [--token NAME]"); eprintln!(" wanderbooks passphrase"); diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index 498378d..d45fd3e 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -2802,3 +2802,111 @@ fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses() "{err}" ); } + +/// tokens.rs INVARIANT 4, commands.rs INVARIANT 4, bridge.rs INVARIANT +/// 11: `token check` says whether the keyring's token is the books' live +/// one, never its text; a second keyring item for one token is said, and +/// `create --replace` keeps one again; a token the keyring can't keep is +/// revoked again; and a bridge that can't read the keyring says so, not +/// "make a token". +#[test] +fn token_check_one_item_per_token_and_a_keyring_that_cant_be_read() { + let home = Home::new("tokencheck"); + 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(); + let said = ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ); + assert!( + !said.1.contains("wb_") && !said.2.contains("wb_"), + "{said:?}" + ); + said + }; + let check = || token(&["check", "--book", "acme"]); + let (code, said, _) = check(); + assert_eq!(code, Some(1)); + assert!(said.contains("There is no token \"agent\""), "{said}"); + let (code, _, err) = token(&["create", "--book", "acme"]); + assert_eq!(code, Some(0), "{err}"); + let (code, said, _) = check(); + assert_eq!(code, Some(0), "{said}"); + assert!(said.contains("is the live one"), "{said}"); + // The one item kept, in the test keyring's default collection. + let keyring = home.run_dir().join("test-keyring"); + let item = std::fs::read_dir(keyring.join("default")) + .unwrap() + .map(|e| e.unwrap().path()) + .collect::>(); + assert_eq!(item.len(), 1); + let item = item[0].clone(); + let file_name = item.file_name().unwrap().to_owned(); + // A second item for it, in another collection: said, none used. + let login = keyring.join("login"); + std::fs::create_dir_all(&login).unwrap(); + std::fs::write(login.join(&file_name), "wb_stale").unwrap(); + let (code, _, err) = check(); + assert_eq!(code, Some(1)); + assert!( + err.contains("2 items") && err.contains("token create --book acme --replace"), + "{err}" + ); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + let err = a.call("books.start", json!({})).unwrap_err(); + assert!(err.contains("2 items"), "{err}"); + // Kept again: one item, the live one, and the session goes on. + let (code, _, err) = token(&["create", "--book", "acme", "--replace"]); + assert_eq!(code, Some(0), "{err}"); + assert!(!login.join(&file_name).exists()); + let (code, said, _) = check(); + assert_eq!(code, Some(0), "{said}"); + a.call("books.start", json!({})).unwrap(); + assert!(a.end().is_some()); + // The keyring holding a token replaced since: not the live one. + let old = std::fs::read_to_string(&item).unwrap(); + let (code, _, _) = token(&["create", "--book", "acme", "--replace"]); + assert_eq!(code, Some(0)); + std::fs::write(&item, &old).unwrap(); + let (code, said, _) = check(); + assert_eq!(code, Some(1)); + assert!( + said.contains("isn't the live one") && said.contains("--replace"), + "{said}" + ); + // A keyring that can't be reached: a bridge with no token says so, + // never "make a token"; and a token made then is revoked again. + std::fs::remove_file(&item).unwrap(); + let unreachable = keyring.join("unreachable"); + std::fs::write(&unreachable, "").unwrap(); + let mut b = Agent::start(&home, "acme"); + b.initialize(); + let err = b.call("books.start", json!({})).unwrap_err(); + assert!( + err.contains("keyring is locked or unreachable") && err.contains("token itself is fine"), + "{err}" + ); + assert!(!err.contains("token create"), "{err}"); + assert!(b.end().is_some()); + let (code, _, err) = check(); + assert_eq!(code, Some(1)); + assert!(err.contains("can't be reached"), "{err}"); + let (code, _, err) = token(&["create", "--book", "acme", "--replace"]); + assert_eq!(code, Some(1)); + assert!( + err.contains("couldn't keep it") && err.contains("revoked again"), + "{err}" + ); + std::fs::remove_file(&unreachable).unwrap(); + let (_, listed, _) = token(&["list"]); + assert!(listed.contains("No tokens yet"), "{listed}"); +} diff --git a/crates/ledger-host/src/bridge.rs b/crates/ledger-host/src/bridge.rs index 304f083..a6220e5 100644 --- a/crates/ledger-host/src/bridge.rs +++ b/crates/ledger-host/src/bridge.rs @@ -40,12 +40,16 @@ //! 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. +//! (tokens.rs) before each new session (even with one held: one the +//! keyring no longer gives is said on stderr, and the keyring's used) +//! and before 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; but when the keyring +//! couldn't be read (locked, unreachable, or holding several items +//! for the token), with that instead: the token is not said to be +//! missing. The token is never written to the agent or to stderr. //! ``` use crate::knobs::{FLAG, Knobs}; @@ -140,6 +144,9 @@ struct Bridge { token: Mutex>, /// Where it is kept: its account, and the root's `run/`. keyring: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>, + /// Why the keyring last couldn't be read, if it couldn't (INVARIANT + /// 11). + keyring_trouble: Mutex>, http: reqwest::Client, knobs: Knobs, to_agent: mpsc::UnboundedSender, @@ -169,6 +176,10 @@ enum Owner { /// before it counts as not answering. const UPSTREAM_WAIT: Duration = Duration::from_secs(10); +/// How long the keyring is waited for before a new session when a token +/// is already held (INVARIANT 11): the one held is used if it is slow. +const KEYRING_RECHECK_WAIT: Duration = Duration::from_secs(2); + /// Where one of the agent's requests stands. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Pending { @@ -340,40 +351,84 @@ impl Bridge { } } + /// Whether a token is held. + fn holds_token(&self) -> bool { + self.token + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + } + /// 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 { + self.read_token_within(self.knobs.queue_wait).await + } + + /// [`Bridge::read_token`], waiting at most `wait` for the keyring. + async fn read_token_within(&self, wait: Duration) -> 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, + wait, tokio::task::spawn_blocking(move || keeper.get(&account)), ) .await; + let read = match read { + Ok(Ok(read)) => read, + Ok(Err(_)) | Err(_) => Err(crate::tokens::KeyringError::Unavailable( + "it didn't give the token in time: is it locked?".into(), + )), + }; + let trouble = read.as_ref().err().cloned(); + *self + .keyring_trouble + .lock() + .unwrap_or_else(|e| e.into_inner()) = trouble; match read { - Ok(Ok(Ok(Some(token)))) => { + 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()); + if new && held.is_some() { + eprintln!( + "wanderbooks mcp: the keyring gives another token for these books than the one held: using the keyring's" + ); + } *held = Some(token); new } - Ok(Ok(Ok(None))) => false, - Ok(Ok(Err(why))) => { + Ok(None) => false, + 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 - } } } + /// What the agent is told instead of the door's "make a token" when + /// no token is held because the keyring couldn't be read (INVARIANT + /// 11). + fn keyring_refusal(&self) -> Option { + if self.holds_token() { + return None; + } + let trouble = self + .keyring_trouble + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone()?; + Some(match trouble { + crate::tokens::KeyringError::Unavailable(why) => format!( + "this computer's keyring is locked or unreachable, so the token for the books {} couldn't be read ({why}). The token itself is fine: unlock the keyring (or log in again) and try once more; don't make a new one (`wanderbooks token check --book {}` says where it stands).", + self.book, self.book + ), + duplicates => duplicates.to_string(), + }) + } + /// A request upstream, carrying the token (INVARIANT 11). fn upstream(&self, method: reqwest::Method) -> reqwest::RequestBuilder { let req = self.http.request(method, self.url()); @@ -416,6 +471,11 @@ impl Bridge { } return self.send_once(message, up).await.0; } + // No token, for a keyring that couldn't be read: that is said, + // not "make one" (INVARIANT 11). + if unauthorized && let Some(why) = self.keyring_refusal() { + return Sent::Refused(why); + } sent } @@ -519,7 +579,15 @@ impl Bridge { params: &Value, epoch: u64, ) -> Result> { - self.find_token().await; + // Read again before every session, even with a token held + // (INVARIANT 11): a held one the keyring no longer gives isn't + // kept on in silence. A slow keyring leaves the one held in use. + if self.holds_token() { + self.read_token_within(KEYRING_RECHECK_WAIT.min(self.knobs.queue_wait)) + .await; + } else { + self.read_token().await; + } let request = json!({"jsonrpc": "2.0", "id": self.own_id(), "method": "initialize", "params": params}); let post = || { self.upstream(reqwest::Method::POST) @@ -530,9 +598,14 @@ impl Bridge { }; 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)?; + // since (INVARIANT 11); with none, for a keyring that couldn't be + // read, that is said instead of the door's words. + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + if self.read_token().await { + response = post().await.map_err(|_| None)?; + } else if let Some(why) = self.keyring_refusal() { + return Err(Some(why)); + } } let session = response .headers() @@ -1265,6 +1338,7 @@ pub fn run( token_name, token: Mutex::new(None), keyring: std::sync::OnceLock::new(), + keyring_trouble: Mutex::new(None), http, knobs, to_agent, @@ -1610,6 +1684,7 @@ mod against_a_server { token_name: crate::tokens::DEFAULT_NAME.into(), token: Mutex::new(Some("wb_test".into())), keyring: std::sync::OnceLock::new(), + keyring_trouble: Mutex::new(None), http, knobs, to_agent, @@ -1821,6 +1896,142 @@ mod against_a_server { assert!(none.session.is_none() && none.init.is_none()); } + /// A bridge whose token is kept in a test keyring in `dir` (none held + /// yet), and that keyring's folder. + fn bridge_with_keyring( + socket: &Path, + dir: &Path, + ) -> (Arc, mpsc::UnboundedReceiver, PathBuf) { + let knobs = Knobs { + file_keyring: true, + ..knobs(1000, 1000) + }; + let (b, rx) = bridge_at(socket, knobs); + *b.token.lock().unwrap() = None; + let account = crate::tokens::Account::new(dir, "acme", crate::tokens::DEFAULT_NAME); + b.keyring.set((account, dir.to_path_buf())).unwrap(); + let keyring = dir.join("test-keyring"); + std::fs::create_dir_all(&keyring).unwrap(); + (b, rx, keyring) + } + + /// The door's refusal of a request with no token. + fn no_token_at_the_door() -> Reply { + Reply { + status: 401, + headers: vec![(ledger_mcp::local::REFUSED_HEADER, "unauthorized".into())], + body: "these books need a token: the human makes one with `wanderbooks token create`" + .into(), + cut: false, + } + } + + /// INVARIANT 11: with no token because the keyring couldn't be read, + /// the agent is told that, never to make a token; with a keyring that + /// simply has none, the door's words. + #[tokio::test] + async fn a_keyring_that_cant_be_read_is_said_never_make_a_token() { + let server = Server::start(); + let dir = tempfile::tempdir().unwrap(); + let (b, _rx, keyring) = bridge_with_keyring(&server.socket, dir.path()); + let call: Value = serde_json::from_str(CALL).unwrap(); + let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}}); + let unreachable = keyring.join(crate::tokens::TEST_UNREACHABLE); + std::fs::write(&unreachable, "").unwrap(); + server.script([no_token_at_the_door(), no_token_at_the_door()]); + let said = what(&b.send(&call, &up(1)).await); + assert!( + said.contains("keyring is locked or unreachable") + && said.contains("token itself is fine") + && said.contains("token check --book acme"), + "{said}" + ); + assert!(!said.contains("token create"), "{said}"); + let refused = b + .initialize_upstream(¶ms, 1) + .await + .unwrap_err() + .unwrap(); + assert!(refused.contains("token itself is fine"), "{refused}"); + assert!( + server + .seen() + .iter() + .all(|s| !s.headers.contains_key("authorization")), + "no bearer was sent" + ); + // Two items for the token: that, and how to keep one again. + std::fs::remove_file(&unreachable).unwrap(); + let account = b.keyring.get().unwrap().0.clone(); + crate::tokens::keeper(dir.path(), &b.knobs) + .set(&account, "wb_one") + .unwrap(); + let other = keyring.join("login"); + std::fs::create_dir_all(&other).unwrap(); + for entry in std::fs::read_dir(keyring.join(crate::tokens::TEST_DEFAULT)).unwrap() { + std::fs::write(other.join(entry.unwrap().file_name()), "wb_two").unwrap(); + } + server.script([no_token_at_the_door()]); + let said = what(&b.send(&call, &up(1)).await); + assert!( + said.contains("2 items") && said.contains("--replace"), + "{said}" + ); + assert!(!said.contains("wb_"), "{said}"); + // A keyring that has none: the door's own words. + std::fs::remove_dir_all(&keyring).unwrap(); + server.script([no_token_at_the_door()]); + let said = what(&b.send(&call, &up(1)).await); + assert!(said.contains("wanderbooks token create"), "{said}"); + } + + /// INVARIANT 11: before each new session the keyring is read again, + /// even with a token held, and the keyring's token is the one used. + #[tokio::test] + async fn each_new_session_reads_the_keyring_again() { + let server = Server::start(); + let dir = tempfile::tempdir().unwrap(); + let (b, _rx, _keyring) = bridge_with_keyring(&server.socket, dir.path()); + *b.token.lock().unwrap() = Some("wb_held".into()); + let account = b.keyring.get().unwrap().0.clone(); + let keeper = crate::tokens::keeper(dir.path(), &b.knobs); + keeper.set(&account, "wb_kept").unwrap(); + let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}}); + let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, + "serverInfo": {"name": "wanderbooks", "version": "0"}}); + let opened = || { + [ + json_reply( + 200, + json!({"jsonrpc": "2.0", "id": "x", "result": result.clone()}), + ), + reply(202, "text/plain", ""), + ] + }; + server.script(opened()); + b.initialize_upstream(¶ms, 1).await.unwrap(); + let bearers = |server: &Server| -> Vec { + server + .seen() + .iter() + .filter_map(|s| s.headers.get("authorization").cloned()) + .collect() + }; + assert_eq!(bearers(&server), ["Bearer wb_kept", "Bearer wb_kept"]); + // A keyring that can't be read then: the token held goes on. + std::fs::write( + dir.path() + .join("test-keyring") + .join(crate::tokens::TEST_UNREACHABLE), + "", + ) + .unwrap(); + server.script(opened()); + b.initialize_upstream(¶ms, 2).await.unwrap(); + assert_eq!(bearers(&server)[2..], ["Bearer wb_kept", "Bearer wb_kept"]); + assert!(b.keyring_refusal().is_none(), "a token is held"); + } + #[tokio::test] async fn the_tool_list_is_read_from_its_answer() { let server = Server::start(); diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index c42ef67..466daab 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -22,10 +22,12 @@ //! 3. Adoption and restoring are previewed first and go 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. `connect` makes the books' agent token only when the keyring +//! 4. A token made is kept in the keyring, and read back from it as +//! kept, before anything is said of it, and its text is printed only +//! by `token print`; one that can't be kept (or read back) is revoked +//! again. A token revoked is forgotten by the keyring too. `token +//! check` says whether the keyring's token is the books' live one, +//! by its SHA-256 alone, never its text. `connect` makes the books' agent token only when the keyring //! has none, replacing the server's (no one could be using it: the //! bridge reads its token from this keyring). //! 5. The viewer's hold lets go only when the owner closes the books; @@ -1238,7 +1240,16 @@ fn make_token( }; 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); + // Kept, and read back as kept: else taken back (INVARIANT 4). + let keeper = crate::tokens::keeper(&run_dir, &common.knobs); + let kept = keeper + .set(&account, &text) + .map_err(String::from) + .and_then(|()| match keeper.get(&account) { + Ok(Some(back)) if back == *text => Ok(()), + Ok(_) => Err("it didn't give back the token it was given to keep".to_string()), + Err(e) => Err(e.to_string()), + }); drop(text); if let Err(why) = kept { let undone = with_server( @@ -1960,6 +1971,7 @@ 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} check --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"); @@ -1967,10 +1979,12 @@ fn token_usage(prog: &str) { 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!(" check whether the token kept here is the books' live one (never"); + eprintln!(" its text: exit 0 if it is)"); eprintln!(" print the token's text, for a client that sends it itself"); } -/// `wanderbooks token create|list|revoke|print`. +/// `wanderbooks token create|list|revoke|check|print`. pub fn token( prog: &str, args: impl IntoIterator, @@ -2102,6 +2116,71 @@ pub fn token( other => Err(fail(format!("the books server answered oddly: {other:?}"))), } } + ["check"] => { + let book = need_book()?; + let opener = Opener::new(common.root.as_deref(), common.knobs, 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); + // Its SHA-256 alone leaves here (INVARIANT 4). + let kept = crate::tokens::keeper(&run_dir, &common.knobs) + .get(&account) + .map_err(|e| { + fail(format!( + "this computer's keyring couldn't give the token {name:?} of the books {book}: {e}" + )) + })? + .map(|text| { + let text = zeroize::Zeroizing::new(text); + ledger_store::journal::sha256_hex(text.as_bytes()) + }); + let request = Request::TokenCheck { + book: book.clone(), + name: name.clone(), + sha256: kept.clone().unwrap_or_default(), + }; + let (live, matches) = match with_server(&common, request).map_err(fail)? { + Response::TokenChecked { live, matches } => (live, matches), + Response::Refused(why) => { + return Err(fail(format!( + "the books server didn't take the check (one older than this command, perhaps): {why}" + ))); + } + other => return Err(fail(format!("the books server answered oddly: {other:?}"))), + }; + let named = if name == crate::tokens::DEFAULT_NAME { + String::new() + } else { + format!(" --name {name}") + }; + let remake = format!("`wanderbooks token create --book {book}{named} --replace`"); + let said = match (kept.is_some(), live, matches) { + (true, Some(t), true) => { + println!( + "The token {name:?} of the books {book} kept in this computer's keyring is the live one (made {}).", + t.created + ); + return Ok(()); + } + (true, Some(t), false) => format!( + "This computer's keyring holds a token {name:?} for the books {book} that isn't the live one (made {}): {remake} makes one and keeps it here; then reconnect the agent.", + t.created + ), + (true, None, _) => format!( + "This computer's keyring holds a token {name:?} for the books {book} that they no longer have (it was revoked): `wanderbooks token create --book {book}{named}` makes one." + ), + (false, Some(t), _) => format!( + "This computer's keyring keeps no token {name:?} for the books {book}, though they have a live one (made {}): {remake} makes one kept here.", + t.created + ), + (false, None, _) => format!( + "There is no token {name:?} for the books {book}: `wanderbooks token create --book {book}{named}` makes one." + ), + }; + println!("{said}"); + Err(exit(1)) + } ["print"] => { let book = need_book()?; let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None) @@ -2117,7 +2196,7 @@ pub fn token( 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)), + Err(why) => Err(fail(why.into())), } } _ => { diff --git a/crates/ledger-host/src/tokens.rs b/crates/ledger-host/src/tokens.rs index 0424876..b1368c4 100644 --- a/crates/ledger-host/src/tokens.rs +++ b/crates/ledger-host/src/tokens.rs @@ -19,6 +19,12 @@ //! 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). +//! 4. One account, one item: keeping a token first removes every item +//! kept for its account in any collection, then makes one and reads +//! it back (else it is refused); reading finds the items in every +//! collection and, finding several, says so instead of giving one +//! of them. A keyring that is locked or can't be reached is an +//! error (KeyringError::Unavailable), never "no token". //! ``` use crate::knobs::Knobs; @@ -29,14 +35,56 @@ pub const SERVICE: &str = "wanderbooks"; /// A token's name when none is given. pub const DEFAULT_NAME: &str = ledger_store::auth::DEFAULT_NAME; +/// Why the keyring gave no answer (INVARIANT 4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum KeyringError { + /// Locked, unreachable, or it refused, with why: a token kept there + /// is untouched. + Unavailable(String), + /// Several items for one token, where one belongs. + Duplicates { + /// How many. + count: usize, + /// The books. + book: String, + /// The token's name. + name: String, + }, +} + +impl std::fmt::Display for KeyringError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable(why) => f.write_str(why), + Self::Duplicates { count, book, name } => { + let named = if name == DEFAULT_NAME { + String::new() + } else { + format!(" --name {name}") + }; + write!( + f, + "this computer's keyring holds {count} items for the token {name:?} of the books {book}, where one belongs, so none of them is used: `wanderbooks token create --book {book}{named} --replace` keeps one again" + ) + } + } + } +} + +impl From for String { + fn from(e: KeyringError) -> Self { + e.to_string() + } +} + /// A place tokens are kept. pub trait Keeper: Send + Sync { - /// The token kept for `account`, if any. - fn get(&self, account: &Account) -> Result, String>; - /// Keep `text` for `account`, replacing any. - fn set(&self, account: &Account, text: &str) -> Result<(), String>; + /// The token kept for `account`, if any (INVARIANT 4). + fn get(&self, account: &Account) -> Result, KeyringError>; + /// Keep `text` for `account`, replacing any (INVARIANT 4). + fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError>; /// Forget `account`'s token (no matter if there is none). - fn delete(&self, account: &Account) -> Result<(), String>; + fn delete(&self, account: &Account) -> Result<(), KeyringError>; } /// Whose token: a root's book's token of a name (INVARIANT 1). @@ -68,6 +116,14 @@ impl Account { fn label(&self) -> String { format!("wanderbooks token: the books {} ({})", self.book, self.name) } + + fn duplicates(&self, count: usize) -> KeyringError { + KeyringError::Duplicates { + count, + book: self.book.clone(), + name: self.name.clone(), + } + } } /// The keeper in force: the OS keyring, or files for tests (INVARIANT 3). @@ -79,58 +135,147 @@ pub fn keeper(run_dir: &Path, knobs: &Knobs) -> Box { } } +/// Items kept under an account, perhaps in several collections: the +/// keyring's or the test files' (INVARIANT 4 is kept once, over these). +trait Items { + /// One item. + type Item; + /// Every item kept for `account`, in any collection. + fn find(&self, account: &Account) -> Result, String>; + /// An item's text. + fn read(&self, item: &Self::Item) -> Result; + /// Remove an item. + fn remove(&self, item: Self::Item) -> Result<(), String>; + /// Make an item for `account` in the default collection. + fn make(&self, account: &Account, text: &str) -> Result<(), String>; +} + +/// The one token kept for `account` (INVARIANT 4). +fn get_one(items: &I, account: &Account) -> Result, KeyringError> { + let mut found = items.find(account).map_err(KeyringError::Unavailable)?; + match found.len() { + 0 => Ok(None), + 1 => items + .read(&found.remove(0)) + .map(Some) + .map_err(KeyringError::Unavailable), + n => Err(account.duplicates(n)), + } +} + +/// Keep `text` as the one item for `account`, read back (INVARIANT 4). +fn set_one(items: &I, account: &Account, text: &str) -> Result<(), KeyringError> { + delete_all(items, account)?; + items + .make(account, text) + .map_err(KeyringError::Unavailable)?; + match get_one(items, account)? { + Some(back) if back == text => Ok(()), + _ => Err(KeyringError::Unavailable( + "it didn't give back the token it was given to keep".into(), + )), + } +} + +/// Remove every item kept for `account`. +fn delete_all(items: &I, account: &Account) -> Result<(), KeyringError> { + for item in items.find(account).map_err(KeyringError::Unavailable)? { + items.remove(item).map_err(KeyringError::Unavailable)?; + } + Ok(()) +} + /// The OS keyring. struct Os; #[cfg(target_os = "linux")] -impl Os { - fn entry(&self, account: &Account) -> Result { - 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}")) +mod os { + use super::{Account, Items, SERVICE}; + use keyring_core::api::CredentialStoreApi; + use std::collections::HashMap; + use std::sync::Arc; + use zbus_secret_service_keyring_store::Store; + + /// The Secret Service, reached for one keeper call. + pub(super) struct Service(Arc); + + impl Service { + pub(super) fn reach() -> Result { + Store::new().map(Self).map_err(|e| { + format!("this computer's keyring can't be reached ({e}): is a Secret Service (GNOME Keyring, KWallet) running and unlocked?") + }) + } + } + + /// What a keyring error says (INVARIANT 4: locked is said as such). + fn refused(e: keyring_core::Error) -> String { + match e { + keyring_core::Error::NoStorageAccess(_) => { + format!("this computer's keyring is locked, or wouldn't let it be read ({e})") + } + e => format!("the keyring refused: {e}"), + } + } + + impl Items for Service { + type Item = keyring_core::Entry; + + fn find(&self, account: &Account) -> Result, String> { + let key = account.key(); + let spec = HashMap::from([("service", SERVICE), ("username", key.as_str())]); + self.0.search(&spec).map_err(refused) + } + + fn read(&self, item: &Self::Item) -> Result { + item.get_password().map_err(refused) + } + + fn remove(&self, item: Self::Item) -> Result<(), String> { + match item.delete_credential() { + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Err(e) => Err(refused(e)), + } + } + + fn make(&self, account: &Account, text: &str) -> Result<(), String> { + let label = account.label(); + let modifiers = HashMap::from([("label", label.as_str())]); + self.0 + .build(SERVICE, &account.key(), Some(&modifiers)) + .and_then(|entry| entry.set_password(text)) + .map_err(refused) + } } } #[cfg(target_os = "linux")] impl Keeper for Os { - fn get(&self, account: &Account) -> Result, 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 get(&self, account: &Account) -> Result, KeyringError> { + let service = os::Service::reach().map_err(KeyringError::Unavailable)?; + get_one(&service, account) } - fn set(&self, account: &Account, text: &str) -> Result<(), String> { - self.entry(account)? - .set_password(text) - .map_err(|e| format!("the keyring refused: {e}")) + fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError> { + let service = os::Service::reach().map_err(KeyringError::Unavailable)?; + set_one(&service, account, text) } - 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}")), - } + fn delete(&self, account: &Account) -> Result<(), KeyringError> { + let service = os::Service::reach().map_err(KeyringError::Unavailable)?; + delete_all(&service, account) } } #[cfg(not(target_os = "linux"))] impl Keeper for Os { - fn get(&self, _: &Account) -> Result, String> { - Err(NOT_YET.into()) + fn get(&self, _: &Account) -> Result, KeyringError> { + Err(KeyringError::Unavailable(NOT_YET.into())) } - fn set(&self, _: &Account, _: &str) -> Result<(), String> { - Err(NOT_YET.into()) + fn set(&self, _: &Account, _: &str) -> Result<(), KeyringError> { + Err(KeyringError::Unavailable(NOT_YET.into())) } - fn delete(&self, _: &Account) -> Result<(), String> { - Err(NOT_YET.into()) + fn delete(&self, _: &Account) -> Result<(), KeyringError> { + Err(KeyringError::Unavailable(NOT_YET.into())) } } @@ -138,31 +283,70 @@ impl Keeper for Os { 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). +/// Files, for tests (INVARIANT 3): a folder per collection (`default/` +/// where tokens are kept), a file a token. A collection holding a file +/// `locked` can't be read; the keyring holding a file `unreachable` +/// can't be reached. So the tests can lock it, or plant a second item. struct Files(PathBuf); +/// The test keyring's default collection. +pub const TEST_DEFAULT: &str = "default"; +/// In a test collection: its items can't be read. +pub const TEST_LOCKED: &str = "locked"; +/// In the test keyring: it can't be reached. +pub const TEST_UNREACHABLE: &str = "unreachable"; + impl Files { - fn path(&self, account: &Account) -> PathBuf { - self.0 - .join(ledger_store::journal::sha256_hex(account.key().as_bytes())) + fn file_name(account: &Account) -> String { + ledger_store::journal::sha256_hex(account.key().as_bytes()) } } -impl Keeper for Files { - fn get(&self, account: &Account) -> Result, 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), +impl Items for Files { + type Item = PathBuf; + + fn find(&self, account: &Account) -> Result, String> { + let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind()); + if self.0.join(TEST_UNREACHABLE).exists() { + return Err("the test keyring can't be reached".into()); + } + let collections = match std::fs::read_dir(&self.0) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(fail(e)), + }; + let name = Self::file_name(account); + let mut found = Vec::new(); + for c in collections { + let file = c.map_err(fail)?.path().join(&name); + if file.is_file() { + found.push(file); + } + } + found.sort(); + Ok(found) + } + + fn read(&self, item: &PathBuf) -> Result { + if item.with_file_name(TEST_LOCKED).exists() { + return Err("the test keyring is locked".into()); + } + std::fs::read_to_string(item).map_err(|e| format!("the test keyring refused: {}", e.kind())) + } + + fn remove(&self, item: PathBuf) -> Result<(), String> { + match std::fs::remove_file(item) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(format!("the test keyring refused: {}", e.kind())), } } - fn set(&self, account: &Account, text: &str) -> Result<(), String> { + fn make(&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 dir = self.0.join(TEST_DEFAULT); + std::fs::create_dir_all(&dir).map_err(fail)?; let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -171,17 +355,23 @@ impl Keeper for Files { options.mode(0o600); } options - .open(&path) + .open(dir.join(Self::file_name(account))) .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())), - } +impl Keeper for Files { + fn get(&self, account: &Account) -> Result, KeyringError> { + get_one(self, account) + } + + fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError> { + set_one(self, account, text) + } + + fn delete(&self, account: &Account) -> Result<(), KeyringError> { + delete_all(self, account) } } @@ -230,9 +420,10 @@ mod tests { { 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(); + let files: Vec<_> = + std::fs::read_dir(dir.path().join("test-keyring").join(TEST_DEFAULT)) + .unwrap() + .collect(); assert_eq!(files.len(), 1); let mode = files[0] .as_ref() @@ -245,6 +436,99 @@ mod tests { } } + /// The test keyring with a second collection (INVARIANT 4). + fn two_collections() -> (tempfile::TempDir, Files, Account, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let files = Files(dir.path().join("test-keyring")); + let a = Account::new(dir.path(), "acme", "agent"); + let other = files.0.join("login"); + std::fs::create_dir_all(&other).unwrap(); + (dir, files, a, other) + } + + #[test] + fn two_items_for_one_token_are_said_never_one_of_them_given() { + let (_dir, k, a, other) = two_collections(); + k.set(&a, "wb_new").unwrap(); + std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap(); + let err = k.get(&a).unwrap_err(); + assert_eq!(err, a.duplicates(2)); + let said = err.to_string(); + assert!( + said.contains("2 items") && said.contains("token create --book acme --replace"), + "{said}" + ); + assert!(!said.contains("wb_"), "{said}"); + // Kept again: every item goes first, and one is left. + k.set(&a, "wb_newer").unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_newer")); + assert!(!other.join(Files::file_name(&a)).exists()); + // Forgotten in every collection. + std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap(); + k.delete(&a).unwrap(); + assert_eq!(k.get(&a).unwrap(), None); + // Another token's name is said in the repair. + let b = Account::new(_dir.path(), "acme", "codex"); + assert!( + b.duplicates(3) + .to_string() + .contains("--name codex --replace") + ); + } + + #[test] + fn a_locked_or_unreachable_keyring_is_an_error_never_no_token() { + let (_dir, k, a, other) = two_collections(); + // The only item, in a locked collection. + std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap(); + std::fs::write(other.join(TEST_LOCKED), "").unwrap(); + let err = k.get(&a).unwrap_err(); + assert!(matches!(err, KeyringError::Unavailable(_)), "{err:?}"); + std::fs::remove_file(other.join(TEST_LOCKED)).unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_old")); + std::fs::write(k.0.join(TEST_UNREACHABLE), "").unwrap(); + for err in [ + k.get(&a).unwrap_err(), + k.set(&a, "wb_new").unwrap_err(), + k.delete(&a).unwrap_err(), + ] { + assert!(matches!(err, KeyringError::Unavailable(_)), "{err:?}"); + } + std::fs::remove_file(k.0.join(TEST_UNREACHABLE)).unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_old"), "untouched"); + } + + /// A keyring that keeps something other than it was given. + struct Forgetful; + + impl Items for Forgetful { + type Item = (); + fn find(&self, _: &Account) -> Result, String> { + Ok(vec![()]) + } + fn read(&self, _: &()) -> Result { + Ok("wb_someone_elses".into()) + } + fn remove(&self, _: ()) -> Result<(), String> { + Ok(()) + } + fn make(&self, _: &Account, _: &str) -> Result<(), String> { + Ok(()) + } + } + + #[test] + fn a_token_kept_is_read_back_or_refused() { + let dir = tempfile::tempdir().unwrap(); + let a = Account::new(dir.path(), "acme", "agent"); + let err = set_one(&Forgetful, &a, "wb_mine").unwrap_err(); + assert!( + matches!(&err, KeyringError::Unavailable(w) if w.contains("didn't give back")), + "{err:?}" + ); + assert!(!err.to_string().contains("wb_")); + } + /// 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. diff --git a/crates/ledger-mcp/src/admin.rs b/crates/ledger-mcp/src/admin.rs index ef2ce85..d484278 100644 --- a/crates/ledger-mcp/src/admin.rs +++ b/crates/ledger-mcp/src/admin.rs @@ -26,7 +26,7 @@ //! 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). +//! INVARIANT 1): a check (TokenCheck) asks by the text's SHA-256. //! 7. A format upgrade is asked with the owner passphrase, which the //! server checks before anything is done (plan 3.9, D-39); no answer //! carries it, and Debug never shows it. @@ -144,6 +144,17 @@ pub enum Request { }, /// The live tokens. TokenList, + /// Whether a token kept elsewhere (this computer's keyring) is the + /// live one of its books and name: asked by its SHA-256 alone, never + /// its text (INVARIANT 6). + TokenCheck { + /// Its books. + book: String, + /// Its name. + name: String, + /// The hex SHA-256 of the text kept. + sha256: String, + }, /// The owner's go-ahead for the format upgrade of books waiting for /// one (plan 3.9, D-39): a verified backup with the keys, then this /// version's journal format declared. The host sends it, for the @@ -455,6 +466,15 @@ pub enum Response { Tokens(Vec), /// A token ended. TokenRevoked(TokenInfo), + /// What a token check found: the books' live token of that name, if + /// any, and whether the text asked about is it. + TokenChecked { + /// The live token of that books and name. + #[serde(default, skip_serializing_if = "Option::is_none")] + live: Option, + /// The text asked about is that live token's. + matches: bool, + }, /// A format upgrade made (or none needed). FormatUpgraded(FormatUpgraded), /// Refused, with why (no path in the data root: INVARIANT 3). @@ -497,7 +517,7 @@ pub fn call_within( let not_sent = |why: String| CallError::NotSent(why); // A list changes nothing: its only question is whether it answered. let look = match request { - Request::List | Request::TokenList => None, + Request::List | Request::TokenList | Request::TokenCheck { .. } => None, Request::Create { .. } | Request::Adopt { .. } | Request::Restore { .. } => { Some("`wanderbooks books list` shows the books there are") } @@ -793,6 +813,24 @@ mod tests { assert!(shown.contains("text: TokenText(…)"), "{shown}"); let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap(); assert_eq!(r.request, Request::TokenList); + let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_check", + "book": "acme", "name": "agent", "sha256": "ab"})) + .unwrap(); + assert_eq!( + r.request, + Request::TokenCheck { + book: "acme".into(), + name: "agent".into(), + sha256: "ab".into() + } + ); + assert!( + serde_json::from_value::( + json!({"proto": 1, "op": "token_check", "book": "acme", "name": "agent", "text": "wb_x"}) + ) + .is_err(), + "a check carries no text" + ); assert!( serde_json::from_value::( json!({"proto": 1, "op": "token_revoke", "book": "acme"}) diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index e53c0c1..f3eedde 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -41,8 +41,8 @@ //! 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. +//! dropped. Tokens are made, listed, checked (by a +//! SHA-256, never a text) and revoked only through the admin socket. //! 9. A file's bytes come in only by `PUT /b/{id}/u/{upload}` and go //! out only by `GET /b/{id}/a/{attachment}` (a stored file) and `GET //! /b/{id}/x/{export}` (a file the books made, D-29) (plan 4.7), each @@ -254,7 +254,7 @@ impl Books { let Some(presented) = presented else { return Err(( crate::access::UNAUTHORIZED, - "these books need a token: the human makes one with `wanderbooks token create`" + "these books need a token, and this request carried none: the human makes one with `wanderbooks token create` (if one was made, `wanderbooks token check` says whether this computer's keyring still gives it)" .into(), )); }; @@ -469,6 +469,15 @@ impl Books { p.tokens().iter().map(admin::TokenInfo::from).collect(), )) }), + Request::TokenCheck { book, name, sha256 } => self.with_principals(|p| { + let live = p.tokens().iter().find(|t| t.book == book && t.name == name); + // A hash, not a secret: compared as it is. + let matches = live.is_some_and(|t| t.sha256.eq_ignore_ascii_case(&sha256)); + Ok(Response::TokenChecked { + live: live.map(admin::TokenInfo::from), + matches, + }) + }), Request::TokenRevoke { book, name, id } => { let book = match BookId::parse(&book) { Ok(id) => id, @@ -2952,6 +2961,30 @@ mod tests { assert_eq!(bound(&books), ["s-other"]); } + /// A token check answers by the SHA-256 asked, for the books and name + /// asked alone. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_token_check_says_whether_the_text_is_the_live_one() { + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + let (old_id, old) = token(&books, "acme", "agent"); + let (new_id, new) = token(&books, "acme", "agent"); + assert_ne!(old_id, new_id); + let check = |book: &str, name: &str, text: &str| match books.admin(Request::TokenCheck { + book: book.into(), + name: name.into(), + sha256: ledger_store::journal::sha256_hex(text.as_bytes()), + }) { + Response::TokenChecked { live, matches } => (live.map(|t| t.id), matches), + other => panic!("{other:?}"), + }; + assert_eq!(check("acme", "agent", &new), (Some(new_id.clone()), true)); + assert_eq!(check("acme", "agent", &old), (Some(new_id), false)); + assert_eq!(check("acme", "other", &new), (None, false)); + assert_eq!(check("beta", "agent", &new), (None, false)); + } + /// An MCP request to `acme` with `bearer`, on `session` if given. fn mcp_request( method: axum::http::Method, diff --git a/docs/testing.md b/docs/testing.md index e79267d..6c1de1e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -491,6 +491,29 @@ recorded once with refusals, a damaged token log refusing every agent), `open_while_held.rs` (the bridge picking up a token made mid-session), `setup.rs` (the passphrase prompts, at a real terminal). +**One keyring item per token (2026-10-22).** On the live books a +reconnected bridge got no token from a keyring that couldn't be read, +was told to "make a token", and after `token create --replace` ran on a +token the keyring no longer gave while `download` read a revoked one. +The test keyring is now a folder per collection that a test can lock, +make unreachable, or plant a second item in (tokens.rs INVARIANT 4), and +the keeping and reading logic is shared with the OS keyring's. +`tokens.rs` unit tests (two items are said, never one of them given; +keeping removes every item first and reads back; a keyring that keeps +something else is refused; locked or unreachable is an error, never no +token), `bridge.rs` `a_keyring_that_cant_be_read_is_said_never_make_a_token` +and `each_new_session_reads_the_keyring_again` (against the scripted +server), `local.rs` `a_token_check_says_whether_the_text_is_the_live_one`, +`admin.rs` (`token_check` carries a hash, never a text), and +`open_while_held.rs` +`token_check_one_item_per_token_and_a_keyring_that_cant_be_read` (`token +check`'s every state end to end, a planted second item refused by +`check` and by the bridge and repaired by `--replace`, a bridge with an +unreachable keyring told so, a token the keyring can't keep revoked +again). The real Secret Service stays behind the ignored +`os_keyring_keeps_and_forgets`: tests never touch this computer's +keyring. + **Slice 5, files in and out.** Uploads by hash and size, exports handed over by the server (`ledger-store` `uploads.rs`, `exports.rs`; `ledger-mcp` `tools/files.rs`; `wanderbooks upload`/`download`): unit tests (expiry, diff --git a/plan/served.md b/plan/served.md index 0f23f0d..bb15261 100644 --- a/plan/served.md +++ b/plan/served.md @@ -192,8 +192,8 @@ No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has **Human factor for `/h/`:** passkey with user verification (webauthn-rs; localhost is a secure context), or the owner passphrase (Argon2id, RFC 9106, m=64 MiB, t=3, p=1). Locally the passphrase is the default. Cookies are `__Host-` and SameSite=Strict, with CSRF tokens and CSP `default-src 'self'`. URL-mode elicitation is used where the client declares it, and the server requires that the session's `sub` equal the token's `sub`. **As built (P2 slice 4), and where it departs:** -- **Tokens** are `wb_` and 64 hex digits (32 random bytes), one book and one name each (`agent` unless named), kept only as a SHA-256 in `auth/principals.jsonl`: a chain of `token.created` (with the token it replaces) and `token.revoked` records, each with its own hash besides the chain's (so a change to the last record is found too), replayed at start; one that can't be read refuses every agent with why (the list of books still answers). Checked in constant time against every live token. Made, listed and revoked only through the admin socket: `wanderbooks token create --book ID [--name N] [--scopes read,write,post,admin] [--replace] | list | revoke | print`. A token made is kept in the keyring before anything is said of it, or revoked again; `print` is the only way its text is shown. `connect` (slice 8) will mint through the same path. -- **Kept in the OS keyring** (Secret Service on Linux, through keyring-core; macOS and Windows stores come with P11), under service `wanderbooks` and an account naming the root by a hash of its path, the book and the token's name. The bridge (`wanderbooks mcp --book ID [--token NAME]`) reads it before each new session and each request until it has one, and again when the door refuses it, so a token made or replaced mid-session is used from the next call (with a new session: a session belongs to the token that opened it); without one, the server's refusal says how to make one. Tests keep tokens in files under the root's `run/` (the `file_keyring` knob). +- **Tokens** are `wb_` and 64 hex digits (32 random bytes), one book and one name each (`agent` unless named), kept only as a SHA-256 in `auth/principals.jsonl`: a chain of `token.created` (with the token it replaces) and `token.revoked` records, each with its own hash besides the chain's (so a change to the last record is found too), replayed at start; one that can't be read refuses every agent with why (the list of books still answers). Checked in constant time against every live token. Made, listed, checked and revoked only through the admin socket: `wanderbooks token create --book ID [--name N] [--scopes read,write,post,admin] [--replace] | list | revoke | check | print`. A token made is kept in the keyring, and read back from it as kept, before anything is said of it, or revoked again; `print` is the only way its text is shown. `check` says whether the token kept here is the books' live one (exit 0), an old one, one the books no longer have, or none, asking the server by its SHA-256 alone (admin op `token_check`). `connect` (slice 8) will mint through the same path. +- **Kept in the OS keyring** (Secret Service on Linux, through keyring-core; macOS and Windows stores come with P11), under service `wanderbooks` and an account naming the root by a hash of its path, the book and the token's name. One account is one item (2026-10-22, after a bridge on the live books ran on a token the keyring no longer gave while `download` read a revoked one): keeping a token first removes every item for its account in every collection, then makes one and reads it back; reading finds the items in every collection and, finding several, refuses with how to keep one again (`token create --replace`) instead of giving one of them; a keyring that is locked or can't be reached is an error, never "no token". The bridge (`wanderbooks mcp --book ID [--token NAME]`) reads it before each new session (even with a token held: the keyring's is used, and a change is said on stderr; a slow keyring leaves the held one in use after 2 s) and each request until it has one, and again when the door refuses it, so a token made or replaced mid-session is used from the next call (with a new session: a session belongs to the token that opened it); without one, the server's refusal says how to make one, but when the keyring couldn't be read the agent is told that instead (locked or unreachable, the token itself fine; or several items), never to make a token. Tests keep tokens in files under the root's `run/` (the `file_keyring` knob): a folder per collection, which a test can lock, make unreachable, or plant a second item in. - **The door** (`/b/{id}/mcp`): no token or an unknown one, 401 with `WWW-Authenticate: Bearer` (the scheme in any case); another book's, 403; a session is bound to its token when opened and used only with it (403), forgotten when it ends, is found gone (an idle one, when next used), or its token is revoked or replaced; an id the server never opened binds nothing; past 100,000 bindings an initialize is refused, never a binding dropped. Each refusal at the door is recorded in that book's access log (action `connect`). - **Scopes** (`ledger-mcp/src/scopes.rs`): a table of every tool. Read-only tools need books:read; a tool with `confirmed` needs books:write to preview and books:post to act; tools that post at once, settings commits, voids, renumbering, corrections and role changes need books:post, and so do the controls on posting (setting an approval rule, retiring a convention, approving an entry) and sealing a TIN (`set_tin`, `set_w9`, which can replace one); `books.access_log` and the tools that can still put a full TIN out (`vendor_1099`, `export_cpa_package`, `get_w9`) need books:admin, and so do backing up now and choosing where backups go (a `backup.*` setting, by `set_setting` or an imported `[backup]` table; slice 7): a backup is the whole book. A token is offered only the tools it can call (the list is then cached per caller, `cacheScope: private`); a call beyond its scopes is refused, unrun, naming the scope. Reading a resource needs books:read. A local token gets all four unless told otherwise. - **The access log** (`books//access.jsonl`, own genesis) records every tool call and resource read, refusals included: who (token id, name), the client as it named itself (cut to 64 characters, any full TIN masked), the request id, the tool or resource, a SHA-256 of the arguments' canonical JSON with what may be a TIN masked first (a value under a key naming one, any key or text holding one in a shape the TIN scanner knows, any number of eight digits or more: an unsalted digest of a TIN whose last four the books show is broken by 10^5 guesses; a file's base64 is hashed as it is, never spelling a TIN in the clear), what came of it (`ok`, `error`, or `refused` with a code), and the journal's head after the call's own last write, if it wrote, whatever it answered (taken under the store's lock as the call lets go). A call's record is written on its own blocking thread while it still counts as running (one that panics part way included, with what it wrote), so a close waits for it too, and before the answer goes; one that can't be written stops the book (no more requests until a restart) and the answer says the call ran (a call refused as not run, by a close or a stopped server, keeps its "not run" code). A tool no table names is refused unrun. Known gap: a crash of the process between a call's journal write and its record leaves the journal ahead of the log for that call. A damaged log, or one holding a record that isn't an access, keeps its book closed, with why. `books.access_log` (admin) reads it newest first, filtered by token, action or outcome, paged with `before`; over stdio (no log) the tool isn't offered.