refactor(host): tokens live in a file under the data root (U0e)

D-63: the 0700 data root is the trust boundary already (I-1: hidden, not
locked), so the OS keyring added no wall and cost headless servers a D-Bus
session, CI a knob and every OS its own store. The file keeper is the only
store: run/tokens/, 0600, written atomically. The three keyring stores, the
file_keyring knob and their tests are removed; an unreadable token file is an
error, never no token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 13:31:42 -06:00
1 parent cfaf05b862
commit d703b68911
15 files changed
+280 -898

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@
//! the test temp dir finds it), made by `wanderbooks setup --home`;
//! its server runs with an empty environment but PATH, and HOME and
//! XDG_CONFIG_HOME inside the home, on that home's root alone: never
//! this account's root, keyring, or sockets.
//! this account's root, tokens, or sockets.
//! 2. A server is ended by its own child's PID, never by a search.
//! 3. Settings a test gives as a settings file's text go into the books
//! as an agent puts them there: the changes ledger-config's import
+3 -3
View File
@@ -27,9 +27,9 @@ use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// Short timings for tests, 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,backup_quiet=300";
/// Short timings for tests.
const KNOBS: &str =
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
/// The owner passphrase the tests set.
const PASS: &str = "correct horse battery";
+39 -51
View File
@@ -17,9 +17,9 @@ 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), 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,backup_quiet=300";
/// 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,backup_quiet=300";
/// A home with a data root, and the program the tests run (possibly a
/// copy, for restarts).
@@ -653,7 +653,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,file_keyring=1",
"close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000",
]);
let mut a = Agent::from(c);
let started = Instant::now();
@@ -722,7 +722,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,file_keyring=1",
"close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000",
]);
let mut a = Agent::from(c);
a.initialize();
@@ -1323,7 +1323,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,file_keyring=1",
"close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000",
]);
let mut a = Agent::from(c);
a.initialize();
@@ -1393,7 +1393,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,file_keyring=1",
"close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000",
]);
let mut a = Agent::from(c);
a.initialize();
@@ -1831,7 +1831,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,file_keyring=1",
"close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000",
]);
let mut d = Agent::from(c);
d.initialize();
@@ -2108,7 +2108,10 @@ fn the_token_commands_and_the_bridge() {
// 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("kept in a token file in the data root"),
"{made}"
);
assert!(
made.contains("books:read") && !made.contains("wb_"),
"{made}"
@@ -2124,7 +2127,7 @@ fn the_token_commands_and_the_bridge() {
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).
// token file 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}");
@@ -2161,7 +2164,7 @@ fn the_token_commands_and_the_bridge() {
printed.trim().starts_with("wb_") && printed.trim().len() == 67,
"{printed}"
);
// Revoked: the next call is refused, and the keyring forgets it.
// Revoked: the next call is refused, and the token file forgets it.
let (code, said, _) = token(&["revoke", "--book", "acme"]);
assert_eq!(code, Some(0), "{said}");
assert!(b.call("books.chart", json!({})).is_err());
@@ -2810,13 +2813,12 @@ fn the_viewers_hold_new_books_and_close_the_books() {
}
#[test]
fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses() {
fn connect_makes_anew_a_token_whose_file_is_gone_and_says_when_an_agent_refuses() {
use std::os::unix::fs::PermissionsExt;
let home = Home::new("connect-lost");
home.create("acme");
// The server keeps the books' agent token; this computer's keyring
// lost it.
std::fs::remove_dir_all(home.run_dir().join("test-keyring")).unwrap();
// The server keeps the books' agent token; its token file is gone.
std::fs::remove_dir_all(home.run_dir().join("tokens")).unwrap();
let (bin, _log) = fake_agent(&home, "claude");
let connect = |path: &Path| {
home.cmd("connect")
@@ -2852,13 +2854,12 @@ fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses()
}
/// 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".
/// 11: `token check` says whether the token file's token is the books'
/// live one, never its text; an unreadable token file is said, by the
/// check and by a bridge, never "no token"; and a token the file can't
/// keep is revoked again.
#[test]
fn token_check_one_item_per_token_and_a_keyring_that_cant_be_read() {
fn token_check_says_where_the_token_file_stands_and_an_unreadable_one_is_said() {
let home = Home::new("tokencheck");
let out = home.books(&["create", "acme"]);
assert!(out.status.success(), "{out:?}");
@@ -2889,38 +2890,24 @@ fn token_check_one_item_per_token_and_a_keyring_that_cant_be_read() {
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"))
// The one file kept, mode 0600.
let folder = home.run_dir().join("tokens");
let item = std::fs::read_dir(&folder)
.unwrap()
.map(|e| e.unwrap().path())
.collect::<Vec<_>>();
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}"
);
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&item).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
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.
// The file 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));
@@ -2931,30 +2918,31 @@ fn token_check_one_item_per_token_and_a_keyring_that_cant_be_read() {
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.
// A token file that can't be read (a folder in its place): 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();
std::fs::create_dir(&item).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.contains("token file in the data root couldn't be read")
&& 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}");
assert!(err.contains("token file couldn't give"), "{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();
std::fs::remove_dir(&item).unwrap();
let (_, listed, _) = token(&["list"]);
assert!(listed.contains("No tokens yet"), "{listed}");
}
+1 -1
View File
@@ -339,7 +339,7 @@ impl OwnerMenu {
if ui
.button(format!("{} Connect {}", icon::PLUGS_CONNECTED, agent.label()))
.on_hover_text(format!(
"Let {} reach these books (it keeps their token in this computer's keyring)",
"Let {} reach these books (it keeps their token in a file in the data root)",
agent.label()
))
.clicked()
-13
View File
@@ -18,10 +18,6 @@ base64 = "0.22"
# `wanderbooks download` checks what arrived against its Repr-Digest.
sha2 = "0.10"
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, the login Keychain on macOS, the Credential Manager on
# Windows (P11a step 0).
keyring-core = "1"
# The owner passphrase is typed at the terminal, unechoed.
rpassword = "7"
# Token texts and passphrases are wiped from memory when dropped.
@@ -30,15 +26,6 @@ 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"] }
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] }
[target.'cfg(windows)'.dependencies]
windows-native-keyring-store = "1.1.0"
[dev-dependencies]
tempfile = "3"
+77 -104
View File
@@ -42,17 +42,16 @@
//! 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 (even with one held: one the
//! keyring no longer gives is said on stderr, and the keyring's used)
//! 11. Every request upstream carries its token, read from the token
//! file (tokens.rs) before each new session (even with one held: one
//! the file no longer gives is said on stderr, and the file'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.
//! refusal, which says how the human makes one; but when the token
//! file is there and couldn't be read, 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};
@@ -143,13 +142,13 @@ 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).
/// The token's text, once read from the token file (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)>,
/// Why the keyring last couldn't be read, if it couldn't (INVARIANT
token_file: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>,
/// Why the token file last couldn't be read, if it couldn't (INVARIANT
/// 11).
keyring_trouble: Mutex<Option<crate::tokens::KeyringError>>,
token_file_trouble: Mutex<Option<crate::tokens::TokenFileError>>,
http: reqwest::Client,
knobs: Knobs,
to_agent: mpsc::UnboundedSender<Value>,
@@ -179,9 +178,9 @@ 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
/// How long the token file 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);
const TOKEN_FILE_RECHECK_WAIT: Duration = Duration::from_secs(2);
/// Where one of the agent's requests stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -339,9 +338,9 @@ impl Bridge {
format!("http://localhost/b/{}/mcp", self.book)
}
/// Read the token from the keyring if it isn't known yet (INVARIANT
/// Read the token from the token file 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
/// try. A token file that fails is said on stderr; the server's refusal
/// then tells the agent what to do.
async fn find_token(&self) {
if self
@@ -362,19 +361,19 @@ impl Bridge {
.is_some()
}
/// Read the token from the keyring, keeping it if found: whether a
/// Read the token from the token file, 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.
/// [`Bridge::read_token`], waiting at most `wait` for the file.
async fn read_token_within(&self, wait: Duration) -> bool {
let Some((account, run_dir)) = self.keyring.get().cloned() else {
let Some((account, run_dir)) = self.token_file.get().cloned() else {
return false;
};
let keeper = crate::tokens::keeper(&run_dir, &self.knobs);
let keeper = crate::tokens::keeper(&run_dir);
let read = tokio::time::timeout(
wait,
tokio::task::spawn_blocking(move || keeper.get(&account)),
@@ -382,13 +381,13 @@ impl Bridge {
.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(),
Ok(Err(_)) | Err(_) => Err(crate::tokens::TokenFileError::new(
"the token file didn't give the token in time",
)),
};
let trouble = read.as_ref().err().cloned();
*self
.keyring_trouble
.token_file_trouble
.lock()
.unwrap_or_else(|e| e.into_inner()) = trouble;
match read {
@@ -397,7 +396,7 @@ impl Bridge {
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"
"wanderbooks mcp: the token file gives another token for these books than the one held: using the file's"
);
}
*held = Some(token);
@@ -412,24 +411,21 @@ impl Bridge {
}
/// 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
/// no token is held because the token file couldn't be read (INVARIANT
/// 11).
fn keyring_refusal(&self) -> Option<String> {
fn token_file_refusal(&self) -> Option<String> {
if self.holds_token() {
return None;
}
let trouble = self
.keyring_trouble
.token_file_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(),
})
Some(format!(
"the token file in the data root couldn't be read, so the token for the books {} couldn't be used ({trouble}). The token itself is fine: mend what stops the file being read and try once more; don't make a new one (`wanderbooks token check --book {}` says where it stands).",
self.book, self.book
))
}
/// A request upstream, carrying the token (INVARIANT 11).
@@ -464,7 +460,7 @@ impl Bridge {
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
// replaced since is read from the token file (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.
@@ -474,9 +470,9 @@ impl Bridge {
}
return self.send_once(message, up).await.0;
}
// No token, for a keyring that couldn't be read: that is said,
// No token, for a token file that couldn't be read: that is said,
// not "make one" (INVARIANT 11).
if unauthorized && let Some(why) = self.keyring_refusal() {
if unauthorized && let Some(why) = self.token_file_refusal() {
return Sent::Refused(why);
}
sent
@@ -583,10 +579,10 @@ impl Bridge {
epoch: u64,
) -> Result<Upstream, Option<String>> {
// 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.
// (INVARIANT 11): a held one the token file no longer gives isn't
// kept on in silence. A slow token file leaves the one held in use.
if self.holds_token() {
self.read_token_within(KEYRING_RECHECK_WAIT.min(self.knobs.queue_wait))
self.read_token_within(TOKEN_FILE_RECHECK_WAIT.min(self.knobs.queue_wait))
.await;
} else {
self.read_token().await;
@@ -601,12 +597,12 @@ impl Bridge {
};
let mut response = post().await.map_err(|_| None)?;
// Refused for its token: once more with one made or replaced
// since (INVARIANT 11); with none, for a keyring that couldn't be
// since (INVARIANT 11); with none, for a token file 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() {
} else if let Some(why) = self.token_file_refusal() {
return Err(Some(why));
}
}
@@ -918,7 +914,7 @@ impl Bridge {
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 _ = self.token_file.set((account, run_dir));
let mut epoch = 0u64;
loop {
let mut lease = match opener.hold().await {
@@ -1279,7 +1275,7 @@ fn usage(prog: &str) {
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. It uses the");
eprintln!(" token NAME (default: agent) that `wanderbooks token create` kept in");
eprintln!(" this computer's keyring.");
eprintln!(" a file in the data root.");
}
/// `wanderbooks mcp --book ID`.
@@ -1340,8 +1336,8 @@ pub fn run(
book,
token_name,
token: Mutex::new(None),
keyring: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None),
token_file: std::sync::OnceLock::new(),
token_file_trouble: Mutex::new(None),
http,
knobs,
to_agent,
@@ -1683,11 +1679,11 @@ 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.
// A token the fake server doesn't check; no token file is read.
token_name: crate::tokens::DEFAULT_NAME.into(),
token: Mutex::new(Some("wb_test".into())),
keyring: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None),
token_file: std::sync::OnceLock::new(),
token_file_trouble: Mutex::new(None),
http,
knobs,
to_agent,
@@ -1899,23 +1895,21 @@ 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(
/// A bridge whose token is kept in a token file under `dir` (none
/// held yet), and the keeper of that file.
fn bridge_with_token_file(
socket: &Path,
dir: &Path,
) -> (Arc<Bridge>, mpsc::UnboundedReceiver<Value>, PathBuf) {
let knobs = Knobs {
file_keyring: true,
..knobs(1000, 1000)
};
let (b, rx) = bridge_at(socket, knobs);
) -> (
Arc<Bridge>,
mpsc::UnboundedReceiver<Value>,
crate::tokens::Keeper,
) {
let (b, rx) = bridge_at(socket, knobs(1000, 1000));
*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)
b.token_file.set((account, dir.to_path_buf())).unwrap();
(b, rx, crate::tokens::keeper(dir))
}
/// The door's refusal of a request with no token.
@@ -1929,22 +1923,24 @@ mod against_a_server {
}
}
/// 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.
/// INVARIANT 11: with no token because the token file is there and
/// couldn't be read, the agent is told that, never to make a token;
/// with a token file that simply isn't there, the door's words.
#[tokio::test]
async fn a_keyring_that_cant_be_read_is_said_never_make_a_token() {
async fn a_token_file_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 (b, _rx, keeper) = bridge_with_token_file(&server.socket, dir.path());
let account = b.token_file.get().unwrap().0.clone();
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();
// A directory where the token file belongs: unreadable.
let file = keeper.file(&account);
std::fs::create_dir_all(&file).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 file in the data root couldn't be read")
&& said.contains("token itself is fine")
&& said.contains("token check --book acme"),
"{said}"
@@ -1963,41 +1959,22 @@ mod against_a_server {
.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();
// No file at all: the door's own words.
std::fs::remove_dir_all(&file).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.
/// INVARIANT 11: before each new session the token file is read
/// again, even with a token held, and the file's token is the one used.
#[tokio::test]
async fn each_new_session_reads_the_keyring_again() {
async fn each_new_session_reads_the_token_file_again() {
let server = Server::start();
let dir = tempfile::tempdir().unwrap();
let (b, _rx, _keyring) = bridge_with_keyring(&server.socket, dir.path());
let (b, _rx, keeper) = bridge_with_token_file(&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);
let account = b.token_file.get().unwrap().0.clone();
keeper.set(&account, "wb_kept").unwrap();
let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}});
let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
@@ -2021,18 +1998,14 @@ mod against_a_server {
.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();
// A token file that can't be read then: the token held goes on.
let file = keeper.file(&account);
std::fs::remove_file(&file).unwrap();
std::fs::create_dir_all(&file).unwrap();
server.script(opened());
b.initialize_upstream(&params, 2).await.unwrap();
assert_eq!(bearers(&server)[2..], ["Bearer wb_kept", "Bearer wb_kept"]);
assert!(b.keyring_refusal().is_none(), "a token is held");
assert!(b.token_file_refusal().is_none(), "a token is held");
}
#[tokio::test]
+33 -35
View File
@@ -22,14 +22,15 @@
//! 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, 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).
//! 4. A token made is kept in its token file (tokens.rs), 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 token
//! file too. `token check` says whether the file's token is the
//! books' live one, by its SHA-256 alone, never its text. `connect`
//! makes the books' agent token only when there is no token file for
//! it, replacing the server's (no one could be using it: the bridge
//! reads its token from this file).
//! 5. The viewer's hold lets go only when the owner closes the books;
//! books that stop, or won't open, are tried again.
//! 6. Books waiting for a format upgrade (plan 3.9, D-39): the owner's
@@ -150,7 +151,7 @@ fn with_server(common: &Common, request: Request) -> Result<Response, String> {
}
/// [`with_server`], with the root's `run/` folder too (where the
/// keyring's test stand-in lives).
/// token files live).
fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBuf), String> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
@@ -1127,7 +1128,7 @@ pub fn connect(
"--help" | "-h" => {
eprintln!("usage: {prog} claude|codex --book ID");
eprintln!(
" Lets Claude Code or Codex reach the books: keeps their token in this computer's keyring and adds the books to the agent's own list."
" Lets Claude Code or Codex reach the books: keeps their token in a file in the data root and adds the books to the agent's own list."
);
return Ok(());
}
@@ -1160,7 +1161,7 @@ pub fn connect(
}
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
/// their token kept in the keyring (made if none is), and `wanderbooks
/// their token kept in its token file (made if none is), and `wanderbooks
/// mcp --book <book>` added to the agent's own list as
/// `wanderbooks-<book>`. What was done, a line each.
pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<String>, String> {
@@ -1207,22 +1208,19 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
})?;
let mut lines = Vec::new();
// The token: kept in the keyring, or made now.
// The token: kept in its file, or made now.
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME);
if crate::tokens::keeper(&run, &common.knobs)
.get(&account)?
.is_none()
{
if crate::tokens::keeper(&run).get(&account)?.is_none() {
let scopes = ledger_store::auth::Scope::ALL
.iter()
.map(|s| s.as_str().to_string())
.collect();
make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?;
lines.push(format!(
"Made the agent token of the books {book}, kept in this computer's keyring."
"Made the agent token of the books {book}, kept in a token file in the data root."
));
}
// The agent's own list: the books' entry made anew.
@@ -1291,8 +1289,8 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
Ok(lines)
}
/// Make the token `name` for `book` (the server does) and keep it in this
/// computer's keyring (INVARIANT 4: kept first, or taken back).
/// Make the token `name` for `book` (the server does) and keep it in its
/// token file (INVARIANT 4: kept first, or taken back).
fn make_token(
common: &Common,
book: &str,
@@ -1315,7 +1313,7 @@ fn make_token(
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, name);
// Kept, and read back as kept: else taken back (INVARIANT 4).
let keeper = crate::tokens::keeper(&run_dir, &common.knobs);
let keeper = crate::tokens::keeper(&run_dir);
let kept = keeper
.set(&account, &text)
.map_err(String::from)
@@ -1345,7 +1343,7 @@ fn make_token(
""
};
return Err(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
"the token was made, but its token file couldn't keep it ({why}), {after}.{replaced}"
));
}
Ok(info)
@@ -2047,8 +2045,8 @@ fn token_usage(prog: &str) {
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");
eprintln!(" create make an agent's token for the books ID and keep it in its");
eprintln!(" token file, 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)");
@@ -2129,7 +2127,7 @@ pub fn token(
let book = need_book()?;
let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?;
println!(
"Made the token {:?} for the books {} ({}), kept in this computer's keyring.",
"Made the token {:?} for the books {} ({}), kept in a token file in the data root.",
info.name,
info.book,
info.scopes.join(", ")
@@ -2177,11 +2175,11 @@ pub fn token(
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);
let forgot = crate::tokens::keeper(&run_dir).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"
"warning: its token file still holds its text ({why}); it opens nothing now"
);
}
Ok(())
@@ -2198,11 +2196,11 @@ pub fn token(
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)
let kept = crate::tokens::keeper(&run_dir)
.get(&account)
.map_err(|e| {
fail(format!(
"this computer's keyring couldn't give the token {name:?} of the books {book}: {e}"
"the token file couldn't give the token {name:?} of the books {book}: {e}"
))
})?
.map(|text| {
@@ -2232,20 +2230,20 @@ pub fn token(
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 {}).",
"The token {name:?} of the books {book} kept in its token file 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.",
"The token file 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."
"The token file 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.",
"The token file 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!(
@@ -2262,7 +2260,7 @@ pub fn token(
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) {
match crate::tokens::keeper(&run_dir).get(&account) {
Ok(Some(text)) => {
println!("{text}");
Ok(())
@@ -2361,7 +2359,7 @@ pub fn passphrase(
}
/// A request to a book over the server's socket, with the agent's token
/// from the keyring, the books held open meanwhile: its status, the
/// from the token file, the books held open meanwhile: its status, the
/// headers wanted, and its body.
fn on_the_socket(
common: &Common,
@@ -2389,7 +2387,7 @@ fn on_the_socket(
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, token_name);
let keeper = crate::tokens::keeper(&run_dir, &common.knobs);
let keeper = crate::tokens::keeper(&run_dir);
let token = tokio::task::spawn_blocking(move || keeper.get(&account))
.await
.map_err(|e| e.to_string())??
+1 -20
View File
@@ -27,9 +27,6 @@ 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,
/// How long the books stay unwritten before the server backs them up
/// (plan 3.7: 30 s), passed to the server as `--backup-quiet`.
pub backup_quiet: Duration,
@@ -48,7 +45,6 @@ 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,
backup_quiet: Duration::from_secs(30),
drill_first: Duration::from_secs(60),
}
@@ -66,14 +62,6 @@ 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"))?;
@@ -114,9 +102,6 @@ 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(","))
}
@@ -168,10 +153,6 @@ 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"));
}
@@ -183,7 +164,7 @@ mod tests {
"close_after=x",
"nope=1",
"close_after=-1",
"file_keyring=yes",
"token_file=1",
] {
assert!(Knobs::parse(bad).is_err(), "{bad}");
}
+1 -1
View File
@@ -8,7 +8,7 @@
//! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks
//! mcp`), and the owner's commands in [`commands`] (`serve`, `close`,
//! `books`, `token`, `connect`, `doctor`, `passphrase`, and what the
//! viewer's Books menu does). Agents' tokens are kept in the OS keyring
//! viewer's Books menu does). Agents' tokens are kept in files under the data root
//! ([`tokens`]).
#[cfg(unix)]
+115 -552
View File
@@ -1,90 +1,65 @@
//! 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
//! Where an agent's token is kept on this computer: a file in the data
//! root's `run/` folder, read by the bridge, so no agent's configuration
//! holds it.
//!
//! Removed (D-63): the OS keyring stores (Secret Service, macOS Keychain,
//! Windows Credential Manager) and the `file_keyring` test knob, because
//! the 0700 data root is the trust boundary already (I-1: hidden, not
//! locked), so a keyring added no wall and cost headless servers a D-Bus
//! session and every OS its own store.
//!
//! ```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)").
//! SOURCE: plan 3.3 and D-63 ("a 0600 file under the 0700 data root, on
//! every OS"), 2.1 ("Local token ... read by the bridge"), 3.5
//! (any process running as that user can read it: advisory
//! locally, I-1).
//! 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.
//! 1. A token is kept in a file named by a hash of 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).
//! 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".
//! 3. The file is in the root's `run/tokens/`, mode 0600 on Unix, and
//! written atomically: a temporary file in the same folder, made
//! 0600 before any byte is written, then renamed over the old one,
//! so a reader sees the old token or the new one, never half.
//! 4. A token file that is there but can't be read is an error, never
//! "no token"; only a missing file is no token.
//! ```
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 = ledger_store::auth::DEFAULT_NAME;
/// Why the keyring gave no answer (INVARIANT 4).
/// Why the token file couldn't be read, kept or removed (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,
},
}
pub struct TokenFileError(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 TokenFileError {
/// An error with `why`, which never holds a token's text.
pub(crate) fn new(why: &str) -> Self {
Self(why.to_string())
}
}
impl From<KeyringError> for String {
fn from(e: KeyringError) -> Self {
impl std::fmt::Display for TokenFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<TokenFileError> for String {
fn from(e: TokenFileError) -> Self {
e.to_string()
}
}
/// A place tokens are kept.
pub trait Keeper: Send + Sync {
/// The token kept for `account`, if any (INVARIANT 4).
fn get(&self, account: &Account) -> Result<Option<String>, 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<(), KeyringError>;
fn refused(e: std::io::Error) -> TokenFileError {
TokenFileError(format!("the token file refused: {}", e.kind()))
}
/// Whose token: a root's book's token of a name (INVARIANT 1).
@@ -107,369 +82,52 @@ impl Account {
}
}
/// The keyring's account name: `<root tag>/<book>/<name>`.
/// The account's 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)
}
fn duplicates(&self, count: usize) -> KeyringError {
KeyringError::Duplicates {
count,
book: self.book.clone(),
name: self.name.clone(),
}
fn file_name(&self) -> String {
ledger_store::journal::sha256_hex(self.key().as_bytes())
}
}
/// 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 place tokens are kept: files in a root's `run/` folder.
pub struct Keeper(PathBuf);
/// The keeper for the root whose `run/` folder is `run_dir`.
pub fn keeper(run_dir: &Path) -> Keeper {
Keeper(run_dir.join("tokens"))
}
/// 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<Vec<Self::Item>, String>;
/// An item's text.
fn read(&self, item: &Self::Item) -> Result<String, String>;
/// 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<I: Items>(items: &I, account: &Account) -> Result<Option<String>, 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)),
impl Keeper {
/// Where `account`'s token file is.
pub(crate) fn file(&self, account: &Account) -> PathBuf {
self.0.join(account.file_name())
}
}
/// Keep `text` as the one item for `account`, read back (INVARIANT 4).
fn set_one<I: Items>(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<I: Items>(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;
/// What a keyring error says (INVARIANT 4: locked is said as such).
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
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}"),
}
}
/// Make an item for `account` in a store's default collection.
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
fn make_in(
store: &dyn keyring_core::api::CredentialStoreApi,
account: &Account,
text: &str,
) -> Result<(), String> {
let label = account.label();
let modifiers = std::collections::HashMap::from([("label", label.as_str())]);
// Only the Secret Service takes a label; the other stores refuse
// modifiers they don't know, so they are built without.
let modifiers = cfg!(target_os = "linux").then_some(&modifiers);
store
.build(SERVICE, &account.key(), modifiers)
.and_then(|entry| entry.set_password(text))
.map_err(refused)
}
/// An item's text, and its removal, the same on every store.
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
fn read_item(item: &keyring_core::Entry) -> Result<String, String> {
item.get_password().map_err(refused)
}
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
fn remove_item(item: keyring_core::Entry) -> Result<(), String> {
match item.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(refused(e)),
}
}
#[cfg(target_os = "linux")]
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<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
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?")
})
/// The token kept for `account`, if any (INVARIANT 4).
pub fn get(&self, account: &Account) -> Result<Option<String>, TokenFileError> {
match std::fs::read_to_string(self.file(account)) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(refused(e)),
}
}
impl Items for Service {
type Item = keyring_core::Entry;
fn find(&self, account: &Account) -> Result<Vec<Self::Item>, String> {
let key = account.key();
let spec = HashMap::from([("service", SERVICE), ("username", key.as_str())]);
self.0.search(&spec).map_err(super::refused)
}
fn read(&self, item: &Self::Item) -> Result<String, String> {
super::read_item(item)
}
fn remove(&self, item: Self::Item) -> Result<(), String> {
super::remove_item(item)
}
fn make(&self, account: &Account, text: &str) -> Result<(), String> {
super::make_in(&*self.0, account, text)
}
}
}
/// The macOS login Keychain (plan 3.3, D-56): one generic password per
/// account, under the service "wanderbooks".
#[cfg(target_os = "macos")]
mod os {
use super::{Account, Items, SERVICE};
use apple_native_keyring_store::keychain::Store;
use keyring_core::api::CredentialStoreApi;
use std::collections::HashMap;
use std::sync::Arc;
/// The login keychain, reached for one keeper call.
pub(super) struct Service(Arc<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
Store::new().map(Self).map_err(|e| {
format!(
"this Mac's keychain can't be reached ({e}): is the login keychain unlocked?"
)
})
}
}
impl Items for Service {
type Item = keyring_core::Entry;
fn find(&self, account: &Account) -> Result<Vec<Self::Item>, String> {
let key = account.key();
let spec = HashMap::from([("service", SERVICE), ("user", key.as_str())]);
self.0.search(&spec).map_err(super::refused)
}
fn read(&self, item: &Self::Item) -> Result<String, String> {
super::read_item(item)
}
fn remove(&self, item: Self::Item) -> Result<(), String> {
super::remove_item(item)
}
fn make(&self, account: &Account, text: &str) -> Result<(), String> {
super::make_in(&*self.0, account, text)
}
}
}
/// The Windows Credential Manager: one generic credential per account
/// (its target name is unique, so a second item for an account cannot
/// exist; INVARIANT 4 holds by construction and is still checked).
#[cfg(windows)]
mod os {
use super::{Account, Items, SERVICE};
use keyring_core::api::CredentialStoreApi;
use std::sync::Arc;
use windows_native_keyring_store::Store;
/// The Credential Manager, reached for one keeper call.
pub(super) struct Service(Arc<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
Store::new()
.map(Self)
.map_err(|e| format!("this computer's Credential Manager can't be reached ({e})"))
}
}
impl Items for Service {
type Item = keyring_core::Entry;
/// The credential for the account, if one is there: its target
/// name is the account, so a probe finds it or finds none.
fn find(&self, account: &Account) -> Result<Vec<Self::Item>, String> {
let entry = self
.0
.build(SERVICE, &account.key(), None)
.map_err(super::refused)?;
match entry.get_password() {
Ok(_) => Ok(vec![entry]),
Err(keyring_core::Error::NoEntry) => Ok(Vec::new()),
Err(e) => Err(super::refused(e)),
}
}
fn read(&self, item: &Self::Item) -> Result<String, String> {
super::read_item(item)
}
fn remove(&self, item: Self::Item) -> Result<(), String> {
super::remove_item(item)
}
fn make(&self, account: &Account, text: &str) -> Result<(), String> {
super::make_in(&*self.0, account, text)
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
impl Keeper for Os {
fn get(&self, account: &Account) -> Result<Option<String>, KeyringError> {
let service = os::Service::reach().map_err(KeyringError::Unavailable)?;
get_one(&service, account)
}
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<(), KeyringError> {
let service = os::Service::reach().map_err(KeyringError::Unavailable)?;
delete_all(&service, account)
}
}
/// Any other OS has no keyring store yet.
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
impl Keeper for Os {
fn get(&self, _: &Account) -> Result<Option<String>, KeyringError> {
Err(KeyringError::Unavailable(NOT_YET.into()))
}
fn set(&self, _: &Account, _: &str) -> Result<(), KeyringError> {
Err(KeyringError::Unavailable(NOT_YET.into()))
}
fn delete(&self, _: &Account) -> Result<(), KeyringError> {
Err(KeyringError::Unavailable(NOT_YET.into()))
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
const NOT_YET: &str = "agents' tokens are kept in the keyring on Linux, macOS and Windows only";
/// 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 file_name(account: &Account) -> String {
ledger_store::journal::sha256_hex(account.key().as_bytes())
}
}
impl Items for Files {
type Item = PathBuf;
fn find(&self, account: &Account) -> Result<Vec<PathBuf>, 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<String, String> {
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 make(&self, account: &Account, text: &str) -> Result<(), String> {
/// Keep `text` for `account`, replacing any (INVARIANT 3).
pub fn set(&self, account: &Account, text: &str) -> Result<(), TokenFileError> {
use std::io::Write;
let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind());
let dir = self.0.join(TEST_DEFAULT);
std::fs::create_dir_all(&dir).map_err(fail)?;
std::fs::create_dir_all(&self.0).map_err(refused)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700))
.map_err(refused)?;
}
let name = account.file_name();
let tmp = self.0.join(format!("{name}.tmp{}", std::process::id()));
let _ = std::fs::remove_file(&tmp);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
@@ -477,24 +135,24 @@ impl Items for Files {
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options
.open(dir.join(Self::file_name(account)))
.and_then(|mut f| f.write_all(text.as_bytes()))
.map_err(fail)
}
}
impl Keeper for Files {
fn get(&self, account: &Account) -> Result<Option<String>, KeyringError> {
get_one(self, account)
let written = options
.open(&tmp)
.and_then(|mut f| f.write_all(text.as_bytes()).and_then(|()| f.sync_all()))
.and_then(|()| std::fs::rename(&tmp, self.0.join(&name)));
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(refused(e));
}
Ok(())
}
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)
/// Forget `account`'s token (no matter if there is none).
pub fn delete(&self, account: &Account) -> Result<(), TokenFileError> {
match std::fs::remove_file(self.file(account)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(refused(e)),
}
}
}
@@ -517,17 +175,12 @@ mod tests {
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() {
fn the_token_file_keeps_and_forgets() {
let dir = tempfile::tempdir().unwrap();
let knobs = Knobs {
file_keyring: true,
..Knobs::default()
};
let k = keeper(dir.path(), &knobs);
let k = keeper(dir.path());
let a = Account::new(dir.path(), "acme", "agent");
assert_eq!(k.get(&a).unwrap(), None);
k.set(&a, "wb_one").unwrap();
@@ -539,135 +192,45 @@ mod tests {
k.delete(&a).unwrap();
k.delete(&a).unwrap();
assert_eq!(k.get(&a).unwrap(), None);
}
#[test]
fn a_token_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let k = keeper(dir.path());
let a = Account::new(dir.path(), "acme", "agent");
k.set(&a, "wb_first").unwrap();
// A second create for the same name replaces the first; one file
// remains, and no temporary file is left behind.
k.set(&a, "wb_second").unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_second"));
let files: Vec<_> = std::fs::read_dir(dir.path().join("tokens"))
.unwrap()
.map(|e| e.unwrap())
.collect();
assert_eq!(files.len(), 1);
assert_eq!(files[0].file_name().to_string_lossy(), a.file_name());
#[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").join(TEST_DEFAULT))
.unwrap()
.collect();
assert_eq!(files.len(), 1);
let mode = files[0]
.as_ref()
.unwrap()
.metadata()
.unwrap()
.permissions()
.mode();
let mode = files[0].metadata().unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
let folder = dir.path().join("tokens").metadata().unwrap();
assert_eq!(folder.permissions().mode() & 0o777, 0o700);
}
}
/// The test keyring with a second collection (INVARIANT 4).
fn two_collections() -> (tempfile::TempDir, Files, Account, PathBuf) {
#[test]
fn an_unreadable_token_file_is_an_error_never_no_token() {
let dir = tempfile::tempdir().unwrap();
let files = Files(dir.path().join("test-keyring"));
let k = keeper(dir.path());
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();
// A directory where the file belongs.
std::fs::create_dir_all(dir.path().join("tokens").join(a.file_name())).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<Vec<()>, String> {
Ok(vec![()])
}
fn read(&self, _: &()) -> Result<String, String> {
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("token file"), "{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.
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
#[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();
assert!(k.set(&a, "wb_new").is_err());
assert!(k.delete(&a).is_err());
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ pub enum Request {
},
/// The live tokens.
TokenList,
/// Whether a token kept elsewhere (this computer's keyring) is the
/// Whether a token kept elsewhere (its token file) is the
/// live one of its books and name: asked by its SHA-256 alone, never
/// its text (INVARIANT 6).
TokenCheck {
+1 -1
View File
@@ -255,7 +255,7 @@ impl Books {
let Some(presented) = presented else {
return Err((
crate::access::UNAUTHORIZED,
"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)"
"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 its token file still gives it)"
.into(),
));
};