Tokens: one keyring item per token, read back; the bridge says a locked keyring; token check
check / check (push) Canceled after 0s
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:
9 files changed
+871
-95
No files matched your search
@@ -26,7 +26,7 @@
|
||||
//! comments are in the preview.
|
||||
//! 6. A token's text is in exactly one answer, TokenCreated, to the
|
||||
//! request that made it; nothing else carries it (auth.rs
|
||||
//! INVARIANT 1).
|
||||
//! INVARIANT 1): a check (TokenCheck) asks by the text's SHA-256.
|
||||
//! 7. A format upgrade is asked with the owner passphrase, which the
|
||||
//! server checks before anything is done (plan 3.9, D-39); no answer
|
||||
//! carries it, and Debug never shows it.
|
||||
@@ -144,6 +144,17 @@ pub enum Request {
|
||||
},
|
||||
/// The live tokens.
|
||||
TokenList,
|
||||
/// Whether a token kept elsewhere (this computer's keyring) is the
|
||||
/// live one of its books and name: asked by its SHA-256 alone, never
|
||||
/// its text (INVARIANT 6).
|
||||
TokenCheck {
|
||||
/// Its books.
|
||||
book: String,
|
||||
/// Its name.
|
||||
name: String,
|
||||
/// The hex SHA-256 of the text kept.
|
||||
sha256: String,
|
||||
},
|
||||
/// The owner's go-ahead for the format upgrade of books waiting for
|
||||
/// one (plan 3.9, D-39): a verified backup with the keys, then this
|
||||
/// version's journal format declared. The host sends it, for the
|
||||
@@ -455,6 +466,15 @@ pub enum Response {
|
||||
Tokens(Vec<TokenInfo>),
|
||||
/// A token ended.
|
||||
TokenRevoked(TokenInfo),
|
||||
/// What a token check found: the books' live token of that name, if
|
||||
/// any, and whether the text asked about is it.
|
||||
TokenChecked {
|
||||
/// The live token of that books and name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
live: Option<TokenInfo>,
|
||||
/// The text asked about is that live token's.
|
||||
matches: bool,
|
||||
},
|
||||
/// A format upgrade made (or none needed).
|
||||
FormatUpgraded(FormatUpgraded),
|
||||
/// Refused, with why (no path in the data root: INVARIANT 3).
|
||||
@@ -497,7 +517,7 @@ pub fn call_within(
|
||||
let not_sent = |why: String| CallError::NotSent(why);
|
||||
// A list changes nothing: its only question is whether it answered.
|
||||
let look = match request {
|
||||
Request::List | Request::TokenList => None,
|
||||
Request::List | Request::TokenList | Request::TokenCheck { .. } => None,
|
||||
Request::Create { .. } | Request::Adopt { .. } | Request::Restore { .. } => {
|
||||
Some("`wanderbooks books list` shows the books there are")
|
||||
}
|
||||
@@ -793,6 +813,24 @@ mod tests {
|
||||
assert!(shown.contains("text: TokenText(…)"), "{shown}");
|
||||
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap();
|
||||
assert_eq!(r.request, Request::TokenList);
|
||||
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_check",
|
||||
"book": "acme", "name": "agent", "sha256": "ab"}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r.request,
|
||||
Request::TokenCheck {
|
||||
book: "acme".into(),
|
||||
name: "agent".into(),
|
||||
sha256: "ab".into()
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<Envelope>(
|
||||
json!({"proto": 1, "op": "token_check", "book": "acme", "name": "agent", "text": "wb_x"})
|
||||
)
|
||||
.is_err(),
|
||||
"a check carries no text"
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<Envelope>(
|
||||
json!({"proto": 1, "op": "token_revoke", "book": "acme"})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user