Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 59 additions & 10 deletions crates/gitlawb-node/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ use gitlawb_core::http_sig::{
};
use gitlawb_core::identity::verify;

/// Per-route-group ceiling on how much request body [`require_signature`] will
/// buffer BEFORE it has authenticated anything.
///
/// This is middleware state rather than a constant because a single number cannot
/// serve both a 2 GB git push and a 2 MB profile update: the git write group
/// passes `GITLAWB_MAX_PACK_BYTES`, every other signed group passes
/// [`crate::server::SIGNED_BODY_LIMIT`]. Making it a required piece of state means
/// a new signed route group cannot be wired up without choosing a limit.
#[derive(Clone, Copy, Debug)]
pub struct SignatureBodyLimit(pub usize);

/// Axum middleware that enforces HTTP Signature authentication (RFC 9421).
///
/// Every write request must carry:
Expand All @@ -48,20 +59,52 @@ use gitlawb_core::identity::verify;
/// 4. Resolves the did:key to an Ed25519 VerifyingKey
/// 5. Rebuilds the signing string and verifies the Ed25519 signature
/// 6. Verifies Content-Digest matches the request body
pub async fn require_signature(request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream
pub async fn require_signature(
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
request: Request,
next: Next,
) -> Response {
verify_signature(limit, request, next).await
}

/// The signature check itself, shared by [`require_signature`] and the optional
/// variant. `limit` bounds the pre-auth buffer; see [`SignatureBodyLimit`].
async fn verify_signature(limit: usize, request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream.
//
// The buffer is bounded, because this runs BEFORE any identity check: an
// unauthenticated caller with no DID, no signature and no repo reaches this
// line. Nothing upstream supplies the bound. On the git write routes
// `RequestBodyLimitLayer` sits on the inner router, and a later `.layer()` is
// the OUTER service, so this middleware runs first and sees the raw body. On
// every other signed group axum's 2 MB `DefaultBodyLimit` is enforced by the
// `Bytes`/`Json` extractors through a request extension, which collecting the
// body here bypasses. `Limited` stops reading at the limit rather than reading
// everything and then measuring it, which is the property that matters.
let (parts, body) = request.into_parts();
let body_bytes =
match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => return (
let body_bytes = match http_body_util::Limited::new(body, limit).collect().await {
Ok(collected) => collected.to_bytes(),
// Over the limit: a distinct status from the 401 an unsigned request gets
// and the 400 below, so a client can tell "too big" from "not authenticated"
// and from "your body did not arrive".
Err(e) if e.is::<http_body_util::LengthLimitError>() => return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({
"error": "body_too_large",
"message": format!("request body exceeds the {limit}-byte limit for this route"),
})),
)
.into_response(),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "unreadable_body", "message": "could not read request body" }),
),
)
.into_response(),
};
.into_response()
}
};

let sig_input = parts
.headers
Expand Down Expand Up @@ -248,11 +291,17 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
/// Optional variant for rolling upgrades: verify and inject `AuthenticatedDid` when
/// RFC 9421 signature headers are present, but allow legacy unsigned requests to
/// continue when no signature attempt was made.
pub async fn optional_signature(request: Request, next: Next) -> Response {
pub async fn optional_signature(
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
request: Request,
next: Next,
) -> Response {
let has_signature_headers = request.headers().contains_key("signature-input")
|| request.headers().contains_key("signature");
if has_signature_headers {
return require_signature(request, next).await;
// Same pre-auth buffer, same bound: presenting signature headers is not
// authentication, so this path is as reachable as the required one.
return verify_signature(limit, request, next).await;
}
next.run(request).await
}
Expand Down
67 changes: 58 additions & 9 deletions crates/gitlawb-node/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,37 @@ async fn graphql_playground() -> impl IntoResponse {
))
}

/// Pre-auth body-buffer ceiling for every signed route group except the git write
/// routes. Deliberately equal to axum's `DefaultBodyLimit` (2 MB), which is what
/// these groups' `Bytes`/`Json` extractors already enforce: that default works
/// through a request extension the extractors consult, and `require_signature`
/// collects the raw body itself, so it never saw the cap. Matching the number
/// keeps this change behavior-preserving on the response path (nothing that used
/// to be accepted starts failing) and purely a memory fix.
pub const SIGNED_BODY_LIMIT: usize = 2 * 1024 * 1024;

