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

+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();
}
}