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:
1 parent
cfaf05b862
commit
d703b68911
15 files changed
+280
-898
No files matched your search
@@ -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(¶ms, 2).await.unwrap();
|
||||
assert_eq!(bearers(&server)[2..], ["Bearer wb_kept", "Bearer wb_kept"]);
|
||||
assert!(b.keyring_refusal().is_none(), "a token is held");
|
||||
assert!(b.token_file_refusal().is_none(), "a token is held");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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())??
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user