/// 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<AppState>, state: AppState) -> Router<AppState> {
///
/// `body_limit` bounds the request body `require_signature` buffers before it has
/// authenticated anything (see [`auth::SignatureBodyLimit`]): `max_pack_bytes` for
/// the git write group, [`SIGNED_BODY_LIMIT`] for every other group.
fn add_auth_layers(
router: Router<AppState>,
state: AppState,
body_limit: usize,
) -> Router<AppState> {
router
.layer(middleware::from_fn_with_state(
state,
auth::require_ucan_chain,
))
.layer(middleware::from_fn(auth::require_signature))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(body_limit),
auth::require_signature,
))
}

pub fn build_router(state: AppState) -> Router {
Expand All @@ -63,7 +83,10 @@ pub fn build_router(state: AppState) -> Router {
// Attach the verified DID to /graphql when a signature is present. The
// layer covers only routes added before it, so /graphql/ws (added after,
// read-only subscriptions) stays open.
.layer(middleware::from_fn(auth::optional_signature))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.route_service("/graphql/ws", GraphQLSubscription::new(schema));

// ── Task routes (write — require HTTP Signature) ───────────────────────
Expand All @@ -74,6 +97,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
state.clone(),
SIGNED_BODY_LIMIT,
);

// ── Task routes (read — open) ──────────────────────────────────────────
Expand Down Expand Up @@ -107,6 +131,7 @@ pub fn build_router(state: AppState) -> Router {
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
.layer(axum::Extension(limiter)),
state.clone(),
SIGNED_BODY_LIMIT,
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(create_ip_limiter));
Expand Down Expand Up @@ -181,6 +206,7 @@ pub fn build_router(state: AppState) -> Router {
axum::routing::delete(agents::deregister_agent),
),
state.clone(),
SIGNED_BODY_LIMIT,
);

// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
Expand All @@ -205,6 +231,7 @@ pub fn build_router(state: AppState) -> Router {
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit)),
state.clone(),
pack_limit,
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(push_limiter));
Expand All @@ -216,7 +243,10 @@ pub fn build_router(state: AppState) -> Router {
// stays unsigned — gating the pin index is tracked separately (#121).
let ipfs_routes = Router::new()
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
.layer(middleware::from_fn(auth::optional_signature))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));

// ── Arweave permanent anchors ──────────────────────────────────────────
Expand Down Expand Up @@ -247,6 +277,7 @@ pub fn build_router(state: AppState) -> Router {
post(bounties::dispute_bounty),
),
state.clone(),
SIGNED_BODY_LIMIT,
);

// ── Bounty routes (read — open) ──────────────────────────────────────
Expand All @@ -262,12 +293,16 @@ pub fn build_router(state: AppState) -> Router {
"/api/v1/agents/{did}/bounties",
get(bounties::agent_bounty_stats),
)
.layer(middleware::from_fn(auth::optional_signature));
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));

// ── Profile routes (write — require HTTP Signature) ─────────────────
let profile_write_routes = add_auth_layers(
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
state.clone(),
SIGNED_BODY_LIMIT,
);

// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
Expand All @@ -282,6 +317,7 @@ pub fn build_router(state: AppState) -> Router {
post(issues::create_issue_comment),
),
state.clone(),
SIGNED_BODY_LIMIT,
);

// ── Peer discovery routes ─────────────────────────────────────────────
Expand All @@ -300,6 +336,7 @@ pub fn build_router(state: AppState) -> Router {
let sync_trigger_routes = add_auth_layers(
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
state.clone(),
SIGNED_BODY_LIMIT,
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
Expand All @@ -316,9 +353,12 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/peers/announce", post(peers::announce))
.route("/api/v1/sync/notify", post(peers::notify_sync));
peer_write_routes = if state.config.require_signed_peer_writes {
add_auth_layers(peer_write_routes, state.clone())
add_auth_layers(peer_write_routes, state.clone(), SIGNED_BODY_LIMIT)
} else {
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
peer_write_routes.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
};
let peer_write_routes = peer_write_routes
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
Expand Down Expand Up @@ -417,7 +457,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/v1/repos/{owner}/{repo}/replicas",
get(replicas::list_replicas),
)
.layer(middleware::from_fn(auth::optional_signature));
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));

// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
// large pack responses from the server don't get truncated on the client side.
Expand Down Expand Up @@ -449,7 +492,13 @@ pub fn build_router(state: AppState) -> Router {
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::optional_signature));
// `pack_limit`, not SIGNED_BODY_LIMIT: git-upload-pack POSTs live here and
// the group has deliberately raised its ceiling, so the pre-auth buffer for
// a signed fetch negotiation must match rather than narrow it.
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(pack_limit),
auth::optional_signature,
));

// ── Meta ──────────────────────────────────────────────────────────────
let meta_routes = Router::new()
Expand Down
Loading
Loading