Tokens: one keyring item per token, read back; the bridge says a locked keyring; token check
check / check (push) Canceled after 0s

On the live books a reconnected bridge got no token from a keyring that
couldn't be read, was told to "make a token", and after
`token create --replace` kept on with the new token in memory while
`download` read a revoked one from the keyring.

- tokens.rs INVARIANT 4: keeping a token removes every item for its
  account in every collection, makes one and reads it back; reading finds
  every item and, finding several, refuses with how to keep one again; a
  locked or unreachable keyring is KeyringError::Unavailable, never
  "no token". The logic is shared by the OS keyring and the test files
  (now a folder per collection a test can lock, make unreachable, or
  plant a second item in).
- make_token reads the token back before saying anything, or revokes it.
- The bridge tells the agent the keyring is locked or unreachable and the
  token is fine (or that it holds several items), never to make a token;
  it reads the keyring again before every new session, even with a token
  held.
- `wanderbooks token check --book ID [--name N]`: whether the token kept
  here is the books' live one, asked by its SHA-256 (admin op
  token_check), never its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 04:17:22 -06:00
1 parent 321b94562a
commit d24653558c
9 files changed
+871 -95

No files matched your search

+36 -3
View File
@@ -41,8 +41,8 @@
//! bound to its token when opened, and forgotten when it ends, is
//! found gone, or its token is revoked or replaced; past MAX_SESSIONS
//! bindings a new session is refused (an initialize), never a binding
//! dropped. Tokens are made, listed and revoked only through
//! the admin socket.
//! dropped. Tokens are made, listed, checked (by a
//! SHA-256, never a text) and revoked only through the admin socket.
//! 9. A file's bytes come in only by `PUT /b/{id}/u/{upload}` and go
//! out only by `GET /b/{id}/a/{attachment}` (a stored file) and `GET
//! /b/{id}/x/{export}` (a file the books made, D-29) (plan 4.7), each
@@ -254,7 +254,7 @@ impl Books {
let Some(presented) = presented else {
return Err((
crate::access::UNAUTHORIZED,
"these books need a token: the human makes one with `wanderbooks token create`"
"these books need a token, and this request carried none: the human makes one with `wanderbooks token create` (if one was made, `wanderbooks token check` says whether this computer's keyring still gives it)"
.into(),
));
};
@@ -469,6 +469,15 @@ impl Books {
p.tokens().iter().map(admin::TokenInfo::from).collect(),
))
}),
Request::TokenCheck { book, name, sha256 } => self.with_principals(|p| {
let live = p.tokens().iter().find(|t| t.book == book && t.name == name);
// A hash, not a secret: compared as it is.
let matches = live.is_some_and(|t| t.sha256.eq_ignore_ascii_case(&sha256));
Ok(Response::TokenChecked {
live: live.map(admin::TokenInfo::from),
matches,
})
}),
Request::TokenRevoke { book, name, id } => {
let book = match BookId::parse(&book) {
Ok(id) => id,
@@ -2952,6 +2961,30 @@ mod tests {
assert_eq!(bound(&books), ["s-other"]);
}
/// A token check answers by the SHA-256 asked, for the books and name
/// asked alone.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_token_check_says_whether_the_text_is_the_live_one() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
create(&books, "acme");
let (old_id, old) = token(&books, "acme", "agent");
let (new_id, new) = token(&books, "acme", "agent");
assert_ne!(old_id, new_id);
let check = |book: &str, name: &str, text: &str| match books.admin(Request::TokenCheck {
book: book.into(),
name: name.into(),
sha256: ledger_store::journal::sha256_hex(text.as_bytes()),
}) {
Response::TokenChecked { live, matches } => (live.map(|t| t.id), matches),
other => panic!("{other:?}"),
};
assert_eq!(check("acme", "agent", &new), (Some(new_id.clone()), true));
assert_eq!(check("acme", "agent", &old), (Some(new_id), false));
assert_eq!(check("acme", "other", &new), (None, false));
assert_eq!(check("beta", "agent", &new), (None, false));
}
/// An MCP request to `acme` with `bearer`, on `session` if given.
fn mcp_request(
method: axum::http::Method,