From 19929054993fd6e67ef39e2e5b6ae6e07c8ba2f7 Mon Sep 17 00:00:00 2001 From: koukyosyumei Date: Tue, 13 Oct 2026 21:36:45 -0400 Subject: [PATCH 1/2] o5s-server token: optional lifetime in seconds --- crates/server/src/main.rs | 9 +++++++-- docs/access.md | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index e589467..17c5cc8 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -46,7 +46,7 @@ async fn main() -> Result<(), Box> { std::env::var(k).ok() })?); if matches!(args.get(1).map(String::as_str), Some("token" | "revoke")) { - let usage = "token TENANT USER | revoke TENANT USER [NOT_AFTER]"; + let usage = "token TENANT USER [SECONDS] | revoke TENANT USER [NOT_AFTER]"; let tenant: u64 = args.get(2).ok_or(usage)?.parse()?; let user: u64 = args.get(3).ok_or(usage)?.parse()?; let now = h5i_app_core::Timestamp::now().secs(); @@ -90,7 +90,12 @@ async fn main() -> Result<(), Box> { .map_err(|e| format!("{e:?}"))? .first_valid_expiry(tenant, user), }; - let exp = (now + 86400).max(floor); + // A day unless SECONDS says otherwise. + let lifetime: u64 = match args.get(4) { + Some(s) => s.parse()?, + None => 86400, + }; + let exp = (now + lifetime).max(floor); println!("{}", HmacAuth::new(secret).issue(tenant, user, exp)); return Ok(()); } diff --git a/docs/access.md b/docs/access.md index 2621f10..46885ca 100644 --- a/docs/access.md +++ b/docs/access.md @@ -2,7 +2,7 @@ ## Who a request is -Every request carries `Authorization: Bearer v1....`, an HMAC-SHA256 token signed with `H5I_APP_SECRET`. `o5s-server token ORG USER` issues one that lasts a day. The organization and user come only from the token, never from a request body. A repository belongs to one organization, and no other organization can see it (`Guarantees.isolation`). +Every request carries `Authorization: Bearer v1....`, an HMAC-SHA256 token signed with `H5I_APP_SECRET`. `o5s-server token ORG USER [SECONDS]` issues one that lasts a day, or SECONDS (`604800` for a week). The organization and user come only from the token, never from a request body. A repository belongs to one organization, and no other organization can see it (`Guarantees.isolation`). ## Repository roles @@ -38,7 +38,7 @@ Independence of reviews is unchanged: an author still cannot approve or report c A token names no session, so revocation works at two granularities: - **One token**: `POST /api/tokens/revoke` with `{}` revokes the token presented. A client that suspects a leak revokes its own token. -- **A user**: `POST /api/tokens/revoke` with `{"all":true}` revokes every token of the caller that expires no later than the one presented. An operator runs `o5s-server revoke ORG USER [NOT_AFTER]`, which by default covers every token `o5s-server token` has issued (they last a day). `o5s-server token` then issues tokens that expire after the revocation. +- **A user**: `POST /api/tokens/revoke` with `{"all":true}` revokes every token of the caller that expires no later than the one presented. An operator runs `o5s-server revoke ORG USER [NOT_AFTER]`, which by default covers every token `o5s-server token` has issued for a day; pass NOT_AFTER to cover longer-lived ones. `o5s-server token` then issues tokens that expire after the revocation. Revoked tokens get `401 token revoked` on every route. The local backend keeps revocations in `revocations.json` in its data directory. The server rereads the file when it changes, so `o5s-server revoke` works while the server runs. PostgreSQL keeps them in `o5s_revoked_users` and `o5s_revoked_tokens`, shared by every replica and the Worker. A native server reuses a "not revoked" answer for up to 5 seconds. On Workers, Hyperdrive may cache the lookup for up to its query-cache lifetime, 60 seconds by default. From 541f1f54ffd48379e529cb4a7461c4636079c7c4 Mon Sep 17 00:00:00 2001 From: koukyosyumei Date: Tue, 13 Oct 2026 21:42:33 -0400 Subject: [PATCH 2/2] Read-only tokens: signed with a derived key, refused for anything but GET on both servers --- crates/server/src/auth.rs | 72 ++++++++++++++++++++++++++++++++++---- crates/server/src/lib.rs | 19 ++++++++++ crates/server/src/main.rs | 15 ++++++-- crates/server/tests/api.rs | 44 +++++++++++++++++++++++ crates/worker/src/lib.rs | 32 ++++++++++++++--- docs/access.md | 2 +- 6 files changed, 170 insertions(+), 14 deletions(-) diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index 80520c5..c27ffbe 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -2,8 +2,15 @@ //! sent as `Authorization: Bearer `: `h5i_app_http::HmacAuth`'s format, //! so either server accepts the other's tokens. Parsing is verified in //! `h5i-app-token/proofs`. +//! +//! A read-only token has the same form, signed with a key derived from the +//! secret instead of the secret: it authenticates its user for `GET` +//! requests only (`permits`), and neither kind can be turned into the other. -use axum::http::HeaderMap; +use axum::http::{HeaderMap, Method}; + +/// Derives the key read-only tokens are signed with. +const READ_ONLY: &[u8] = b"o5s read-only token key"; #[derive(Debug)] pub struct AuthError(pub String); @@ -39,6 +46,34 @@ impl HmacAuth { String::from_utf8(token).expect("tokens are ascii") } + /// A token for `user` of `tenant`, valid until `exp`, for `GET` only. + pub fn issue_read_only(&self, tenant: u64, user: u64, exp: u64) -> String { + let payload = h5i_app_token::encode_payload(tenant, user, exp); + let token = h5i_app_token::join(&payload, &tag_with(&read_key(&self.secret), &payload)); + String::from_utf8(token).expect("tokens are ascii") + } + + /// Whether `token` is signed as read-only (valid or not otherwise). + pub fn read_only(&self, token: &str) -> bool { + h5i_app_token::parse(token.as_bytes()).is_some_and(|t| { + std::iter::once(&self.secret) + .chain(&self.previous) + .any(|s| ct_eq(&tag_with(&read_key(s), &t.payload), &t.sig)) + }) + } + + /// Whether a request may proceed: a read-only token may only `GET`. + pub fn permits(&self, method: &Method, headers: &HeaderMap) -> bool { + if method == Method::GET || method == Method::HEAD { + return true; + } + let token = headers + .get("authorization") + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")); + !token.is_some_and(|t| self.read_only(t)) + } + /// The tenant and user of a token valid at `now` (unix seconds). pub fn verify(&self, token: &str, now: u64) -> Result<(u64, u64), AuthError> { self.claims(token, now).map(|(t, u, _)| (t, u)) @@ -48,11 +83,12 @@ impl HmacAuth { pub fn claims(&self, token: &str, now: u64) -> Result<(u64, u64, u64), AuthError> { let err = |m: &str| AuthError(m.to_string()); let t = h5i_app_token::parse(token.as_bytes()).ok_or_else(|| err("malformed token"))?; - let signed = ct_eq(&self.tag(&t.payload), &t.sig) - || self - .previous - .iter() - .any(|old| ct_eq(&tag_with(old, &t.payload), &t.sig)); + let signed = std::iter::once(&self.secret) + .chain(&self.previous) + .any(|s| { + ct_eq(&tag_with(s, &t.payload), &t.sig) + || ct_eq(&tag_with(&read_key(s), &t.payload), &t.sig) + }); if !signed { return Err(err("bad signature")); } @@ -89,6 +125,10 @@ fn tag_with(secret: &[u8], payload: &[u8]) -> Vec { libcrux_hmac::hmac(libcrux_hmac::Algorithm::Sha256, secret, payload, None) } +fn read_key(secret: &[u8]) -> Vec { + tag_with(secret, READ_ONLY) +} + /// Constant-time equality, so the comparison does not leak the tag. fn ct_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { @@ -113,6 +153,26 @@ mod tests { ); } + #[test] + fn read_only_tokens_authenticate_reads_only() { + let auth = HmacAuth::new("s"); + let r = auth.issue_read_only(1, 2, 100); + assert_eq!(auth.verify(&r, 100).unwrap(), (1, 2)); + assert!(auth.read_only(&r)); + assert!(!auth.read_only(&auth.issue(1, 2, 100))); + assert!(!HmacAuth::new("other").read_only(&r)); + let mut headers = HeaderMap::new(); + headers.insert("authorization", format!("Bearer {r}").parse().unwrap()); + assert!(auth.permits(&Method::GET, &headers)); + assert!(!auth.permits(&Method::POST, &headers)); + let full = auth.issue(1, 2, 100); + headers.insert("authorization", format!("Bearer {full}").parse().unwrap()); + assert!(auth.permits(&Method::POST, &headers)); + // Rotation keeps read-only tokens read-only. + let rotated = HmacAuth::new("new").with_previous(vec![b"s".to_vec()]); + assert!(rotated.read_only(&r) && rotated.verify(&r, 0).is_ok()); + } + #[test] fn rotation_accepts_previous_secrets_without_issuing_with_them() { let old = HmacAuth::new("old").issue(1, 2, 100); diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 7ec334e..e103e79 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -72,6 +72,21 @@ impl AppState { } } +/// Refuse every request but `GET` that carries a read-only token. +async fn read_only_guard( + State(state): State, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + if !state.auth.permits(request.method(), request.headers()) { + return json( + StatusCode::FORBIDDEN, + serde_json::json!({"error":"read-only token"}), + ); + } + next.run(request).await +} + pub fn router(state: AppState) -> Router { Router::new() .route("/", get(|| async { Html(include_str!("index.html")) })) @@ -94,6 +109,10 @@ pub fn router(state: AppState) -> Router { state.clone(), revocation::guard, )) + .route_layer(axum::middleware::from_fn_with_state( + state.clone(), + read_only_guard, + )) .route_layer(axum::middleware::from_fn_with_state( state.clone(), metrics::observe, diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 17c5cc8..6fee602 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -46,7 +46,7 @@ async fn main() -> Result<(), Box> { std::env::var(k).ok() })?); if matches!(args.get(1).map(String::as_str), Some("token" | "revoke")) { - let usage = "token TENANT USER [SECONDS] | revoke TENANT USER [NOT_AFTER]"; + let usage = "token TENANT USER [SECONDS] [--read-only] | revoke TENANT USER [NOT_AFTER]"; let tenant: u64 = args.get(2).ok_or(usage)?.parse()?; let user: u64 = args.get(3).ok_or(usage)?.parse()?; let now = h5i_app_core::Timestamp::now().secs(); @@ -91,12 +91,21 @@ async fn main() -> Result<(), Box> { .first_valid_expiry(tenant, user), }; // A day unless SECONDS says otherwise. - let lifetime: u64 = match args.get(4) { + let read_only = args[4..].iter().any(|a| a == "--read-only"); + let lifetime: u64 = match args[4..].iter().find(|a| *a != "--read-only") { Some(s) => s.parse()?, None => 86400, }; let exp = (now + lifetime).max(floor); - println!("{}", HmacAuth::new(secret).issue(tenant, user, exp)); + let auth = HmacAuth::new(secret); + println!( + "{}", + if read_only { + auth.issue_read_only(tenant, user, exp) + } else { + auth.issue(tenant, user, exp) + } + ); return Ok(()); } std::fs::create_dir_all(&root)?; diff --git a/crates/server/tests/api.rs b/crates/server/tests/api.rs index b1dd40d..94bb275 100644 --- a/crates/server/tests/api.rs +++ b/crates/server/tests/api.rs @@ -891,3 +891,47 @@ async fn revoked_tokens_are_refused_everywhere() { StatusCode::UNAUTHORIZED ); } + +#[tokio::test] +async fn read_only_tokens_read_and_change_nothing() { + let dir = tempfile::tempdir().unwrap(); + let s = state(dir.path()); + command(&s, 1, serde_json::json!({"cmd":"create","repo":"demo"})).await; + let viewer = s.auth.issue_read_only(1, 9, u64::MAX); + let (status, v) = with_token(&s, &viewer, "/api/state?repo=demo", None).await; + assert_eq!(status, StatusCode::OK, "{v}"); + let (status, v) = with_token(&s, &viewer, "/api/repositories", None).await; + assert_eq!( + (status, v["repositories"].clone()), + (StatusCode::OK, serde_json::json!(["demo"])) + ); + // An open repository would let any member write; this token still cannot, + // nor create a repository, export, or open a workspace. + for (url, body) in [ + ( + "/api/command", + serde_json::json!({"cmd":"create","repo":"other"}), + ), + ( + "/api/command", + serde_json::json!({"cmd":"fork","repo":"demo","source":"main","channel":"x"}), + ), + ( + "/api/export", + serde_json::json!({"repo":"demo","channel":"main"}), + ), + ( + "/api/worktree", + serde_json::json!({"repo":"demo","channel":"main"}), + ), + ] { + let (status, v) = with_token(&s, &viewer, url, Some(body)).await; + assert_eq!( + (status, v["error"].as_str()), + (StatusCode::FORBIDDEN, Some("read-only token")), + "{url}" + ); + } + let (_, v) = with_token(&s, &viewer, "/api/repositories", None).await; + assert_eq!(v["repositories"], serde_json::json!(["demo"])); +} diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 05425bc..f984494 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -163,6 +163,10 @@ fn router(env: Env, ctx: Context) -> Router { { o5s_platform::limits::set(limits); } + let state = AppState { + env: Arc::new(env), + ctx: Arc::new(ctx), + }; Router::new() .route( "/", @@ -183,10 +187,30 @@ fn router(env: Env, ctx: Context) -> Router { .route("/api/tokens/revoke", post(revoke_token)) .route("/api/worktree", post(worktree)) .route("/api/worktree/propose", post(worktree_propose)) - .with_state(AppState { - env: Arc::new(env), - ctx: Arc::new(ctx), - }) + .route_layer(axum::middleware::from_fn_with_state( + state.clone(), + read_only_guard, + )) + .with_state(state) +} + +/// Refuse every request but `GET` that carries a read-only token. +async fn read_only_guard( + State(s): State, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + let permitted = match s.auth() { + Ok(auth) => auth.permits(request.method(), request.headers()), + Err(e) => return e, + }; + if !permitted { + return json( + StatusCode::FORBIDDEN, + serde_json::json!({"error":"read-only token"}), + ); + } + next.run(request).await } /// Deliver the Git projections admissions left stale: their export after diff --git a/docs/access.md b/docs/access.md index 46885ca..254142d 100644 --- a/docs/access.md +++ b/docs/access.md @@ -2,7 +2,7 @@ ## Who a request is -Every request carries `Authorization: Bearer v1....`, an HMAC-SHA256 token signed with `H5I_APP_SECRET`. `o5s-server token ORG USER [SECONDS]` issues one that lasts a day, or SECONDS (`604800` for a week). The organization and user come only from the token, never from a request body. A repository belongs to one organization, and no other organization can see it (`Guarantees.isolation`). +Every request carries `Authorization: Bearer v1....`, an HMAC-SHA256 token signed with `H5I_APP_SECRET`. `o5s-server token ORG USER [SECONDS] [--read-only]` issues one that lasts a day, or SECONDS (`604800` for a week). A `--read-only` token is signed with a key derived from the secret: it works for `GET` requests only, and both servers refuse anything else with 403 before any handler runs, so it can be shared to show repositories without letting its holder create one. The organization and user come only from the token, never from a request body. A repository belongs to one organization, and no other organization can see it (`Guarantees.isolation`). ## Repository roles