From a7eb0592719d0d34570913f23f7ad0ddb189cb36 Mon Sep 17 00:00:00 2001 From: The-FOOL-00 Date: Fri, 29 May 2026 15:45:07 +0530 Subject: [PATCH 1/7] feat(ucan): validate proof chains and attenuation --- crates/gitlawb-core/src/ucan.rs | 200 ++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/crates/gitlawb-core/src/ucan.rs b/crates/gitlawb-core/src/ucan.rs index 3bce76e..3a43796 100644 --- a/crates/gitlawb-core/src/ucan.rs +++ b/crates/gitlawb-core/src/ucan.rs @@ -41,6 +41,18 @@ impl Capability { self.constraints = Some(constraints); self } + + /// Returns `true` if `self` is a valid attenuation of `parent`. + /// + /// A delegated capability is only valid if it is at most as permissive as + /// the parent capability backing it. `"*"` in either field and `repo/admin` + /// in the action position act as wildcards that cover any value. + pub fn is_attenuated_by(&self, parent: &Capability) -> bool { + let resource_ok = parent.with == self.with || parent.with == "*"; + let action_ok = + parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN; + resource_ok && action_ok + } } /// Well-known gitlawb capability strings. @@ -132,6 +144,26 @@ impl Ucan { } } + /// Check if this UCAN's not-before time is in the future (token not yet valid). + pub fn is_before_valid(&self) -> bool { + if let Some(nbf) = self.payload.nbf { + Utc::now().timestamp() < nbf + } else { + false + } + } + + /// Verify this UCAN's audience matches `expected`. + pub fn verify_audience(&self, expected: &Did) -> Result<()> { + if &self.payload.aud != expected { + return Err(Error::Ucan(format!( + "audience mismatch: expected {expected}, got {}", + self.payload.aud + ))); + } + Ok(()) + } + /// Verify the signature on this UCAN. pub fn verify_signature(&self) -> Result<()> { use crate::identity::verify; @@ -217,6 +249,10 @@ impl Ucan { return Err(Error::Ucan("token is expired".to_string())); } + if self.is_before_valid() { + return Err(Error::Ucan("token is not yet valid".to_string())); + } + for proof_token in &self.payload.prf { let proof = Self::decode(proof_token) .map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?; @@ -229,6 +265,17 @@ impl Ucan { ))); } + // Every delegated capability must be covered by the proof (attenuation). + for cap in &self.payload.att { + let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p)); + if !covered { + return Err(Error::Ucan(format!( + "capability attenuation violated: '{}' on '{}' not covered by proof", + cap.can, cap.with + ))); + } + } + // Verify the proof's signature and chain recursively proof.verify_chain()?; } @@ -399,4 +446,157 @@ mod tests { let err = delegated.verify_chain().unwrap_err(); assert!(err.to_string().contains("expired")); } + + #[test] + fn is_before_valid_future_nbf() { + let issuer = Keypair::generate(); + let audience = Keypair::generate().did(); + let nbf_future = chrono::Utc::now() + chrono::Duration::hours(1); + + let payload = UcanPayload { + ucan: "1.0.0".to_string(), + iss: issuer.did(), + aud: audience, + att: vec![], + exp: None, + nbf: Some(nbf_future.timestamp()), + prf: vec![], + }; + let signing_bytes = serde_json::to_vec(&payload).unwrap(); + let sig = issuer.sign_b64(&signing_bytes); + let ucan = Ucan { payload, s: sig }; + + assert!(ucan.is_before_valid()); + let err = ucan.verify_chain().unwrap_err(); + assert!(err.to_string().contains("not yet valid")); + } + + #[test] + fn is_before_valid_past_nbf() { + let issuer = Keypair::generate(); + let audience = Keypair::generate().did(); + let nbf_past = chrono::Utc::now() - chrono::Duration::hours(1); + + let payload = UcanPayload { + ucan: "1.0.0".to_string(), + iss: issuer.did(), + aud: audience, + att: vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)], + exp: None, + nbf: Some(nbf_past.timestamp()), + prf: vec![], + }; + let signing_bytes = serde_json::to_vec(&payload).unwrap(); + let sig = issuer.sign_b64(&signing_bytes); + let ucan = Ucan { payload, s: sig }; + + assert!(!ucan.is_before_valid()); + ucan.verify_chain().unwrap(); + } + + #[test] + fn verify_audience_matches() { + let issuer = Keypair::generate(); + let audience = Keypair::generate().did(); + let ucan = Ucan::issue(&issuer, audience.clone(), vec![], None).unwrap(); + ucan.verify_audience(&audience).unwrap(); + } + + #[test] + fn verify_audience_mismatch() { + let issuer = Keypair::generate(); + let audience = Keypair::generate().did(); + let wrong = Keypair::generate().did(); + let ucan = Ucan::issue(&issuer, audience, vec![], None).unwrap(); + let err = ucan.verify_audience(&wrong).unwrap_err(); + assert!(err.to_string().contains("audience mismatch")); + } + + #[test] + fn attenuation_valid_subset() { + let alice = Keypair::generate(); + let bob = Keypair::generate(); + let charlie = Keypair::generate(); + + // Alice grants Bob push on a specific repo + let root = Ucan::issue( + &alice, + bob.did(), + vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)], + None, + ) + .unwrap(); + + // Bob delegates the same capability (exact subset) to Charlie + let delegated = Ucan::delegate( + &bob, + charlie.did(), + vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)], + None, + &root, + ) + .unwrap(); + + delegated.verify_chain().unwrap(); + } + + #[test] + fn attenuation_exceeds_parent_is_rejected() { + let alice = Keypair::generate(); + let bob = Keypair::generate(); + let charlie = Keypair::generate(); + + // Alice grants Bob push on one repo only + let root = Ucan::issue( + &alice, + bob.did(), + vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)], + None, + ) + .unwrap(); + + // Bob tries to delegate merge (not in the original grant) to Charlie + let delegated = Ucan::delegate( + &bob, + charlie.did(), + vec![Capability::new("gitlawb://repos/org/repo", caps::PR_MERGE)], + None, + &root, + ) + .unwrap(); + + let err = delegated.verify_chain().unwrap_err(); + assert!(err.to_string().contains("attenuation violated")); + } + + #[test] + fn attenuation_repo_admin_covers_all() { + let alice = Keypair::generate(); + let bob = Keypair::generate(); + let charlie = Keypair::generate(); + + // Alice grants Bob repo/admin (superpower) + let root = Ucan::issue( + &alice, + bob.did(), + vec![Capability::new( + "gitlawb://repos/org/repo", + caps::REPO_ADMIN, + )], + None, + ) + .unwrap(); + + // Bob delegates a more specific capability — covered by repo/admin + let delegated = Ucan::delegate( + &bob, + charlie.did(), + vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)], + None, + &root, + ) + .unwrap(); + + delegated.verify_chain().unwrap(); + } } From d2b8b6e6488ec6e76573f491b397e52df00899eb Mon Sep 17 00:00:00 2001 From: The-FOOL-00 Date: Fri, 29 May 2026 15:45:12 +0530 Subject: [PATCH 2/7] feat(auth): validate UCAN chain in middleware --- crates/gitlawb-node/src/auth/mod.rs | 213 +++++++++++++++++++++++++++- 1 file changed, 212 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index c0c9aa0..5910e5c 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -1,5 +1,5 @@ use axum::body::Body; -use axum::extract::Request; +use axum::extract::{Request, State}; use axum::http::StatusCode; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; @@ -8,6 +8,11 @@ use http_body_util::BodyExt; use serde_json::json; use std::collections::HashMap; +use gitlawb_core::did::Did; +use gitlawb_core::ucan::Ucan; + +use crate::state::AppState; + /// The authenticated agent's DID, injected into request extensions by `require_signature`. #[derive(Clone, Debug)] pub struct AuthenticatedDid(pub String); @@ -240,6 +245,114 @@ pub async fn optional_signature(request: Request, next: Next) -> Response { next.run(request).await } +/// Validate a raw UCAN token string supplied in `X-Ucan`. +/// +/// Checks performed: +/// 1. The token decodes to a valid [`Ucan`] structure. +/// 2. The UCAN issuer (`iss`) matches `signer_did` — the DID that signed the +/// HTTP request — preventing replay of another agent's UCAN. +/// 3. The UCAN audience (`aud`) matches `expected_aud` — the node's own DID. +/// 4. The full proof chain is cryptographically valid (signatures, expiry, +/// not-before, chain linkage, and capability attenuation). +fn validate_ucan_chain( + token: &str, + expected_aud: &Did, + signer_did: &Did, +) -> Result<(), (StatusCode, Json)> { + let ucan = Ucan::decode(token).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid_ucan", "message": e.to_string() })), + ) + })?; + + if &ucan.payload.iss != signer_did { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "invalid_ucan", + "message": format!( + "UCAN issuer {} does not match request signer {}", + ucan.payload.iss, signer_did + ), + })), + )); + } + + ucan.verify_audience(expected_aud).map_err(|e| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid_ucan", "message": e.to_string() })), + ) + })?; + + ucan.verify_chain().map_err(|e| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid_ucan", "message": e.to_string() })), + ) + })?; + + Ok(()) +} + +/// Axum middleware that validates a UCAN chain when `X-Ucan` is present. +/// +/// Must be layered so that it runs after [`require_signature`], which sets the +/// [`AuthenticatedDid`] extension consumed here. +/// +/// When `X-Ucan` is absent the request passes through unchanged, preserving +/// backward compatibility for agents that pre-date UCAN delegation. When the +/// header is present the full chain is validated: the UCAN issuer must match +/// the HTTP Signature identity, the audience must be this node's DID, and +/// every proof in the chain must be cryptographically sound with no capability +/// escalation. +pub async fn require_ucan_chain( + State(state): State, + request: Request, + next: Next, +) -> Response { + let token = match request + .headers() + .get("x-ucan") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + { + Some(t) => t, + None => return next.run(request).await, + }; + + let signer_did: Did = match request.extensions().get::() { + Some(a) => match a.0.parse() { + Ok(did) => did, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal_error", "message": e.to_string() })), + ) + .into_response() + } + }, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "invalid_ucan", + "message": "UCAN validation requires a valid HTTP Signature", + })), + ) + .into_response() + } + }; + + if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) { + return (status, body).into_response(); + } + + tracing::info!(did = %signer_did, "✓ UCAN chain validated"); + next.run(request).await +} + fn human_detected(message: &str) -> impl IntoResponse { ( StatusCode::UNAUTHORIZED, @@ -258,3 +371,101 @@ fn human_detected(message: &str) -> impl IntoResponse { })), ) } + +#[cfg(test)] +mod tests { + use super::*; + use gitlawb_core::identity::Keypair; + use gitlawb_core::ucan::{caps, Capability, Ucan}; + + fn bootstrap_ucan(node: &Keypair, agent_did: Did) -> Ucan { + Ucan::bootstrap(node, agent_did).unwrap() + } + + fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan { + Ucan::delegate( + agent, + node_did, + vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)], + None, + proof, + ) + .unwrap() + } + + #[test] + fn validate_ucan_chain_valid() { + let node = Keypair::generate(); + let agent = Keypair::generate(); + let node_did = node.did(); + let agent_did = agent.did(); + + let proof = bootstrap_ucan(&node, agent_did.clone()); + let delegation = delegation_ucan(&agent, node_did.clone(), &proof); + let token = delegation.encode().unwrap(); + + assert!(validate_ucan_chain(&token, &node_did, &agent_did).is_ok()); + } + + #[test] + fn validate_ucan_chain_wrong_issuer() { + let node = Keypair::generate(); + let agent = Keypair::generate(); + let other = Keypair::generate(); + let node_did = node.did(); + let agent_did = agent.did(); + + let proof = bootstrap_ucan(&node, agent_did.clone()); + let delegation = delegation_ucan(&agent, node_did.clone(), &proof); + let token = delegation.encode().unwrap(); + + // signer_did is `other` but UCAN iss is `agent` — must be rejected + let err = validate_ucan_chain(&token, &node_did, &other.did()).unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + let body = err.1 .0.to_string(); + assert!(body.contains("does not match request signer")); + } + + #[test] + fn validate_ucan_chain_wrong_audience() { + let node = Keypair::generate(); + let agent = Keypair::generate(); + let other_node = Keypair::generate(); + let node_did = node.did(); + let agent_did = agent.did(); + + let proof = bootstrap_ucan(&node, agent_did.clone()); + let delegation = delegation_ucan(&agent, node_did.clone(), &proof); + let token = delegation.encode().unwrap(); + + // expected_aud is a different node — must be rejected + let err = validate_ucan_chain(&token, &other_node.did(), &agent_did).unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + let body = err.1 .0.to_string(); + assert!(body.contains("audience mismatch")); + } + + #[test] + fn validate_ucan_chain_expired_proof() { + let node = Keypair::generate(); + let agent = Keypair::generate(); + let node_did = node.did(); + let agent_did = agent.did(); + + let exp = chrono::Utc::now() - chrono::Duration::hours(1); + let proof = Ucan::issue( + &node, + agent_did.clone(), + vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)], + Some(exp), + ) + .unwrap(); + let delegation = delegation_ucan(&agent, node_did.clone(), &proof); + let token = delegation.encode().unwrap(); + + let err = validate_ucan_chain(&token, &node_did, &agent_did).unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + let body = err.1 .0.to_string(); + assert!(body.contains("expired")); + } +} From 0044a57e66165f722385762c2286b8f26c3c4d3f Mon Sep 17 00:00:00 2001 From: The-FOOL-00 Date: Fri, 29 May 2026 15:45:18 +0530 Subject: [PATCH 3/7] feat(server): enforce UCAN chain on write routes --- crates/gitlawb-node/src/server.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 59bc69d..f93df22 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -44,6 +44,10 @@ pub fn build_router(state: AppState) -> Router { .route("/api/v1/tasks/{id}/claim", post(tasks::claim_task)) .route("/api/v1/tasks/{id}/complete", post(tasks::complete_task)) .route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_ucan_chain, + )) .layer(middleware::from_fn(auth::require_signature)); // ── Task routes (read — open) ────────────────────────────────────────── @@ -64,6 +68,10 @@ pub fn build_router(state: AppState) -> Router { .route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr)) .layer(middleware::from_fn(rate_limit::rate_limit_by_did)) .layer(axum::Extension(limiter)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_ucan_chain, + )) .layer(middleware::from_fn(auth::require_signature)); // ── Write routes — require HTTP Signature (no rate limit) ───────────── @@ -124,6 +132,10 @@ pub fn build_router(state: AppState) -> Router { "/api/v1/repos/{owner}/{repo}/labels/{label}", axum::routing::delete(labels::remove_label), ) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_ucan_chain, + )) .layer(middleware::from_fn(auth::require_signature)); // Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git @@ -138,6 +150,10 @@ pub fn build_router(state: AppState) -> Router { ) .layer(DefaultBodyLimit::disable()) .layer(RequestBodyLimitLayer::new(pack_limit)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_ucan_chain, + )) .layer(middleware::from_fn(auth::require_signature)); // ── IPFS content-addressed retrieval and pin listing ────────────────── @@ -171,6 +187,10 @@ pub fn build_router(state: AppState) -> Router { "/api/v1/bounties/{id}/dispute", post(bounties::dispute_bounty), ) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_ucan_chain, + )) .layer(middleware::from_fn(auth::require_signature)); // ── Bounty routes (read — open) ────────────────────────────────────── @@ -197,6 +217,10 @@ pub fn build_router(state: AppState) -> Router { "/api/v1/repos/{owner}/{repo}/issues/{id}/comments", post(issues::create_issue_comment), ) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_ucan_chain, + )) .layer(middleware::from_fn(auth::require_signature)); // ── Peer discovery routes ───────────────────────────────────────────── @@ -211,7 +235,12 @@ pub fn build_router(state: AppState) -> Router { .route("/api/v1/sync/trigger", post(peers::trigger_sync)) .route("/api/v1/sync/notify", post(peers::notify_sync)); peer_write_routes = if state.config.require_signed_peer_writes { - peer_write_routes.layer(middleware::from_fn(auth::require_signature)) + peer_write_routes + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_ucan_chain, + )) + .layer(middleware::from_fn(auth::require_signature)) } else { peer_write_routes.layer(middleware::from_fn(auth::optional_signature)) }; From 57e8f34e0e43ef30915ffa34261ea63189684c25 Mon Sep 17 00:00:00 2001 From: The-FOOL-00 Date: Fri, 29 May 2026 16:47:21 +0530 Subject: [PATCH 4/7] docs(ucan): clarify wildcard attenuation semantics in doc comment --- crates/gitlawb-core/src/ucan.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-core/src/ucan.rs b/crates/gitlawb-core/src/ucan.rs index 3a43796..5fff7b5 100644 --- a/crates/gitlawb-core/src/ucan.rs +++ b/crates/gitlawb-core/src/ucan.rs @@ -45,8 +45,10 @@ impl Capability { /// Returns `true` if `self` is a valid attenuation of `parent`. /// /// A delegated capability is only valid if it is at most as permissive as - /// the parent capability backing it. `"*"` in either field and `repo/admin` - /// in the action position act as wildcards that cover any value. + /// the parent capability backing it. `"*"` on the **parent**'s resource or + /// action field and `repo/admin` in the parent's action position act as + /// wildcards that cover any delegated value; wildcards on `self` carry no + /// special meaning. pub fn is_attenuated_by(&self, parent: &Capability) -> bool { let resource_ok = parent.with == self.with || parent.with == "*"; let action_ok = From 24436c6c3ffe4331636e44848a3318ba7cf2e976 Mon Sep 17 00:00:00 2001 From: The-FOOL-00 Date: Fri, 29 May 2026 16:47:30 +0530 Subject: [PATCH 5/7] test(node): add test-only constructors for Db and RepoStore --- crates/gitlawb-node/src/db/mod.rs | 5 +++++ crates/gitlawb-node/src/git/repo_store.rs | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 6690025..2e67755 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -190,6 +190,11 @@ impl Db { &self.pool } + #[cfg(test)] + pub fn for_testing(pool: PgPool) -> Self { + Self { pool } + } + pub async fn connect(database_url: &str) -> Result { let pool = PgPool::connect(database_url).await?; let db = Self { pool }; diff --git a/crates/gitlawb-node/src/git/repo_store.rs b/crates/gitlawb-node/src/git/repo_store.rs index 496cf06..deab29c 100644 --- a/crates/gitlawb-node/src/git/repo_store.rs +++ b/crates/gitlawb-node/src/git/repo_store.rs @@ -33,6 +33,16 @@ pub struct RepoStore { } impl RepoStore { + #[cfg(test)] + pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self { + Self { + repos_dir, + tigris: None, + pool, + migrated: Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())), + } + } + pub fn new(repos_dir: PathBuf, tigris: Option, pool: PgPool) -> Self { Self { repos_dir, From 16782c38309c32cf19641b891fe4edeb04c19168 Mon Sep 17 00:00:00 2001 From: The-FOOL-00 Date: Fri, 29 May 2026 16:47:37 +0530 Subject: [PATCH 6/7] fix(auth): harden require_ucan_chain middleware, lower log level, and add tests --- crates/gitlawb-node/src/auth/mod.rs | 134 ++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index 5910e5c..a428cca 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -261,7 +261,7 @@ fn validate_ucan_chain( ) -> Result<(), (StatusCode, Json)> { let ucan = Ucan::decode(token).map_err(|e| { ( - StatusCode::BAD_REQUEST, + StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid_ucan", "message": e.to_string() })), ) })?; @@ -326,11 +326,12 @@ pub async fn require_ucan_chain( Some(a) => match a.0.parse() { Ok(did) => did, Err(e) => { + tracing::warn!(raw_did = %a.0, err = %e, "failed to parse DID from authenticated identity"); return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "internal_error", "message": e.to_string() })), + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid_identity", "message": "invalid DID in token" })), ) - .into_response() + .into_response(); } }, None => { @@ -349,7 +350,7 @@ pub async fn require_ucan_chain( return (status, body).into_response(); } - tracing::info!(did = %signer_did, "✓ UCAN chain validated"); + tracing::debug!(did = %signer_did, "UCAN chain validated"); next.run(request).await } @@ -375,8 +376,11 @@ fn human_detected(message: &str) -> impl IntoResponse { #[cfg(test)] mod tests { use super::*; + use axum::{middleware, Router}; use gitlawb_core::identity::Keypair; use gitlawb_core::ucan::{caps, Capability, Ucan}; + use std::{path::PathBuf, sync::Arc, time::Duration}; + use tower::ServiceExt; fn bootstrap_ucan(node: &Keypair, agent_did: Did) -> Ucan { Ucan::bootstrap(node, agent_did).unwrap() @@ -468,4 +472,124 @@ mod tests { let body = err.1 .0.to_string(); assert!(body.contains("expired")); } + + fn make_test_state(node_did: gitlawb_core::did::Did) -> crate::state::AppState { + use crate::{config::Config, graphql, rate_limit::RateLimiter}; + use clap::Parser; + + let keypair = Keypair::generate(); + let (ref_tx, _) = tokio::sync::broadcast::channel(1); + let (task_tx, _) = tokio::sync::broadcast::channel(1); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = Arc::new(crate::db::Db::for_testing(pool.clone())); + let schema = Arc::new(graphql::build_schema( + db.clone(), + ref_tx.clone(), + task_tx.clone(), + )); + crate::state::AppState { + config: Arc::new(Config::parse_from(["gitlawb-node"])), + db, + node_did, + node_keypair: Arc::new(keypair), + p2p: None, + http_client: Arc::new(reqwest::Client::new()), + ref_update_tx: ref_tx, + task_event_tx: task_tx, + graphql_schema: schema, + machine_id: None, + repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool), + rate_limiter: RateLimiter::new(100, Duration::from_secs(60)), + } + } + + #[tokio::test] + async fn require_ucan_chain_no_header_passes_through() { + let state = make_test_state(Keypair::generate().did()); + let app = Router::new() + .route("/", axum::routing::get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state(state, require_ucan_chain)); + + let req = Request::builder() + .uri("/") + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn require_ucan_chain_missing_did_returns_401() { + let state = make_test_state(Keypair::generate().did()); + let app = Router::new() + .route("/", axum::routing::get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state(state, require_ucan_chain)); + + // x-ucan present but no AuthenticatedDid extension → 401 + let req = Request::builder() + .uri("/") + .header("x-ucan", "any-token") + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn require_ucan_chain_wrong_issuer_returns_401() { + let node = Keypair::generate(); + let agent = Keypair::generate(); + let other = Keypair::generate(); + let node_did = node.did(); + let agent_did = agent.did(); + + // Build a valid token where iss = agent, but supply `other` as the signer. + let proof = bootstrap_ucan(&node, agent_did.clone()); + let token = delegation_ucan(&agent, node_did.clone(), &proof) + .encode() + .unwrap(); + + let state = make_test_state(node_did); + let app = Router::new() + .route("/", axum::routing::get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state(state, require_ucan_chain)); + + // AuthenticatedDid is `other`, UCAN iss is `agent` → issuer mismatch → 401 + let req = Request::builder() + .uri("/") + .header("x-ucan", token) + .extension(AuthenticatedDid(other.did().to_string())) + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn require_ucan_chain_malformed_token_returns_401() { + let state = make_test_state(Keypair::generate().did()); + let app = Router::new() + .route("/", axum::routing::get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state(state, require_ucan_chain)); + + // Malformed x-ucan (invalid JSON) + let req = Request::builder() + .uri("/") + .header("x-ucan", "invalid-token-structure") + .extension(AuthenticatedDid(Keypair::generate().did().to_string())) + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let body_bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap(); + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body_json["error"], "invalid_ucan"); + } } From 0ae30efdedc787e59425424f66bb5cb3cd38994d Mon Sep 17 00:00:00 2001 From: The-FOOL-00 Date: Fri, 29 May 2026 16:47:45 +0530 Subject: [PATCH 7/7] refactor(server): centralize and deduplicate auth middleware layering --- crates/gitlawb-node/src/server.rs | 290 +++++++++++++++--------------- 1 file changed, 143 insertions(+), 147 deletions(-) diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index f93df22..3468f25 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -31,6 +31,19 @@ async fn graphql_playground() -> impl IntoResponse { )) } +/// Applies the standard auth middleware pair to a router: HTTP Signature verification +/// followed by UCAN chain validation. The two layers run in this order for every +/// matched request: `require_signature` first (sets `AuthenticatedDid`), then +/// `require_ucan_chain` (reads it). +fn add_auth_layers(router: Router, state: AppState) -> Router { + router + .layer(middleware::from_fn_with_state( + state, + auth::require_ucan_chain, + )) + .layer(middleware::from_fn(auth::require_signature)) +} + pub fn build_router(state: AppState) -> Router { // ── GraphQL routes ───────────────────────────────────────────────────── let schema = state.graphql_schema.as_ref().clone(); @@ -39,16 +52,14 @@ pub fn build_router(state: AppState) -> Router { .route_service("/graphql/ws", GraphQLSubscription::new(schema)); // ── Task routes (write — require HTTP Signature) ─────────────────────── - let task_write_routes = Router::new() - .route("/api/v1/tasks", post(tasks::create_task)) - .route("/api/v1/tasks/{id}/claim", post(tasks::claim_task)) - .route("/api/v1/tasks/{id}/complete", post(tasks::complete_task)) - .route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_ucan_chain, - )) - .layer(middleware::from_fn(auth::require_signature)); + let task_write_routes = add_auth_layers( + Router::new() + .route("/api/v1/tasks", post(tasks::create_task)) + .route("/api/v1/tasks/{id}/claim", post(tasks::claim_task)) + .route("/api/v1/tasks/{id}/complete", post(tasks::complete_task)) + .route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)), + state.clone(), + ); // ── Task routes (read — open) ────────────────────────────────────────── let task_read_routes = Router::new() @@ -57,104 +68,98 @@ pub fn build_router(state: AppState) -> Router { // ── Rate-limited creation routes — require HTTP Signature + per-DID throttle let limiter = state.rate_limiter.clone(); - let creation_routes = Router::new() - .route("/api/v1/repos", post(repos::create_repo)) - .route("/api/register", post(register::register)) - .route("/api/v1/repos/{owner}/{repo}/fork", post(repos::fork_repo)) - .route( - "/api/v1/repos/{owner}/{repo}/issues", - post(issues::create_issue), - ) - .route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr)) - .layer(middleware::from_fn(rate_limit::rate_limit_by_did)) - .layer(axum::Extension(limiter)) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_ucan_chain, - )) - .layer(middleware::from_fn(auth::require_signature)); + let creation_routes = add_auth_layers( + Router::new() + .route("/api/v1/repos", post(repos::create_repo)) + .route("/api/register", post(register::register)) + .route("/api/v1/repos/{owner}/{repo}/fork", post(repos::fork_repo)) + .route( + "/api/v1/repos/{owner}/{repo}/issues", + post(issues::create_issue), + ) + .route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr)) + .layer(middleware::from_fn(rate_limit::rate_limit_by_did)) + .layer(axum::Extension(limiter)), + state.clone(), + ); // ── Write routes — require HTTP Signature (no rate limit) ───────────── - let write_routes = Router::new() - .route( - "/api/v1/repos/{owner}/{repo}/pulls/{number}/merge", - post(pulls::merge_pr), - ) - .route( - "/api/v1/repos/{owner}/{repo}/pulls/{number}/close", - post(pulls::close_pr), - ) - .route( - "/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews", - post(pulls::create_review), - ) - .route( - "/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", - post(pulls::create_comment), - ) - .route( - "/api/v1/repos/{owner}/{repo}/hooks", - post(webhooks::create_webhook), - ) - .route( - "/api/v1/repos/{owner}/{repo}/hooks/{id}", - axum::routing::delete(webhooks::delete_webhook), - ) - .route( - "/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", - post(protect::protect_branch), - ) - .route( - "/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", - axum::routing::delete(protect::unprotect_branch), - ) - .route( - "/api/v1/repos/{owner}/{repo}/star", - axum::routing::put(stars::star_repo), - ) - .route( - "/api/v1/repos/{owner}/{repo}/star", - axum::routing::delete(stars::unstar_repo), - ) - .route( - "/api/v1/repos/{owner}/{repo}/replicas", - axum::routing::put(replicas::register_replica), - ) - .route( - "/api/v1/repos/{owner}/{repo}/replicas", - axum::routing::delete(replicas::unregister_replica), - ) - .route( - "/api/v1/repos/{owner}/{repo}/labels", - post(labels::add_label), - ) - .route( - "/api/v1/repos/{owner}/{repo}/labels/{label}", - axum::routing::delete(labels::remove_label), - ) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_ucan_chain, - )) - .layer(middleware::from_fn(auth::require_signature)); + let write_routes = add_auth_layers( + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/merge", + post(pulls::merge_pr), + ) + .route( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/close", + post(pulls::close_pr), + ) + .route( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews", + post(pulls::create_review), + ) + .route( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", + post(pulls::create_comment), + ) + .route( + "/api/v1/repos/{owner}/{repo}/hooks", + post(webhooks::create_webhook), + ) + .route( + "/api/v1/repos/{owner}/{repo}/hooks/{id}", + axum::routing::delete(webhooks::delete_webhook), + ) + .route( + "/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", + post(protect::protect_branch), + ) + .route( + "/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", + axum::routing::delete(protect::unprotect_branch), + ) + .route( + "/api/v1/repos/{owner}/{repo}/star", + axum::routing::put(stars::star_repo), + ) + .route( + "/api/v1/repos/{owner}/{repo}/star", + axum::routing::delete(stars::unstar_repo), + ) + .route( + "/api/v1/repos/{owner}/{repo}/replicas", + axum::routing::put(replicas::register_replica), + ) + .route( + "/api/v1/repos/{owner}/{repo}/replicas", + axum::routing::delete(replicas::unregister_replica), + ) + .route( + "/api/v1/repos/{owner}/{repo}/labels", + post(labels::add_label), + ) + .route( + "/api/v1/repos/{owner}/{repo}/labels/{label}", + axum::routing::delete(labels::remove_label), + ), + state.clone(), + ); // Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git // routes only — all other API routes keep axum's default 2 MB cap. // HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb // helper signs requests with RFC 9421 signatures using the agent's keypair. let pack_limit = state.config.max_pack_bytes; - let git_write_routes = Router::new() - .route( - "/{owner}/{repo}/git-receive-pack", - post(repos::git_receive_pack), - ) - .layer(DefaultBodyLimit::disable()) - .layer(RequestBodyLimitLayer::new(pack_limit)) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_ucan_chain, - )) - .layer(middleware::from_fn(auth::require_signature)); + let git_write_routes = add_auth_layers( + Router::new() + .route( + "/{owner}/{repo}/git-receive-pack", + post(repos::git_receive_pack), + ) + .layer(DefaultBodyLimit::disable()) + .layer(RequestBodyLimitLayer::new(pack_limit)), + state.clone(), + ); // ── IPFS content-addressed retrieval and pin listing ────────────────── let ipfs_routes = Router::new() @@ -165,33 +170,31 @@ pub fn build_router(state: AppState) -> Router { let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors)); // ── Bounty routes (write — require HTTP Signature) ───────────────── - let bounty_write_routes = Router::new() - .route( - "/api/v1/repos/{owner}/{repo}/bounties", - post(bounties::create_bounty), - ) - .route("/api/v1/bounties/{id}/claim", post(bounties::claim_bounty)) - .route( - "/api/v1/bounties/{id}/submit", - post(bounties::submit_bounty), - ) - .route( - "/api/v1/bounties/{id}/approve", - post(bounties::approve_bounty), - ) - .route( - "/api/v1/bounties/{id}/cancel", - post(bounties::cancel_bounty), - ) - .route( - "/api/v1/bounties/{id}/dispute", - post(bounties::dispute_bounty), - ) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_ucan_chain, - )) - .layer(middleware::from_fn(auth::require_signature)); + let bounty_write_routes = add_auth_layers( + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/bounties", + post(bounties::create_bounty), + ) + .route("/api/v1/bounties/{id}/claim", post(bounties::claim_bounty)) + .route( + "/api/v1/bounties/{id}/submit", + post(bounties::submit_bounty), + ) + .route( + "/api/v1/bounties/{id}/approve", + post(bounties::approve_bounty), + ) + .route( + "/api/v1/bounties/{id}/cancel", + post(bounties::cancel_bounty), + ) + .route( + "/api/v1/bounties/{id}/dispute", + post(bounties::dispute_bounty), + ), + state.clone(), + ); // ── Bounty routes (read — open) ────────────────────────────────────── let bounty_read_routes = Router::new() @@ -208,20 +211,18 @@ pub fn build_router(state: AppState) -> Router { ); // ── Issue routes (write — require HTTP Signature, no rate limit) ───── - let issue_write_routes = Router::new() - .route( - "/api/v1/repos/{owner}/{repo}/issues/{id}/close", - post(issues::close_issue), - ) - .route( - "/api/v1/repos/{owner}/{repo}/issues/{id}/comments", - post(issues::create_issue_comment), - ) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_ucan_chain, - )) - .layer(middleware::from_fn(auth::require_signature)); + let issue_write_routes = add_auth_layers( + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/issues/{id}/close", + post(issues::close_issue), + ) + .route( + "/api/v1/repos/{owner}/{repo}/issues/{id}/comments", + post(issues::create_issue_comment), + ), + state.clone(), + ); // ── Peer discovery routes ───────────────────────────────────────────── // Peer writes accept signatures when present and can require them after a @@ -235,12 +236,7 @@ pub fn build_router(state: AppState) -> Router { .route("/api/v1/sync/trigger", post(peers::trigger_sync)) .route("/api/v1/sync/notify", post(peers::notify_sync)); peer_write_routes = if state.config.require_signed_peer_writes { - peer_write_routes - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_ucan_chain, - )) - .layer(middleware::from_fn(auth::require_signature)) + add_auth_layers(peer_write_routes, state.clone()) } else { peer_write_routes.layer(middleware::from_fn(auth::optional_signature)) };