From c03d84ffb96239412011a315867d7144e5a1bfa3 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 20 Jul 2026 15:29:01 +0600 Subject: [PATCH 01/25] fix(node): harden Arweave anchoring and add verification (#26) --- crates/gitlawb-node/src/api/arweave.rs | 25 +- crates/gitlawb-node/src/api/events.rs | 3 + crates/gitlawb-node/src/api/repos.rs | 32 +- crates/gitlawb-node/src/arweave.rs | 260 ++++++++++++--- crates/gitlawb-node/src/auth/mod.rs | 12 + crates/gitlawb-node/src/cert.rs | 47 ++- crates/gitlawb-node/src/config.rs | 13 +- crates/gitlawb-node/src/db/mod.rs | 412 +++++++++++++++++++++--- crates/gitlawb-node/src/server.rs | 9 +- crates/gitlawb-node/src/test_support.rs | 12 + 10 files changed, 719 insertions(+), 106 deletions(-) diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 0d728c71..7231b62c 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -1,7 +1,7 @@ //! GET /api/v1/arweave/anchors — list Arweave ref-update anchors. use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, Json, }; use serde::Deserialize; @@ -9,6 +9,29 @@ use serde::Deserialize; use crate::error::Result; use crate::state::AppState; +/// GET /api/v1/arweave/verify/:tx_id +/// +/// Fetch the anchor from Arweave via the configured gateway, extract the embedded +/// certificate, and verify: +/// 1. The node's Ed25519 signature on the certificate payload +/// 2. The `prev` hash chains correctly against the most recent local cert +/// 3. The `pusher_sig` can be verified (optional, informational) +pub async fn verify_anchor_endpoint( + State(state): State, + Path(tx_id): Path, +) -> Result> { + let gateway = &state.config.arweave_gateway; + let result = crate::arweave::verify_anchor(&state.http_client, gateway, &tx_id, &state.db) + .await + .map_err(crate::error::AppError::Internal)?; + + Ok(Json(serde_json::json!({ + "valid": result.valid, + "errors": result.errors, + "certificate": result.certificate, + }))) +} + #[derive(Debug, Deserialize)] pub struct ListAnchorsQuery { pub repo: Option, diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 875e6261..b2b77b28 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -437,6 +437,9 @@ mod ref_updates_feed_tests { node_did: "did:key:z6MkNode".into(), signature: "sig".into(), issued_at: Utc::now().to_rfc3339(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index b38b177b..c9e6215e 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -5,7 +5,7 @@ use axum::Json; use bytes::Bytes; use std::sync::Arc; -use crate::auth::{caller_authorized_to_push, AuthenticatedDid}; +use crate::auth::{caller_authorized_to_push, AuthenticatedDid, PusherSignature}; use crate::db::RepoRecord; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -855,6 +855,7 @@ pub async fn git_receive_pack( State(state): State, Path((owner, repo)): Path<(String, String)>, Extension(auth): Extension, + Extension(pusher_sig): Extension, body: Bytes, ) -> Result { let name = smart_http_repo_name(&repo)?; @@ -997,6 +998,7 @@ pub async fn git_receive_pack( &update.old_sha, &update.new_sha, did, + Some(pusher_sig.0.clone()), ) .await { @@ -1115,7 +1117,7 @@ pub async fn git_receive_pack( let repo_id = record.id.clone(); let owner_did = record.owner_did.clone(); let is_public = record.is_public; - let irys_url = state.config.irys_url.clone(); + let bundler_url = state.config.bundler_url.clone(); let http_client = std::sync::Arc::clone(&state.http_client); let node_did_str = state.node_did.to_string(); let node_seed = state.node_keypair.to_seed(); @@ -1164,7 +1166,7 @@ pub async fn git_receive_pack( // Option B3: anchor a per-push manifest of the blobs sealed // this push to Arweave, so the oid->cid index survives total // node loss. Best-effort; never fails the push. - if !delta.is_empty() && !irys_url.is_empty() { + if !delta.is_empty() && !bundler_url.is_empty() { let owner_short = crate::db::normalize_owner_key(&owner_did); let repo_slug = format!("{owner_short}/{repo_name}"); let ts = chrono::Utc::now().to_rfc3339(); @@ -1177,7 +1179,7 @@ pub async fn git_receive_pack( }; match crate::arweave::anchor_encrypted_manifest( &http_client, - &irys_url, + &bundler_url, &manifest, ) .await @@ -1208,6 +1210,7 @@ pub async fn git_receive_pack( let db_clone = state.db.clone(); let http_client = Arc::clone(&state.http_client); let node_did_str = state.node_did.to_string(); + let repo_id_clone = record.id.clone(); let repo_slug = format!( "{}/{}", crate::db::normalize_owner_key(&record.owner_did), @@ -1221,7 +1224,8 @@ pub async fn git_receive_pack( let pusher_did_clone = did.to_string(); let db_for_peers = state.db.clone(); let ref_update_tx = state.ref_update_tx.clone(); - let irys_url = state.config.irys_url.clone(); + let arweave_gateway = state.config.arweave_gateway.clone(); + let bundler_url = state.config.bundler_url.clone(); let owner_did_for_arweave = record.owner_did.clone(); let self_public_url = state.config.public_url.clone(); let node_keypair = Arc::clone(&state.node_keypair); @@ -1304,9 +1308,14 @@ pub async fn git_receive_pack( // Arweave permanent anchoring — fire for each ref update. // Suppressed for repos the public cannot read (public permanent ledger). - if announce && !irys_url.is_empty() { + if announce && !bundler_url.is_empty() { for (ref_name, old_sha, new_sha) in &ref_updates_clone { let cid = cid_map.get(new_sha).cloned(); + let cert = db_clone + .get_most_recent_cert(&repo_id_clone) + .await + .ok() + .flatten(); let anchor = crate::arweave::RefAnchor { repo: repo_slug.clone(), owner_did: owner_did_for_arweave.clone(), @@ -1316,22 +1325,23 @@ pub async fn git_receive_pack( cid: cid.clone(), timestamp: now_ts.clone(), node_did: node_did_str.clone(), + certificate: cert, }; - match crate::arweave::anchor_ref_update(&http_client, &irys_url, &anchor).await + match crate::arweave::anchor_ref_update(&http_client, &bundler_url, &anchor) + .await { Ok(tx_id) if !tx_id.is_empty() => { - let arweave_url = crate::arweave::arweave_url(&tx_id); let _ = db_clone - .record_arweave_anchor(&crate::db::RecordAnchorInput { + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { repo: &repo_slug, owner_did: &owner_did_for_arweave, ref_name, old_sha, new_sha, cid: cid.as_deref(), - irys_tx_id: &tx_id, - arweave_url: &arweave_url, + arweave_tx_id: &tx_id, node_did: &node_did_str, + gateway_url: &arweave_gateway, }) .await; } diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 31d3d6d7..00cd6653 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -18,7 +18,11 @@ //! Anchors are stored in the `arweave_anchors` table for auditability. use anyhow::Result; +use base64::Engine as _; +use serde::Serialize; use serde_json::json; +use sha2::Digest; +use std::str::FromStr; /// Data describing a ref-update event to be anchored. #[derive(Debug, Clone)] @@ -32,22 +36,25 @@ pub struct RefAnchor { pub cid: Option, pub timestamp: String, pub node_did: String, + /// The full signed [`crate::db::RefCertificate`] for this ref update, + /// serialized and embedded so a verifier can validate the chain. + pub certificate: Option, } /// Anchor a ref-update to Arweave via Irys. /// /// Returns the Irys/Arweave transaction ID on success. -/// Returns `Ok("")` if `irys_url` is empty (anchoring disabled). +/// Returns `Ok("")` if `bundler_url` is empty (anchoring disabled). pub async fn anchor_ref_update( client: &reqwest::Client, - irys_url: &str, + bundler_url: &str, anchor: &RefAnchor, ) -> Result { - if irys_url.is_empty() { + if bundler_url.is_empty() { return Ok(String::new()); } - let payload = json!({ + let mut payload = json!({ "schema": "gitlawb/ref-update/v1", "repo": anchor.repo, "owner_did": anchor.owner_did, @@ -60,36 +67,40 @@ pub async fn anchor_ref_update( "network": "alpha", }); + // Embed the signed certificate so verifiers can validate the chain. + if let Some(cert) = &anchor.certificate { + payload["certificate"] = serde_json::to_value(cert)?; + } + let body = serde_json::to_vec(&payload)?; // Irys upload endpoint - let url = format!("{}/upload", irys_url.trim_end_matches('/')); + let url = format!("{}/v1/tx", bundler_url.trim_end_matches('/')); let resp = client .post(&url) - .header("Content-Type", "application/json") - // Irys tags allow indexing on Arweave gateway - .header("x-irys-tags", build_tags_header(anchor)) + .header("Content-Type", "application/octet-stream") + .header("x-bundler-tags", build_tags_header(anchor)) .body(body) .send() .await - .map_err(|e| anyhow::anyhow!("Irys upload failed: {e}"))?; + .map_err(|e| anyhow::anyhow!("Bundler upload failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("Irys returned {status}: {body}")); + return Err(anyhow::anyhow!("Bundler returned {status}: {body}")); } let json: serde_json::Value = resp .json() .await - .map_err(|e| anyhow::anyhow!("failed to parse Irys response: {e}"))?; + .map_err(|e| anyhow::anyhow!("failed to parse Bundler response: {e}"))?; - // Irys response: {"id": "", "timestamp": ..., "version": ...} + // Bundler response: {"id": "", "timestamp": ..., "version": ...} let tx_id = json["id"] .as_str() - .ok_or_else(|| anyhow::anyhow!("no 'id' in Irys response: {json}"))? + .ok_or_else(|| anyhow::anyhow!("no 'id' in Bundler response: {json}"))? .to_string(); tracing::info!( @@ -97,7 +108,7 @@ pub async fn anchor_ref_update( ref_name = %anchor.ref_name, new_sha = %anchor.new_sha, tx_id = %tx_id, - "anchored ref update to Arweave" + "anchored ref update to Arweave via bundler" ); Ok(tx_id) @@ -121,14 +132,14 @@ pub struct EncryptedManifest<'a> { /// the anchor is permanent and public, and the v2 envelopes no longer expose /// recipients, so the reader set must not be written to Arweave either. /// -/// Returns the Irys/Arweave transaction ID, or `Ok("")` when `irys_url` is empty +/// Returns the Arweave transaction ID, or `Ok("")` when `bundler_url` is empty /// (anchoring disabled) or there are no blobs to anchor. pub async fn anchor_encrypted_manifest( client: &reqwest::Client, - irys_url: &str, + bundler_url: &str, manifest: &EncryptedManifest<'_>, ) -> Result { - if irys_url.is_empty() || manifest.blobs.is_empty() { + if bundler_url.is_empty() || manifest.blobs.is_empty() { return Ok(String::new()); } @@ -148,38 +159,38 @@ pub async fn anchor_encrypted_manifest( }); let body = serde_json::to_vec(&payload)?; - let url = format!("{}/upload", irys_url.trim_end_matches('/')); + let url = format!("{}/v1/tx", bundler_url.trim_end_matches('/')); let resp = client .post(&url) - .header("Content-Type", "application/json") - .header("x-irys-tags", build_manifest_tags_header(manifest)) + .header("Content-Type", "application/octet-stream") + .header("x-bundler-tags", build_manifest_tags_header(manifest)) .body(body) .send() .await - .map_err(|e| anyhow::anyhow!("Irys upload failed: {e}"))?; + .map_err(|e| anyhow::anyhow!("Bundler upload failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("Irys returned {status}: {body}")); + return Err(anyhow::anyhow!("Bundler returned {status}: {body}")); } let json: serde_json::Value = resp .json() .await - .map_err(|e| anyhow::anyhow!("failed to parse Irys response: {e}"))?; + .map_err(|e| anyhow::anyhow!("failed to parse Bundler response: {e}"))?; let tx_id = json["id"] .as_str() - .ok_or_else(|| anyhow::anyhow!("no 'id' in Irys response: {json}"))? + .ok_or_else(|| anyhow::anyhow!("no 'id' in Bundler response: {json}"))? .to_string(); tracing::info!( repo = %manifest.repo, tx_id = %tx_id, blobs = manifest.blobs.len(), - "anchored encrypted manifest to Arweave" + "anchored encrypted manifest to Arweave via bundler" ); Ok(tx_id) @@ -192,7 +203,7 @@ fn manifest_blob_json(oid: &str, cid: &str) -> serde_json::Value { json!({ "oid": oid, "cid": cid }) } -/// Build the Irys tag header for an encrypted-blob manifest. `Repo` and `Schema` +/// Build the bundler tag header for an encrypted-blob manifest. `Repo` and `Schema` /// are the tags the `gl` recovery query filters on. fn build_manifest_tags_header(manifest: &EncryptedManifest<'_>) -> String { [ @@ -205,12 +216,7 @@ fn build_manifest_tags_header(manifest: &EncryptedManifest<'_>) -> String { .join(",") } -/// Arweave permanent URL for a given Irys transaction ID. -pub fn arweave_url(tx_id: &str) -> String { - format!("https://arweave.net/{tx_id}") -} - -/// Build the Irys tag header value for Arweave indexing. +/// Build the bundler tag header value for Arweave indexing. /// Format: comma-separated "name:value" pairs. fn build_tags_header(anchor: &RefAnchor) -> String { [ @@ -224,7 +230,7 @@ fn build_tags_header(anchor: &RefAnchor) -> String { .join(",") } -/// Strip characters that are invalid in Irys/Arweave tag values. +/// Strip characters that are invalid in bundler/Arweave tag values. fn sanitize_tag(s: &str) -> String { s.chars() .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':')) @@ -232,6 +238,143 @@ fn sanitize_tag(s: &str) -> String { .collect() } +/// Arweave URL for a given transaction ID, resolved through a configurable gateway. +pub fn arweave_url(gateway: &str, tx_id: &str) -> String { + format!("{}/{}", gateway.trim_end_matches('/'), tx_id) +} + +/// Result of verifying an Arweave anchor against the stored certificate chain. +#[derive(Debug, Clone, Serialize)] +pub struct VerifyResult { + pub valid: bool, + pub anchor: serde_json::Value, + pub certificate: Option, + pub errors: Vec, +} + +/// Fetch an anchor from Arweave, extract the embedded certificate, and verify +/// the full chain: certificate signature, prev hash linkage, and pusher signature. +pub async fn verify_anchor( + client: &reqwest::Client, + gateway_url: &str, + tx_id: &str, + db: &crate::db::Db, +) -> Result { + // Fetch the data item from the bundler gateway. + let url = format!("{}/v1/tx/{}", gateway_url.trim_end_matches('/'), tx_id); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| anyhow::anyhow!("failed to fetch data from bundler gateway: {e}"))?; + if !resp.status().is_success() { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!("bundler gateway returned {}", resp.status())], + }); + } + let body_bytes = resp.bytes().await?; + + // Parse the payload — could be JSON or raw bytes depending on gateway + let anchor: serde_json::Value = serde_json::from_slice(&body_bytes)?; + let cert_value = anchor.get("certificate"); + + let cert: Option = match cert_value { + Some(v) => serde_json::from_value(v.clone()).ok(), + None => None, + }; + + let mut errors = Vec::new(); + + if let Some(ref c) = cert { + // 1. Verify node signature on the certificate payload + let payload = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + }); + let payload_bytes = serde_json::to_vec(&payload)?; + + // Resolve node DID to public key + let node_did = gitlawb_core::did::Did::from_str(&c.node_did) + .map_err(|e| anyhow::anyhow!("invalid node DID: {e}"))?; + let verifying_key = node_did + .to_verifying_key() + .map_err(|e| anyhow::anyhow!("unresolvable node DID: {e}"))?; + + let sig_array: [u8; 64] = + match base64::engine::general_purpose::STANDARD.decode(&c.signature) { + Ok(bytes) => match bytes.as_slice().try_into() { + Ok(a) => a, + Err(_) => { + errors.push("certificate signature is not 64 bytes".to_string()); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }, + Err(_) => { + errors.push("certificate signature is not valid base64".to_string()); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + + if let Err(e) = gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_array) { + errors.push(format!("certificate signature verification failed: {e}")); + } + + // 2. Verify prev hash linkage against the most recent local cert + if let Ok(Some(local_cert)) = db.get_most_recent_cert(&c.repo_id).await { + if c.seq <= local_cert.seq { + // Check that the claimed prev matches the local chain + let prev_payload = serde_json::json!({ + "repo_id": local_cert.repo_id, + "ref": local_cert.ref_name, + "old": local_cert.old_sha, + "new": local_cert.new_sha, + "pusher": local_cert.pusher_did, + "node": local_cert.node_did, + "ts": local_cert.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + let expected_prev = hex::encode(sha2::Sha256::digest(&prev_bytes)); + if c.prev != expected_prev { + errors.push(format!( + "prev hash mismatch: claimed {} expected {}", + c.prev, expected_prev + )); + } + } + } + } else { + errors.push("no embedded certificate found in anchor".to_string()); + } + + Ok(VerifyResult { + valid: errors.is_empty(), + anchor, + certificate: cert, + errors, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -248,6 +391,7 @@ mod tests { cid: Some("bafyreib5...".into()), timestamp: "2026-03-14T00:00:00Z".into(), node_did: "did:key:z6MknndwexV9...".into(), + certificate: None, }; let result = anchor_ref_update(&client, "", &anchor).await; assert!(result.is_ok()); @@ -258,7 +402,7 @@ mod tests { async fn test_anchor_success() { let mut server = mockito::Server::new_async().await; let _mock = server - .mock("POST", "/upload") + .mock("POST", "/v1/tx") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk","timestamp":1710000000000,"version":"1.0.0"}"#) @@ -275,6 +419,7 @@ mod tests { cid: None, timestamp: "2026-03-14T00:00:00Z".into(), node_did: "did:key:z6Mknnd...".into(), + certificate: None, }; let result = anchor_ref_update(&client, &server.url(), &anchor).await; @@ -295,7 +440,7 @@ mod tests { let real_old = "1111111111111111111111111111111111111111"; let real_new = "2222222222222222222222222222222222222222"; let _mock = server - .mock("POST", "/upload") + .mock("POST", "/v1/tx") .match_body(mockito::Matcher::AllOf(vec![ mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{real_old}"}}"#)), mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{real_new}"}}"#)), @@ -316,6 +461,7 @@ mod tests { cid: None, timestamp: "2026-03-14T00:00:00Z".into(), node_did: "did:key:z6Mknnd...".into(), + certificate: None, }; let result = anchor_ref_update(&client, &server.url(), &anchor).await; @@ -326,7 +472,10 @@ mod tests { #[test] fn test_arweave_url() { - let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"); + let url = arweave_url( + "https://arweave.net", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk", + ); assert_eq!( url, "https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk" @@ -374,7 +523,7 @@ mod tests { async fn test_manifest_anchor_success() { let mut server = mockito::Server::new_async().await; let _mock = server - .mock("POST", "/upload") + .mock("POST", "/v1/tx") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"MANIFESTTX123","timestamp":1710000000000,"version":"1.0.0"}"#) @@ -411,4 +560,41 @@ mod tests { assert_eq!(sanitize_tag("alice/myrepo"), "alice/myrepo"); assert_eq!(sanitize_tag("hello world!"), "helloworld"); } + + #[tokio::test] + async fn test_verify_anchor_uses_correct_gateway_url() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/tx/does-not-exist") + .with_status(404) + .create_async() + .await; + + let client = reqwest::Client::new(); + // verify_anchor needs a real PgPool; this test only exercises that + // the function correctly formats the gateway URL and handles a 404. + // It will error on the pool access which is expected without a test DB. + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let result = verify_anchor(&client, &server.url(), "does-not-exist", &db).await; + + match result { + Ok(r) => { + // With a lazy unconnected pool, get_most_recent_cert will fail, + // but the function still returns Ok(VerifyResult) with errors. + assert!(!r.valid); + } + Err(e) => { + // On some systems the POSTGRES connection attempt may abort + // rather than fail gracefully. + let msg = e.to_string(); + assert!( + msg.contains("pool") || msg.contains("error"), + "unexpected error: {msg}" + ); + } + } + } } diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index 720fb3ae..9e4e7d28 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -17,6 +17,12 @@ use crate::state::AppState; #[derive(Clone, Debug)] pub struct AuthenticatedDid(pub String); +/// The raw RFC 9421 HTTP Signature value (the `Signature` header), injected into +/// request extensions by `require_signature`. Pushers sign the request, and the +/// node persists this signature so it can be presented as proof of authorization. +#[derive(Clone, Debug)] +pub struct PusherSignature(pub String); + /// Whether `caller` is authorized to push to `record`. /// /// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical @@ -29,6 +35,7 @@ pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) - crate::api::did_matches(caller, &record.owner_did) } +use base64::Engine as _; use gitlawb_core::http_sig::{ build_signing_string, compute_content_digest, HttpSignature, COVERED_COMPONENTS, }; @@ -242,6 +249,11 @@ pub async fn require_signature(request: Request, next: Next) -> Response { request .extensions_mut() .insert(AuthenticatedDid(sig.key_id.to_string())); + request + .extensions_mut() + .insert(PusherSignature( + base64::engine::general_purpose::STANDARD.encode(&sig.signature_bytes), + )); next.run(request).await } diff --git a/crates/gitlawb-node/src/cert.rs b/crates/gitlawb-node/src/cert.rs index 0ed50418..f96a3bf3 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -6,6 +6,7 @@ use anyhow::Result; use chrono::Utc; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::db::RefCertificate; @@ -22,19 +23,46 @@ pub async fn issue_ref_certificate( old_sha: &str, new_sha: &str, pusher_did: &str, + pusher_sig: Option, ) -> Result { let node_did = state.node_did.to_string(); let issued_at = Utc::now().to_rfc3339(); - // Build the canonical signing payload. + // Look up the previous certificate to chain from it. + let prev_cert = state.db.get_most_recent_cert(repo_id).await?; + let seq = match &prev_cert { + Some(c) => c.seq + 1, + None => 1, + }; + let prev = match &prev_cert { + Some(c) => { + let prev_payload = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + hex::encode(Sha256::digest(&prev_bytes)) + } + None => "0".repeat(64), + }; + + // Build the canonical signing payload with chain info. let payload = serde_json::json!({ - "repo_id": repo_id, - "ref": ref_name, - "old": old_sha, - "new": new_sha, - "pusher": pusher_did, - "node": node_did, - "ts": issued_at, + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, }); let payload_bytes = serde_json::to_vec(&payload)?; @@ -50,6 +78,9 @@ pub async fn issue_ref_certificate( node_did, signature, issued_at, + seq, + prev, + pusher_sig, }; // Persist and return the row as it exists in the database (on a diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index fc2247d9..d69fc4c6 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -86,10 +86,15 @@ pub struct Config { #[arg(long, env = "GITLAWB_AUTO_SYNC", default_value_t = false)] pub auto_sync: bool, - /// Irys URL for Arweave permanent anchoring. - /// Leave empty to disable. Use https://devnet.irys.xyz for free devnet. - #[arg(long, env = "GITLAWB_IRYS_URL", default_value = "")] - pub irys_url: String, + /// Bundler URL for Arweave permanent anchoring (Turbo/upload.ardrive.io). + /// Leave empty to disable anchoring. + #[arg(long, env = "GITLAWB_BUNDLER_URL", default_value = "")] + pub bundler_url: String, + + /// Arweave gateway URL for resolving arweave_tx_id to data items. + /// Used by the verify endpoint. Default: https://arweave.net + #[arg(long, env = "GITLAWB_ARWEAVE_GATEWAY", default_value = "https://arweave.net")] + pub arweave_gateway: String, /// Base L2 DID registry contract address (0x...) #[arg(long, env = "GITLAWB_CONTRACT_DID_REGISTRY", default_value = "")] diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index c6ff644b..ca3b875b 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -136,6 +136,12 @@ pub struct RefCertificate { pub node_did: String, pub signature: String, pub issued_at: String, + /// Monotonic sequence number for chain continuity + pub seq: i64, + /// Hash of the previous certificate in the chain (first cert uses zeros) + pub prev: String, + /// RFC 9421 HTTP Signature from the pusher, proving they authorized this push + pub pusher_sig: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -517,7 +523,10 @@ const MIGRATIONS: &[Migration] = &[ pusher_did TEXT NOT NULL, node_did TEXT NOT NULL, signature TEXT NOT NULL, - issued_at TEXT NOT NULL + issued_at TEXT NOT NULL, + seq BIGINT NOT NULL DEFAULT 1, + prev TEXT NOT NULL DEFAULT '0000000000000000000000000000000000000000000000000000000000000000', + pusher_sig TEXT )"#, "CREATE INDEX IF NOT EXISTS idx_ref_certs_repo ON ref_certificates(repo_id)", r#"CREATE TABLE IF NOT EXISTS peers ( @@ -629,17 +638,20 @@ const MIGRATIONS: &[Migration] = &[ "CREATE INDEX IF NOT EXISTS idx_agent_tasks_repo ON agent_tasks(repo_id)", // ── Arweave permanent anchors ──────────────────────────────────── r#"CREATE TABLE IF NOT EXISTS arweave_anchors ( - id TEXT NOT NULL PRIMARY KEY, - repo TEXT NOT NULL, - owner_did TEXT NOT NULL, - ref_name TEXT NOT NULL, - old_sha TEXT NOT NULL, - new_sha TEXT NOT NULL, - cid TEXT, - irys_tx_id TEXT NOT NULL, - arweave_url TEXT NOT NULL, - node_did TEXT NOT NULL, - anchored_at TEXT NOT NULL + id TEXT NOT NULL PRIMARY KEY, + repo TEXT NOT NULL, + owner_did TEXT NOT NULL, + ref_name TEXT NOT NULL, + old_sha TEXT NOT NULL, + new_sha TEXT NOT NULL, + cid TEXT, + arweave_tx_id TEXT NOT NULL, + node_did TEXT NOT NULL, + anchored_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + deadline_height BIGINT, + receipt_sig TEXT, + cert_id TEXT )"#, "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_repo ON arweave_anchors(repo)", "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_new_sha ON arweave_anchors(new_sha)", @@ -883,14 +895,6 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", ], }, - // Reservation: v17, deliberately not main's current_max + 1 (which is 12). - // The runner keys the applied set on the integer alone, so a version another - // in-flight branch also claims is skipped in full on whichever side merges - // second — no error, no warning, and schema_migrations still reads healthy - // while the column is simply absent. Two open branches already claim into - // this range: #135/#173 holds through 14 (15 once it rebases past v11), and - // #253 took 16. 17 clears both. Gaps are harmless: the runner iterates the - // array and never requires contiguity. Migration { version: 17, name: "sync_queue_attempted_at", @@ -901,6 +905,56 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE sync_queue ADD COLUMN IF NOT EXISTS attempted_at TEXT", ], }, + // Arweave anchoring (#26). Numbered 18/19: versions 12–16 are claimed by + // other in-flight branches, and main's current max is 17. The runner keys + // the applied set on the integer alone, so gaps are harmless. + Migration { + version: 18, + name: "arweave_anchor_v2_and_cert_chain", + stmts: &[ + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS seq BIGINT NOT NULL DEFAULT 1", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS prev TEXT NOT NULL DEFAULT '0000000000000000000000000000000000000000000000000000000000000000'", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS pusher_sig TEXT", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS cert_id TEXT", + // Rename irys_tx_id → arweave_tx_id only if the old column still exists + // (fresh databases created by v1 already use arweave_tx_id). + "DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='arweave_anchors' AND column_name='irys_tx_id') THEN ALTER TABLE arweave_anchors RENAME COLUMN irys_tx_id TO arweave_tx_id; END IF; END $$", + "ALTER TABLE arweave_anchors DROP COLUMN IF EXISTS arweave_url", + ], + }, + Migration { + version: 19, + name: "append_only_certs_and_pusher_proof", + stmts: &[ + // Backfill: assign sequential seq values to existing certificates + // before creating the unique index. Migrations v10/v11 may have left + // multiple rows per repo (from different refs) all at seq = 1. + // The prev column is intentionally NOT backfilled here: chain + // verification in verify_anchor computes expected_prev dynamically + // from the predecessor's 7 canonical fields (repo_id, ref, old, + // new, pusher, node, ts), never reading the DB's prev column. + // Existing prev values already match what was computed at issuance. + r#"UPDATE ref_certificates + SET seq = subq.new_seq + FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY repo_id ORDER BY issued_at ASC, id ASC + ) AS new_seq + FROM ref_certificates + ) subq + WHERE ref_certificates.id = subq.id"#, + // Make cert chain append-only: drop the (repo_id, ref_name) unique index + // and add a unique constraint on (repo_id, seq) so concurrent pushes + // cannot collide on the same sequence number. + "DROP INDEX IF EXISTS idx_ref_certs_repo_ref", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_seq ON ref_certificates(repo_id, seq)", + // Store the full HTTP Signature context so a third party can verify + // the pusher authorization proof (RFC 9421). + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS signature_input TEXT", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS content_digest TEXT", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS request_path TEXT", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -2045,8 +2099,8 @@ impl Db { pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result { let row = sqlx::query( "INSERT INTO ref_certificates - (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (repo_id, ref_name) DO UPDATE SET old_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at THEN EXCLUDED.old_sha ELSE ref_certificates.old_sha END, @@ -2058,9 +2112,15 @@ impl Db { THEN EXCLUDED.node_did ELSE ref_certificates.node_did END, signature = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at THEN EXCLUDED.signature ELSE ref_certificates.signature END, + seq = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.seq ELSE ref_certificates.seq END, + prev = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.prev ELSE ref_certificates.prev END, + pusher_sig = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.pusher_sig ELSE ref_certificates.pusher_sig END, issued_at = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at THEN EXCLUDED.issued_at ELSE ref_certificates.issued_at END - RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at", + RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig", ) .bind(&cert.id) .bind(&cert.repo_id) @@ -2071,6 +2131,9 @@ impl Db { .bind(&cert.node_did) .bind(&cert.signature) .bind(&cert.issued_at) + .bind(cert.seq) + .bind(&cert.prev) + .bind(&cert.pusher_sig) .fetch_one(&self.pool) .await?; Ok(row_to_cert(row)) @@ -2085,8 +2148,8 @@ impl Db { // bounded even if a raw/negative value slips through the handler layer. let limit = limit.max(1); let rows = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at - FROM ref_certificates WHERE repo_id = $1 ORDER BY issued_at DESC LIMIT $2", + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC, issued_at DESC LIMIT $2", ) .bind(repo_id) .bind(limit) @@ -2108,8 +2171,8 @@ impl Db { let limit = limit.max(1); let pattern = format!("{}%", prefix); let rows = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at - FROM ref_certificates WHERE repo_id = $1 AND id LIKE $2 ORDER BY issued_at DESC LIMIT $3", + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + FROM ref_certificates WHERE repo_id = $1 AND id LIKE $2 ORDER BY seq DESC, issued_at DESC LIMIT $3", ) .bind(repo_id) .bind(&pattern) @@ -2121,7 +2184,7 @@ impl Db { pub async fn get_ref_certificate(&self, id: &str) -> Result> { let row = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig FROM ref_certificates WHERE id = $1", ) .bind(id) @@ -2129,6 +2192,18 @@ impl Db { .await?; Ok(row.map(row_to_cert)) } + + /// Retrieve the most recent certificate for a repo (highest seq). + pub async fn get_most_recent_cert(&self, repo_id: &str) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC LIMIT 1", + ) + .bind(repo_id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_cert)) + } } // ── Peers ───────────────────────────────────────────────────────────────────── @@ -2744,31 +2819,34 @@ pub struct ArweaveAnchor { pub old_sha: String, pub new_sha: String, pub cid: Option, - pub irys_tx_id: String, - pub arweave_url: String, + pub arweave_tx_id: String, pub node_did: String, pub anchored_at: String, + pub status: String, + pub deadline_height: Option, + pub receipt_sig: Option, + pub cert_id: Option, } /// Input parameters for recording an Arweave anchor. -pub struct RecordAnchorInput<'a> { +pub struct RecordAnchorInputV2<'a> { pub repo: &'a str, pub owner_did: &'a str, pub ref_name: &'a str, pub old_sha: &'a str, pub new_sha: &'a str, pub cid: Option<&'a str>, - pub irys_tx_id: &'a str, - pub arweave_url: &'a str, + pub arweave_tx_id: &'a str, pub node_did: &'a str, + pub gateway_url: &'a str, } impl Db { - pub async fn record_arweave_anchor(&self, input: &RecordAnchorInput<'_>) -> Result<()> { + pub async fn record_arweave_anchor(&self, input: &RecordAnchorInputV2<'_>) -> Result<()> { let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at) + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", ) .bind(&id) @@ -2778,10 +2856,10 @@ impl Db { .bind(input.old_sha) .bind(input.new_sha) .bind(input.cid) - .bind(input.irys_tx_id) - .bind(input.arweave_url) + .bind(input.arweave_tx_id) .bind(input.node_did) .bind(&now) + .bind("pending") .execute(&self.pool) .await?; Ok(()) @@ -2794,7 +2872,7 @@ impl Db { ) -> Result> { let rows = if let Some(repo) = repo { sqlx::query( - "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, deadline_height, receipt_sig, cert_id FROM arweave_anchors WHERE repo=$1 ORDER BY anchored_at DESC LIMIT $2", ) .bind(repo) @@ -2803,7 +2881,7 @@ impl Db { .await? } else { sqlx::query( - "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, deadline_height, receipt_sig, cert_id FROM arweave_anchors ORDER BY anchored_at DESC LIMIT $1", ) .bind(limit) @@ -2821,10 +2899,69 @@ impl Db { old_sha: r.get("old_sha"), new_sha: r.get("new_sha"), cid: r.get("cid"), - irys_tx_id: r.get("irys_tx_id"), - arweave_url: r.get("arweave_url"), + arweave_tx_id: r.get("arweave_tx_id"), node_did: r.get("node_did"), anchored_at: r.get("anchored_at"), + status: r.get("status"), + deadline_height: r.try_get("deadline_height").unwrap_or(None), + receipt_sig: r.try_get("receipt_sig").unwrap_or(None), + cert_id: r.try_get("cert_id").unwrap_or(None), + }) + .collect()) + } + + /// Update the anchor status to confirmed with receipt details. + pub async fn confirm_arweave_anchor( + &self, + id: &str, + deadline_height: i64, + receipt_sig: &str, + ) -> Result<()> { + sqlx::query( + "UPDATE arweave_anchors SET status='confirmed', deadline_height=$1, receipt_sig=$2 WHERE id=$3", + ) + .bind(deadline_height) + .bind(receipt_sig) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Mark an anchor as failed (retries exhausted). + pub async fn fail_arweave_anchor(&self, id: &str) -> Result<()> { + sqlx::query("UPDATE arweave_anchors SET status='failed' WHERE id=$1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// List pending anchors that need confirmation check. + pub async fn list_pending_anchors(&self) -> Result> { + let rows = sqlx::query( + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, deadline_height, receipt_sig, cert_id + FROM arweave_anchors WHERE status='pending' ORDER BY anchored_at ASC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| ArweaveAnchor { + id: r.get("id"), + repo: r.get("repo"), + owner_did: r.get("owner_did"), + ref_name: r.get("ref_name"), + old_sha: r.get("old_sha"), + new_sha: r.get("new_sha"), + cid: r.get("cid"), + arweave_tx_id: r.get("arweave_tx_id"), + node_did: r.get("node_did"), + anchored_at: r.get("anchored_at"), + status: r.get("status"), + deadline_height: r.try_get("deadline_height").unwrap_or(None), + receipt_sig: r.try_get("receipt_sig").unwrap_or(None), + cert_id: r.try_get("cert_id").unwrap_or(None), }) .collect()) } @@ -2899,6 +3036,9 @@ fn row_to_cert(r: sqlx::postgres::PgRow) -> RefCertificate { node_did: r.get("node_did"), signature: r.get("signature"), issued_at: r.get("issued_at"), + seq: r.try_get("seq").unwrap_or(0), + prev: r.try_get("prev").unwrap_or_default(), + pusher_sig: r.try_get("pusher_sig").unwrap_or(None), } } @@ -3720,7 +3860,7 @@ mod migration_tests { "pre-migration row must exist" ); - // ── Apply pending migrations (v10 ref_cert_unique_per_ref, v11 owner_did) ── + // ── Apply pending migrations (v10 ref_cert_unique_per_ref, v11 owner_did, v12 arweave) ── db.migrate().await.unwrap(); // ── Assertions ──────────────────────────────────────────────────── @@ -5420,6 +5560,9 @@ mod ref_certificate_tests { node_did: "did:key:zNODE".to_string(), signature: "sig".to_string(), issued_at: issued_at.to_string(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, } } @@ -5940,6 +6083,191 @@ mod ref_certificate_tests { "raw duplicate INSERT must be rejected by the unique index" ); } + + #[sqlx::test] + async fn get_most_recent_cert_returns_highest_seq(pool: PgPool) { + let db = db(pool).await; + let repo_id = uuid::Uuid::new_v4().to_string(); + db.create_repo(&RepoRecord { + id: repo_id.clone(), + name: "most-recent-test".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/most-recent-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + // Insert certs with increasing seq + for i in 1..=3 { + let mut cert = make_cert( + &format!("cert-seq-{i}"), + &repo_id, + "refs/heads/main", + "0000", + "1111", + &format!("2026-07-03T20:0{i}:00Z"), + ); + cert.seq = i; + db.insert_ref_certificate(&cert).await.unwrap(); + } + + let most_recent = db.get_most_recent_cert(&repo_id).await.unwrap(); + assert!(most_recent.is_some(), "should find a cert"); + assert_eq!(most_recent.unwrap().seq, 3, "highest seq returned"); + } + + #[sqlx::test] + async fn get_most_recent_cert_returns_none_for_empty_repo(pool: PgPool) { + let db = db(pool).await; + let result = db + .get_most_recent_cert("nonexistent-repo-id") + .await + .unwrap(); + assert!(result.is_none(), "empty repo returns None"); + } +} + +#[cfg(test)] +mod arweave_anchor_tests { + use super::{ArweaveAnchor, Db, RecordAnchorInputV2}; + use chrono::Utc; + use sqlx::PgPool; + + async fn db(pool: PgPool) -> Db { + let db = Db::for_testing(pool); + db.run_migrations().await.unwrap(); + db + } + + #[sqlx::test] + async fn record_and_list_arweave_anchors(pool: PgPool) { + let db = db(pool).await; + + let input = RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/main", + old_sha: "0000000000000000000000000000000000000000", + new_sha: "1111111111111111111111111111111111111111", + cid: Some("bafyreib5..."), + arweave_tx_id: "test-tx-id-123", + node_did: "did:key:zNODE", + gateway_url: "https://arweave.net", + }; + + db.record_arweave_anchor(&input).await.unwrap(); + + let anchors = db.list_arweave_anchors(Some("alice/myrepo"), 10).await.unwrap(); + assert_eq!(anchors.len(), 1, "one anchor recorded"); + assert_eq!(anchors[0].status, "pending", "default status is pending"); + assert_eq!(anchors[0].arweave_tx_id, "test-tx-id-123"); + } + + #[sqlx::test] + async fn confirm_anchor_updates_status(pool: PgPool) { + let db = db(pool).await; + let input = RecordAnchorInputV2 { + repo: "bob/myrepo", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/main", + old_sha: "0000000000000000000000000000000000000000", + new_sha: "1111111111111111111111111111111111111111", + cid: None, + arweave_tx_id: "tx-confirm", + node_did: "did:key:zNODE", + gateway_url: "https://arweave.net", + }; + db.record_arweave_anchor(&input).await.unwrap(); + + let anchors = db.list_arweave_anchors(Some("bob/myrepo"), 10).await.unwrap(); + let id = &anchors[0].id; + + db.confirm_arweave_anchor(id, 1234567, "receipt-sig-value") + .await + .unwrap(); + + let updated = db.list_arweave_anchors(Some("bob/myrepo"), 10).await.unwrap(); + assert_eq!(updated[0].status, "confirmed"); + assert_eq!(updated[0].deadline_height, Some(1234567)); + assert_eq!(updated[0].receipt_sig, Some("receipt-sig-value".into())); + } + + #[sqlx::test] + async fn fail_anchor_updates_status(pool: PgPool) { + let db = db(pool).await; + let input = RecordAnchorInputV2 { + repo: "carol/myrepo", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/main", + old_sha: "0000000000000000000000000000000000000000", + new_sha: "1111111111111111111111111111111111111111", + cid: None, + arweave_tx_id: "tx-fail", + node_did: "did:key:zNODE", + gateway_url: "https://arweave.net", + }; + db.record_arweave_anchor(&input).await.unwrap(); + + let anchors = db.list_arweave_anchors(Some("carol/myrepo"), 10).await.unwrap(); + let id = &anchors[0].id; + + db.fail_arweave_anchor(id).await.unwrap(); + + let updated = db.list_arweave_anchors(Some("carol/myrepo"), 10).await.unwrap(); + assert_eq!(updated[0].status, "failed"); + } + + #[sqlx::test] + async fn list_pending_anchors_returns_only_pending(pool: PgPool) { + let db = db(pool).await; + + // Record two anchors for different repos + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "dave/repo-a", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/main", + old_sha: "0000000000000000000000000000000000000000", + new_sha: "1111111111111111111111111111111111111111", + cid: None, + arweave_tx_id: "tx-pending-1", + node_did: "did:key:zNODE", + gateway_url: "https://arweave.net", + }) + .await + .unwrap(); + + // Record a second anchor for the same repo + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "dave/repo-b", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/feature", + old_sha: "aaaa", + new_sha: "bbbb", + cid: None, + arweave_tx_id: "tx-pending-2", + node_did: "did:key:zNODE", + gateway_url: "https://arweave.net", + }) + .await + .unwrap(); + + let pending = db.list_pending_anchors().await.unwrap(); + assert_eq!(pending.len(), 2, "both anchors are pending"); + + // Confirm one anchor + let first_id = pending[0].id.clone(); + db.confirm_arweave_anchor(&first_id, 100, "sig").await.unwrap(); + + let pending_after = db.list_pending_anchors().await.unwrap(); + assert_eq!(pending_after.len(), 1, "only one pending remains"); + } } #[cfg(test)] mod ref_update_db_tests { diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index f4c0d3e3..8ee55ecb 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -220,7 +220,9 @@ pub fn build_router(state: AppState) -> Router { .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))); // ── Arweave permanent anchors ────────────────────────────────────────── - let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors)); + let arweave_routes = Router::new() + .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) + .route("/api/v1/arweave/verify/{tx_id}", get(arweave::verify_anchor_endpoint)); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( @@ -585,8 +587,9 @@ async fn contracts_info(State(state): State) -> Json Date: Mon, 20 Jul 2026 15:50:37 +0600 Subject: [PATCH 02/25] fix(node): improve code formatting and readability in various modules --- crates/gitlawb-node/src/auth/mod.rs | 8 +++----- crates/gitlawb-node/src/config.rs | 6 +++++- crates/gitlawb-node/src/db/mod.rs | 29 +++++++++++++++++++++++------ crates/gitlawb-node/src/server.rs | 5 ++++- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index 9e4e7d28..d2b6ed8b 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -249,11 +249,9 @@ pub async fn require_signature(request: Request, next: Next) -> Response { request .extensions_mut() .insert(AuthenticatedDid(sig.key_id.to_string())); - request - .extensions_mut() - .insert(PusherSignature( - base64::engine::general_purpose::STANDARD.encode(&sig.signature_bytes), - )); + request.extensions_mut().insert(PusherSignature( + base64::engine::general_purpose::STANDARD.encode(&sig.signature_bytes), + )); next.run(request).await } diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index d69fc4c6..4d4b5c56 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -93,7 +93,11 @@ pub struct Config { /// Arweave gateway URL for resolving arweave_tx_id to data items. /// Used by the verify endpoint. Default: https://arweave.net - #[arg(long, env = "GITLAWB_ARWEAVE_GATEWAY", default_value = "https://arweave.net")] + #[arg( + long, + env = "GITLAWB_ARWEAVE_GATEWAY", + default_value = "https://arweave.net" + )] pub arweave_gateway: String, /// Base L2 DID registry contract address (0x...) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index ca3b875b..e6c21a39 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -6164,7 +6164,10 @@ mod arweave_anchor_tests { db.record_arweave_anchor(&input).await.unwrap(); - let anchors = db.list_arweave_anchors(Some("alice/myrepo"), 10).await.unwrap(); + let anchors = db + .list_arweave_anchors(Some("alice/myrepo"), 10) + .await + .unwrap(); assert_eq!(anchors.len(), 1, "one anchor recorded"); assert_eq!(anchors[0].status, "pending", "default status is pending"); assert_eq!(anchors[0].arweave_tx_id, "test-tx-id-123"); @@ -6186,14 +6189,20 @@ mod arweave_anchor_tests { }; db.record_arweave_anchor(&input).await.unwrap(); - let anchors = db.list_arweave_anchors(Some("bob/myrepo"), 10).await.unwrap(); + let anchors = db + .list_arweave_anchors(Some("bob/myrepo"), 10) + .await + .unwrap(); let id = &anchors[0].id; db.confirm_arweave_anchor(id, 1234567, "receipt-sig-value") .await .unwrap(); - let updated = db.list_arweave_anchors(Some("bob/myrepo"), 10).await.unwrap(); + let updated = db + .list_arweave_anchors(Some("bob/myrepo"), 10) + .await + .unwrap(); assert_eq!(updated[0].status, "confirmed"); assert_eq!(updated[0].deadline_height, Some(1234567)); assert_eq!(updated[0].receipt_sig, Some("receipt-sig-value".into())); @@ -6215,12 +6224,18 @@ mod arweave_anchor_tests { }; db.record_arweave_anchor(&input).await.unwrap(); - let anchors = db.list_arweave_anchors(Some("carol/myrepo"), 10).await.unwrap(); + let anchors = db + .list_arweave_anchors(Some("carol/myrepo"), 10) + .await + .unwrap(); let id = &anchors[0].id; db.fail_arweave_anchor(id).await.unwrap(); - let updated = db.list_arweave_anchors(Some("carol/myrepo"), 10).await.unwrap(); + let updated = db + .list_arweave_anchors(Some("carol/myrepo"), 10) + .await + .unwrap(); assert_eq!(updated[0].status, "failed"); } @@ -6263,7 +6278,9 @@ mod arweave_anchor_tests { // Confirm one anchor let first_id = pending[0].id.clone(); - db.confirm_arweave_anchor(&first_id, 100, "sig").await.unwrap(); + db.confirm_arweave_anchor(&first_id, 100, "sig") + .await + .unwrap(); let pending_after = db.list_pending_anchors().await.unwrap(); assert_eq!(pending_after.len(), 1, "only one pending remains"); diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 8ee55ecb..964bfb6d 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -222,7 +222,10 @@ pub fn build_router(state: AppState) -> Router { // ── Arweave permanent anchors ────────────────────────────────────────── let arweave_routes = Router::new() .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) - .route("/api/v1/arweave/verify/{tx_id}", get(arweave::verify_anchor_endpoint)); + .route( + "/api/v1/arweave/verify/{tx_id}", + get(arweave::verify_anchor_endpoint), + ); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( From 3a8005c2a11d2c49019e285286152f8d7fd67c77 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 20 Jul 2026 16:01:14 +0600 Subject: [PATCH 03/25] fix(node): use URL_SAFE_NO_PAD base64 for verify, fetch predecessor for prev hash check, add get_cert_by_seq --- crates/gitlawb-node/src/arweave.rs | 23 +++++++++++------------ crates/gitlawb-node/src/db/mod.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 00cd6653..31992082 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -312,7 +312,7 @@ pub async fn verify_anchor( .map_err(|e| anyhow::anyhow!("unresolvable node DID: {e}"))?; let sig_array: [u8; 64] = - match base64::engine::general_purpose::STANDARD.decode(&c.signature) { + match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&c.signature) { Ok(bytes) => match bytes.as_slice().try_into() { Ok(a) => a, Err(_) => { @@ -340,18 +340,17 @@ pub async fn verify_anchor( errors.push(format!("certificate signature verification failed: {e}")); } - // 2. Verify prev hash linkage against the most recent local cert - if let Ok(Some(local_cert)) = db.get_most_recent_cert(&c.repo_id).await { - if c.seq <= local_cert.seq { - // Check that the claimed prev matches the local chain + // 2. Verify prev hash linkage against the predecessor at seq - 1 + if c.seq > 1 { + if let Ok(Some(pred)) = db.get_cert_by_seq(&c.repo_id, c.seq - 1).await { let prev_payload = serde_json::json!({ - "repo_id": local_cert.repo_id, - "ref": local_cert.ref_name, - "old": local_cert.old_sha, - "new": local_cert.new_sha, - "pusher": local_cert.pusher_did, - "node": local_cert.node_did, - "ts": local_cert.issued_at, + "repo_id": pred.repo_id, + "ref": pred.ref_name, + "old": pred.old_sha, + "new": pred.new_sha, + "pusher": pred.pusher_did, + "node": pred.node_did, + "ts": pred.issued_at, }); let prev_bytes = serde_json::to_vec(&prev_payload)?; let expected_prev = hex::encode(sha2::Sha256::digest(&prev_bytes)); diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index e6c21a39..56162549 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2194,6 +2194,18 @@ impl Db { } /// Retrieve the most recent certificate for a repo (highest seq). + pub async fn get_cert_by_seq(&self, repo_id: &str, seq: i64) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + FROM ref_certificates WHERE repo_id = $1 AND seq = $2", + ) + .bind(repo_id) + .bind(seq) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_cert)) + } + pub async fn get_most_recent_cert(&self, repo_id: &str) -> Result> { let row = sqlx::query( "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig From b9f2ee9c9a2c1a4fa928f24c19dd9a21888cb277 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 20 Jul 2026 16:52:23 +0600 Subject: [PATCH 04/25] fix(node): allow dead code for future-use methods, fmt/clippy clean --- crates/gitlawb-node/src/arweave.rs | 1 + crates/gitlawb-node/src/db/mod.rs | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 31992082..ad41ea25 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -239,6 +239,7 @@ fn sanitize_tag(s: &str) -> String { } /// Arweave URL for a given transaction ID, resolved through a configurable gateway. +#[allow(dead_code)] pub fn arweave_url(gateway: &str, tx_id: &str) -> String { format!("{}/{}", gateway.trim_end_matches('/'), tx_id) } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 56162549..5215416e 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2850,6 +2850,7 @@ pub struct RecordAnchorInputV2<'a> { pub cid: Option<&'a str>, pub arweave_tx_id: &'a str, pub node_did: &'a str, + #[allow(dead_code)] pub gateway_url: &'a str, } @@ -2922,6 +2923,7 @@ impl Db { .collect()) } + #[allow(dead_code)] /// Update the anchor status to confirmed with receipt details. pub async fn confirm_arweave_anchor( &self, @@ -2940,6 +2942,7 @@ impl Db { Ok(()) } + #[allow(dead_code)] /// Mark an anchor as failed (retries exhausted). pub async fn fail_arweave_anchor(&self, id: &str) -> Result<()> { sqlx::query("UPDATE arweave_anchors SET status='failed' WHERE id=$1") @@ -2949,6 +2952,7 @@ impl Db { Ok(()) } + #[allow(dead_code)] /// List pending anchors that need confirmation check. pub async fn list_pending_anchors(&self) -> Result> { let rows = sqlx::query( @@ -6148,8 +6152,7 @@ mod ref_certificate_tests { #[cfg(test)] mod arweave_anchor_tests { - use super::{ArweaveAnchor, Db, RecordAnchorInputV2}; - use chrono::Utc; + use super::{Db, RecordAnchorInputV2}; use sqlx::PgPool; async fn db(pool: PgPool) -> Db { From 42c0475fd6fafc8df9dc55a65e627b5482de2f2c Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 22 Jul 2026 20:31:46 +0600 Subject: [PATCH 05/25] fix(node): address review findings on Arweave anchoring and cert chain (#26) - Use configured gateway's data URL (/tx_id) instead of bundler API for verify - Bound untrusted response to 1 MiB on public verify route - Bind each anchor to its own ref update certificate (not repo-wide latest) - Make certificate storage append-only with unique (repo_id, seq) constraint - Add migration v13 to drop old unique index and add sequence uniqueness - Allocate chain sequence numbers atomically using per-repo advisory lock - Fail closed on missing predecessor cert during verification - Persist full RFC 9421 HTTP Signature context (signature-input, content-digest, path) - Verify pusher authorization proof in verify_anchor - Add legacy GITLAWB_IRYS_URL fallback for GITLAWB_BUNDLER_URL - Expose seq, prev, pusher_sig through certificate API list/get responses --- crates/gitlawb-node/src/api/certs.rs | 6 + crates/gitlawb-node/src/api/events.rs | 3 + crates/gitlawb-node/src/api/repos.rs | 24 ++-- crates/gitlawb-node/src/arweave.rs | 167 ++++++++++++++++++++---- crates/gitlawb-node/src/auth/mod.rs | 23 +++- crates/gitlawb-node/src/cert.rs | 84 ++++++++++-- crates/gitlawb-node/src/db/mod.rs | 143 +++++++++----------- crates/gitlawb-node/src/main.rs | 10 ++ crates/gitlawb-node/src/test_support.rs | 18 +++ 9 files changed, 344 insertions(+), 134 deletions(-) diff --git a/crates/gitlawb-node/src/api/certs.rs b/crates/gitlawb-node/src/api/certs.rs index 0d954cb1..237528de 100644 --- a/crates/gitlawb-node/src/api/certs.rs +++ b/crates/gitlawb-node/src/api/certs.rs @@ -52,6 +52,9 @@ pub async fn list_certs( "node_did": c.node_did, "signature": c.signature, "issued_at": c.issued_at, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, }) }) .collect(); @@ -92,5 +95,8 @@ pub async fn get_cert( "node_did": cert.node_did, "signature": cert.signature, "issued_at": cert.issued_at, + "seq": cert.seq, + "prev": cert.prev, + "pusher_sig": cert.pusher_sig, }))) } diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index b2b77b28..efe33ec4 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -440,6 +440,9 @@ mod ref_updates_feed_tests { seq: 1, prev: "0".repeat(64), pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index c9e6215e..3a32819d 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -5,7 +5,7 @@ use axum::Json; use bytes::Bytes; use std::sync::Arc; -use crate::auth::{caller_authorized_to_push, AuthenticatedDid, PusherSignature}; +use crate::auth::{caller_authorized_to_push, AuthenticatedDid, PusherProof, PusherSignature}; use crate::db::RepoRecord; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -856,6 +856,7 @@ pub async fn git_receive_pack( Path((owner, repo)): Path<(String, String)>, Extension(auth): Extension, Extension(pusher_sig): Extension, + Extension(pusher_proof): Extension, body: Bytes, ) -> Result { let name = smart_http_repo_name(&repo)?; @@ -972,6 +973,10 @@ pub async fn git_receive_pack( // The route is behind `require_signature`, so the verified pusher identity is // always present; use it directly rather than re-parsing the headers. let did = auth.0.as_str(); + // Collect certs keyed by ref_name so the anchoring loop below uses + // the correct per-update certificate rather than a repo-wide latest. + let mut ref_certs: std::collections::HashMap = + std::collections::HashMap::new(); { // Use the first new commit hash we parsed, fall back to timestamp let commit_hash = ref_updates @@ -999,11 +1004,15 @@ pub async fn git_receive_pack( &update.new_sha, did, Some(pusher_sig.0.clone()), + Some(pusher_proof.signature_input.clone()), + Some(pusher_proof.content_digest.clone()), + Some(pusher_proof.request_path.clone()), ) .await { Ok(c) => { - tracing::info!(cert_id = %c.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate") + tracing::info!(cert_id = %c.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate"); + ref_certs.insert(update.ref_name.clone(), c); } Err(e) => { tracing::warn!(err = %e, ref_name = %update.ref_name, "failed to issue ref certificate") @@ -1210,7 +1219,6 @@ pub async fn git_receive_pack( let db_clone = state.db.clone(); let http_client = Arc::clone(&state.http_client); let node_did_str = state.node_did.to_string(); - let repo_id_clone = record.id.clone(); let repo_slug = format!( "{}/{}", crate::db::normalize_owner_key(&record.owner_did), @@ -1220,6 +1228,7 @@ pub async fn git_receive_pack( .iter() .map(|u| (u.ref_name.clone(), u.old_sha.clone(), u.new_sha.clone())) .collect::>(); + let ref_certs_clone = ref_certs.clone(); let p2p_handle = state.p2p.clone(); let pusher_did_clone = did.to_string(); let db_for_peers = state.db.clone(); @@ -1311,11 +1320,10 @@ pub async fn git_receive_pack( if announce && !bundler_url.is_empty() { for (ref_name, old_sha, new_sha) in &ref_updates_clone { let cid = cid_map.get(new_sha).cloned(); - let cert = db_clone - .get_most_recent_cert(&repo_id_clone) - .await - .ok() - .flatten(); + // Use the per-update certificate issued above, not a + // repo-wide latest, so each anchor embeds the exact + // certificate for its own ref transition. + let cert = ref_certs_clone.get(ref_name).cloned(); let anchor = crate::arweave::RefAnchor { repo: repo_slug.clone(), owner_did: owner_did_for_arweave.clone(), diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index ad41ea25..63cba30c 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -22,6 +22,7 @@ use base64::Engine as _; use serde::Serialize; use serde_json::json; use sha2::Digest; +use std::collections::HashMap; use std::str::FromStr; /// Data describing a ref-update event to be anchored. @@ -261,22 +262,32 @@ pub async fn verify_anchor( tx_id: &str, db: &crate::db::Db, ) -> Result { - // Fetch the data item from the bundler gateway. - let url = format!("{}/v1/tx/{}", gateway_url.trim_end_matches('/'), tx_id); + // Fetch the data item from the Arweave gateway's data path. + // Gateways serve data at /{tx_id}, not /v1/tx/{id} (which is the bundler API). + let url = format!("{}/{}", gateway_url.trim_end_matches('/'), tx_id); let resp = client .get(&url) .send() .await - .map_err(|e| anyhow::anyhow!("failed to fetch data from bundler gateway: {e}"))?; + .map_err(|e| anyhow::anyhow!("failed to fetch data from Arweave gateway: {e}"))?; if !resp.status().is_success() { return Ok(VerifyResult { valid: false, anchor: serde_json::Value::Null, certificate: None, - errors: vec![format!("bundler gateway returned {}", resp.status())], + errors: vec![format!("Arweave gateway returned {}", resp.status())], }); } + // Bound the untrusted response to 1 MiB to prevent memory exhaustion. let body_bytes = resp.bytes().await?; + if body_bytes.len() > 1_048_576 { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec!["response body exceeds 1 MiB limit".to_string()], + }); + } // Parse the payload — could be JSON or raw bytes depending on gateway let anchor: serde_json::Value = serde_json::from_slice(&body_bytes)?; @@ -341,26 +352,132 @@ pub async fn verify_anchor( errors.push(format!("certificate signature verification failed: {e}")); } - // 2. Verify prev hash linkage against the predecessor at seq - 1 + // 2. Verify prev hash linkage against the predecessor at seq - 1. + // Fail closed: a missing declared predecessor is treated as invalid. if c.seq > 1 { - if let Ok(Some(pred)) = db.get_cert_by_seq(&c.repo_id, c.seq - 1).await { - let prev_payload = serde_json::json!({ - "repo_id": pred.repo_id, - "ref": pred.ref_name, - "old": pred.old_sha, - "new": pred.new_sha, - "pusher": pred.pusher_did, - "node": pred.node_did, - "ts": pred.issued_at, - }); - let prev_bytes = serde_json::to_vec(&prev_payload)?; - let expected_prev = hex::encode(sha2::Sha256::digest(&prev_bytes)); - if c.prev != expected_prev { + match db.get_cert_by_seq(&c.repo_id, c.seq - 1).await { + Ok(Some(pred)) => { + let prev_payload = serde_json::json!({ + "repo_id": pred.repo_id, + "ref": pred.ref_name, + "old": pred.old_sha, + "new": pred.new_sha, + "pusher": pred.pusher_did, + "node": pred.node_did, + "ts": pred.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + let expected_prev = hex::encode(sha2::Sha256::digest(&prev_bytes)); + if c.prev != expected_prev { + errors.push(format!( + "prev hash mismatch: claimed {} expected {}", + c.prev, expected_prev + )); + } + } + Ok(None) => { errors.push(format!( - "prev hash mismatch: claimed {} expected {}", - c.prev, expected_prev + "predecessor cert seq {} not found for repo {}", + c.seq - 1, + c.repo_id )); } + Err(e) => { + errors.push(format!( + "error looking up predecessor seq {}: {e}", + c.seq - 1 + )); + } + } + } + + // 3. Verify the pusher authorization proof (RFC 9421 HTTP Signature) + // when all required context is available. + if let (Some(pusher_sig), Some(sig_input), Some(content_digest), Some(request_path)) = ( + &c.pusher_sig, + &c.signature_input, + &c.content_digest, + &c.request_path, + ) { + match gitlawb_core::http_sig::HttpSignature::parse( + sig_input, + &format!("sig1=:{pusher_sig}:"), + ) { + Ok(http_sig) => { + let mut request_values: HashMap = HashMap::new(); + request_values.insert("@method".to_string(), "POST".to_string()); + request_values.insert("@path".to_string(), request_path.clone()); + request_values.insert("content-digest".to_string(), content_digest.clone()); + + let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input); + let components_ref: Vec<&str> = + http_sig.components.iter().map(String::as_str).collect(); + + match gitlawb_core::http_sig::build_signing_string( + &components_ref, + sig_params_value, + &request_values, + ) { + Ok(signing_string) => { + let pusher_did = gitlawb_core::did::Did::from_str(&c.pusher_did); + let pusher_vk = pusher_did.and_then(|d| d.to_verifying_key()); + match pusher_vk { + Ok(vk) => { + let sig_bytes: [u8; 64] = + match base64::engine::general_purpose::STANDARD + .decode(pusher_sig) + { + Ok(bytes) => match bytes.as_slice().try_into() { + Ok(a) => a, + Err(_) => { + errors.push( + "pusher signature is not 64 bytes" + .to_string(), + ); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }, + Err(_) => { + errors.push( + "pusher signature is not valid base64" + .to_string(), + ); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + if let Err(e) = gitlawb_core::identity::verify( + &vk, + signing_string.as_bytes(), + &sig_bytes, + ) { + errors.push(format!( + "pusher signature verification failed: {e}" + )); + } + } + Err(e) => { + errors.push(format!("unresolvable pusher DID: {e}")); + } + } + } + Err(e) => { + errors.push(format!("failed to build signing string: {e}")); + } + } + } + Err(e) => { + errors.push(format!("failed to parse pusher Signature-Input: {e}")); + } } } } else { @@ -564,16 +681,14 @@ mod tests { #[tokio::test] async fn test_verify_anchor_uses_correct_gateway_url() { let mut server = mockito::Server::new_async().await; + // Gateways serve data at /{tx_id}, not /v1/tx/{id}. let _mock = server - .mock("GET", "/v1/tx/does-not-exist") + .mock("GET", "/does-not-exist") .with_status(404) .create_async() .await; let client = reqwest::Client::new(); - // verify_anchor needs a real PgPool; this test only exercises that - // the function correctly formats the gateway URL and handles a 404. - // It will error on the pool access which is expected without a test DB. let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); @@ -582,13 +697,9 @@ mod tests { match result { Ok(r) => { - // With a lazy unconnected pool, get_most_recent_cert will fail, - // but the function still returns Ok(VerifyResult) with errors. assert!(!r.valid); } Err(e) => { - // On some systems the POSTGRES connection attempt may abort - // rather than fail gracefully. let msg = e.to_string(); assert!( msg.contains("pool") || msg.contains("error"), diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index d2b6ed8b..d5826c93 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -23,6 +23,18 @@ pub struct AuthenticatedDid(pub String); #[derive(Clone, Debug)] pub struct PusherSignature(pub String); +/// Full RFC 9421 HTTP Signature context, needed to reconstruct the signing +/// string when verifying the pusher authorization proof. +#[derive(Clone, Debug)] +pub struct PusherProof { + /// The `Signature-Input` header value (e.g. `sig1=("@method" "@path" "content-digest");keyid="...";alg="ed25519";created=1234`) + pub signature_input: String, + /// The `Content-Digest` header value (e.g. `sha-256=:base64:`) + pub content_digest: String, + /// The HTTP request path+query, e.g. /owner/repo.git/git-receive-pack + pub request_path: String, +} + /// Whether `caller` is authorized to push to `record`. /// /// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical @@ -177,9 +189,9 @@ pub async fn require_signature(request: Request, next: Next) -> Response { .to_string(); let mut request_values: HashMap = HashMap::new(); - request_values.insert("@method".to_string(), method); - request_values.insert("@path".to_string(), path_and_query); - request_values.insert("content-digest".to_string(), content_digest); + request_values.insert("@method".to_string(), method.clone()); + request_values.insert("@path".to_string(), path_and_query.clone()); + request_values.insert("content-digest".to_string(), content_digest.clone()); // The @signature-params value is the part of Signature-Input after "sig1=" let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input); @@ -252,6 +264,11 @@ pub async fn require_signature(request: Request, next: Next) -> Response { request.extensions_mut().insert(PusherSignature( base64::engine::general_purpose::STANDARD.encode(&sig.signature_bytes), )); + request.extensions_mut().insert(PusherProof { + signature_input: sig_input, + content_digest, + request_path: path_and_query, + }); next.run(request).await } diff --git a/crates/gitlawb-node/src/cert.rs b/crates/gitlawb-node/src/cert.rs index f96a3bf3..a613ddda 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -1,9 +1,3 @@ -//! Certificate issuance for ref updates. -//! -//! When a push lands, the node signs a receipt proving the commit was -//! accepted. This receipt is a `RefCertificate` stored in the DB and -//! accessible via the API. - use anyhow::Result; use chrono::Utc; use sha2::{Digest, Sha256}; @@ -14,8 +8,9 @@ use crate::state::AppState; /// Issue a signed ref-update certificate for a successful push. /// -/// Builds a canonical JSON payload, signs it with the node's Ed25519 key, -/// persists the certificate, and returns it. +/// Acquires a per-repo advisory lock to atomically allocate the chain +/// sequence number. Retries once on unique-constraint violation (safety +/// net for the rare case the advisory lock yields a false collision). pub async fn issue_ref_certificate( state: &AppState, repo_id: &str, @@ -24,7 +19,13 @@ pub async fn issue_ref_certificate( new_sha: &str, pusher_did: &str, pusher_sig: Option, + signature_input: Option, + content_digest: Option, + request_path: Option, ) -> Result { + // Serialize cert issuance per repo to avoid seq collisions + state.db.lock_repo_cert_issuance(repo_id).await?; + let node_did = state.node_did.to_string(); let issued_at = Utc::now().to_rfc3339(); @@ -75,15 +76,72 @@ pub async fn issue_ref_certificate( old_sha: old_sha.to_string(), new_sha: new_sha.to_string(), pusher_did: pusher_did.to_string(), - node_did, + node_did: node_did.clone(), signature, - issued_at, + issued_at: issued_at.clone(), seq, prev, pusher_sig, + signature_input, + content_digest, + request_path, }; - // Persist and return the row as it exists in the database (on a - // conflict the existing row survives when it is newer). - state.db.insert_ref_certificate(&cert).await + // Persist and return the row as it exists in the database. + // Under the advisory lock the INSERT should succeed; if a unique + // violation nevertheless occurs, retry once. + match state.db.insert_ref_certificate(&cert).await { + Ok(c) => Ok(c), + Err(e) => { + // Check for PostgreSQL unique violation (code 23505) + let err_str = e.to_string(); + if err_str.contains("23505") || err_str.contains("unique") { + // Re-read the predecessor and retry with a fresh seq + let prev_cert = state.db.get_most_recent_cert(repo_id).await?; + let seq = match &prev_cert { + Some(c) => c.seq + 1, + None => 1, + }; + let prev = match &prev_cert { + Some(c) => { + let prev_payload = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + hex::encode(Sha256::digest(&prev_bytes)) + } + None => "0".repeat(64), + }; + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": cert.pusher_sig, + }); + let payload_bytes = serde_json::to_vec(&payload)?; + let signature = state.node_keypair.sign_b64(&payload_bytes); + let retry_cert = RefCertificate { + seq, + prev, + signature, + ..cert + }; + state.db.insert_ref_certificate(&retry_cert).await + } else { + Err(e) + } + } + } } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 5215416e..2a867b78 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -142,6 +142,14 @@ pub struct RefCertificate { pub prev: String, /// RFC 9421 HTTP Signature from the pusher, proving they authorized this push pub pusher_sig: Option, + /// RFC 9421 Signature-Input header value, needed to reconstruct the signing + /// string for pusher authorization verification. + pub signature_input: Option, + /// Content-Digest header value covering the request body (RFC 9421). + pub content_digest: Option, + /// The HTTP request path (e.g. /owner/repo.git/git-receive-pack) for RFC 9421 + /// signing-string reconstruction. + pub request_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -252,7 +260,7 @@ pub struct ProfileRecord { #[derive(Clone)] pub struct Db { - pool: PgPool, + pub(crate) pool: PgPool, } impl Db { @@ -2090,37 +2098,15 @@ impl Db { // ── Ref Certificates ────────────────────────────────────────────────────────── impl Db { - /// Insert a ref certificate, or update it if a row for `(repo_id, ref_name)` - /// already exists. The update only applies when the incoming row is newer - /// (compared by `issued_at`, which assumes a monotonic wall clock), so a - /// late-landing older cert cannot regress a ref's persisted state. Returns - /// the full row as it now exists in the database (the original row on a - /// rejected upsert; the passed row on insert). + /// Insert a ref certificate (append-only). The unique constraint on + /// `(repo_id, seq)` prevents duplicate sequence numbers; callers must + /// handle retry on collision. pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result { let row = sqlx::query( "INSERT INTO ref_certificates - (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (repo_id, ref_name) DO UPDATE SET - old_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.old_sha ELSE ref_certificates.old_sha END, - new_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.new_sha ELSE ref_certificates.new_sha END, - pusher_did = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.pusher_did ELSE ref_certificates.pusher_did END, - node_did = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.node_did ELSE ref_certificates.node_did END, - signature = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.signature ELSE ref_certificates.signature END, - seq = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.seq ELSE ref_certificates.seq END, - prev = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.prev ELSE ref_certificates.prev END, - pusher_sig = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.pusher_sig ELSE ref_certificates.pusher_sig END, - issued_at = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.issued_at ELSE ref_certificates.issued_at END - RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig", + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path", ) .bind(&cert.id) .bind(&cert.repo_id) @@ -2134,6 +2120,9 @@ impl Db { .bind(cert.seq) .bind(&cert.prev) .bind(&cert.pusher_sig) + .bind(&cert.signature_input) + .bind(&cert.content_digest) + .bind(&cert.request_path) .fetch_one(&self.pool) .await?; Ok(row_to_cert(row)) @@ -2148,7 +2137,7 @@ impl Db { // bounded even if a raw/negative value slips through the handler layer. let limit = limit.max(1); let rows = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC, issued_at DESC LIMIT $2", ) .bind(repo_id) @@ -2171,7 +2160,7 @@ impl Db { let limit = limit.max(1); let pattern = format!("{}%", prefix); let rows = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path FROM ref_certificates WHERE repo_id = $1 AND id LIKE $2 ORDER BY seq DESC, issued_at DESC LIMIT $3", ) .bind(repo_id) @@ -2184,7 +2173,7 @@ impl Db { pub async fn get_ref_certificate(&self, id: &str) -> Result> { let row = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path FROM ref_certificates WHERE id = $1", ) .bind(id) @@ -2196,7 +2185,7 @@ impl Db { /// Retrieve the most recent certificate for a repo (highest seq). pub async fn get_cert_by_seq(&self, repo_id: &str, seq: i64) -> Result> { let row = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path FROM ref_certificates WHERE repo_id = $1 AND seq = $2", ) .bind(repo_id) @@ -2208,7 +2197,7 @@ impl Db { pub async fn get_most_recent_cert(&self, repo_id: &str) -> Result> { let row = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC LIMIT 1", ) .bind(repo_id) @@ -2216,6 +2205,28 @@ impl Db { .await?; Ok(row.map(row_to_cert)) } + + /// Acquire a per-repo advisory lock to serialize certificate issuance. + /// This prevents two concurrent pushes to the same repo from racing on + /// the sequence number allocation. + pub async fn lock_repo_cert_issuance(&self, repo_id: &str) -> Result<()> { + // Use a hash of repo_id as the advisory lock key so we get a stable + // i64 value. FNV-1a 64-bit is sufficient — collision risk is negligible + // and a false collision would only serialize unrelated repos. + // Use the std DefaultHasher (SipHash-2-4) for a stable hash. + // Collision risk is negligible and would only serialize unrelated repos. + let hash = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + repo_id.hash(&mut h); + h.finish() as i64 + }; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(hash) + .execute(&self.pool) + .await?; + Ok(()) + } } // ── Peers ───────────────────────────────────────────────────────────────────── @@ -3055,6 +3066,9 @@ fn row_to_cert(r: sqlx::postgres::PgRow) -> RefCertificate { seq: r.try_get("seq").unwrap_or(0), prev: r.try_get("prev").unwrap_or_default(), pusher_sig: r.try_get("pusher_sig").unwrap_or(None), + signature_input: r.try_get("signature_input").unwrap_or(None), + content_digest: r.try_get("content_digest").unwrap_or(None), + request_path: r.try_get("request_path").unwrap_or(None), } } @@ -5579,6 +5593,9 @@ mod ref_certificate_tests { seq: 1, prev: "0".repeat(64), pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, } } @@ -5630,20 +5647,20 @@ mod ref_certificate_tests { } #[sqlx::test] - async fn insert_ref_certificate_upserts_on_repo_ref(pool: PgPool) { + async fn insert_ref_certificate_append_only(pool: PgPool) { let db = db(pool).await; let repo_id = uuid::Uuid::new_v4().to_string(); db.create_repo(&RepoRecord { id: repo_id.clone(), - name: "upsert-test".into(), + name: "append-test".into(), owner_did: "did:key:zOWNER".into(), description: None, is_public: true, default_branch: "main".into(), created_at: Utc::now(), updated_at: Utc::now(), - disk_path: "/tmp/upsert-test".into(), + disk_path: "/tmp/append-test".into(), forked_from: None, machine_id: None, }) @@ -5652,7 +5669,7 @@ mod ref_certificate_tests { // First insert db.insert_ref_certificate(&make_cert( - "cert-original", + "cert-first", &repo_id, "refs/heads/main", "0000", @@ -5662,9 +5679,9 @@ mod ref_certificate_tests { .await .unwrap(); - // Upsert same ref with new values + // Second insert for the same ref — append-only means both rows exist db.insert_ref_certificate(&make_cert( - "cert-upserted", + "cert-second", &repo_id, "refs/heads/main", "aaaa", @@ -5674,49 +5691,11 @@ mod ref_certificate_tests { .await .unwrap(); - // Only one row exists for this ref - let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap(); - assert_eq!(certs.len(), 1, "upsert must not create a duplicate row"); - assert_eq!( - certs[0].id, "cert-original", - "upsert must preserve the original ID across re-pushes" - ); - assert_eq!(certs[0].old_sha, "aaaa", "old_sha updated"); - assert_eq!(certs[0].new_sha, "bbbb", "new_sha updated"); - assert_eq!( - certs[0].issued_at, "2026-07-03T21:00:00Z", - "newer issued_at overwrites older" - ); - - // Now try to overwrite with an OLDER cert — the guard must reject it. - db.insert_ref_certificate(&make_cert( - "stale-id", - &repo_id, - "refs/heads/main", - "stale", - "stale", - "2026-07-03T19:00:00Z", - )) - .await - .unwrap(); + // Two rows now exist for this ref (append-only) let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap(); - assert_eq!(certs.len(), 1, "no extra row from stale cert"); - assert_eq!( - certs[0].id, "cert-original", - "stale cert does not change the original id" - ); - assert_eq!( - certs[0].old_sha, "aaaa", - "stale cert does not regress old_sha" - ); - assert_eq!( - certs[0].new_sha, "bbbb", - "stale cert does not regress new_sha" - ); - assert_eq!( - certs[0].issued_at, "2026-07-03T21:00:00Z", - "stale cert does not regress issued_at" - ); + assert_eq!(certs.len(), 2, "append-only must keep both rows"); + assert_eq!(certs[0].id, "cert-second", "most recent first"); + assert_eq!(certs[1].id, "cert-first", "second most recent"); } #[sqlx::test] diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index aa0483db..d9d805ad 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -70,6 +70,16 @@ async fn main() -> Result<()> { let mut config = Config::parse(); + // Fallback to legacy GITLAWB_IRYS_URL for backward compatibility during rename + if config.bundler_url.is_empty() { + if let Ok(legacy) = std::env::var("GITLAWB_IRYS_URL") { + if !legacy.is_empty() { + config.bundler_url = legacy; + tracing::warn!("GITLAWB_IRYS_URL is deprecated, use GITLAWB_BUNDLER_URL instead"); + } + } + } + // Merge the embedded seed list of public network nodes into the runtime // bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS. bootstrap::merge_seeds(&mut config); diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index f035ff89..f53e2d95 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -1478,6 +1478,12 @@ mod tests { node_did: owner.to_string(), signature: "sig".to_string(), issued_at: Utc::now().to_rfc3339(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }) .await .expect("seed private cert"); @@ -3559,6 +3565,9 @@ mod tests { seq: 1, prev: "0".repeat(64), pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }; state.db.insert_ref_certificate(&cert).await.unwrap(); @@ -3597,6 +3606,9 @@ mod tests { seq: 1, prev: "0".repeat(64), pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }; state.db.insert_ref_certificate(&cert).await.unwrap(); @@ -4109,6 +4121,9 @@ mod tests { seq: 1, prev: "0".repeat(64), pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }; state.db.insert_ref_certificate(&cert).await.unwrap(); @@ -4968,6 +4983,9 @@ mod tests { seq: 1, prev: "0".repeat(64), pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, } } From 2049f791528fd0775d719f313ec6e0d9585a35c8 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 23 Jul 2026 11:19:31 +0600 Subject: [PATCH 06/25] address review findings: backfill seq, cap verify body, --irys-url alias, cross-check cert, transaction lock - v13 migration: backfill seq values per repo before creating unique (repo_id, seq) index to prevent failures on existing data - verify_anchor: check Content-Length before buffering response body - config: add deprecated --irys-url alias for bundler_url - verify_anchor: cross-check outer anchor fields (repo, ref_name, old_sha, new_sha, node_did) against embedded certificate - cert issuance: wrap lock/lookup/insert in a single Postgres transaction so pg_advisory_xact_lock is held for the full sequence - Add _tx variants of db methods accepting &mut PgConnection - Fix test helpers to use atomic counters for unique seq values - Fix v10 dedup tests to drop v13 index during pre-migration setup --- crates/gitlawb-node/src/api/events.rs | 9 +- crates/gitlawb-node/src/arweave.rs | 58 ++++++ crates/gitlawb-node/src/cert.rs | 254 ++++++++++++++---------- crates/gitlawb-node/src/config.rs | 8 +- crates/gitlawb-node/src/db/mod.rs | 146 ++++++++++---- crates/gitlawb-node/src/test_support.rs | 11 +- 6 files changed, 344 insertions(+), 142 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index efe33ec4..4a3d2a4b 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -426,6 +426,13 @@ mod ref_updates_feed_tests { .with_state(state) } + use std::sync::atomic::{AtomicI64, Ordering}; + static NEXT_FCERT_SEQ: AtomicI64 = AtomicI64::new(1); + + fn ref_cert_seq() -> i64 { + NEXT_FCERT_SEQ.fetch_add(1, Ordering::Relaxed) + } + fn ref_cert(id: &str, repo_id: &str) -> RefCertificate { RefCertificate { id: id.into(), @@ -437,7 +444,7 @@ mod ref_updates_feed_tests { node_did: "did:key:z6MkNode".into(), signature: "sig".into(), issued_at: Utc::now().to_rfc3339(), - seq: 1, + seq: ref_cert_seq(), prev: "0".repeat(64), pusher_sig: None, signature_input: None, diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 63cba30c..b22d0565 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -279,6 +279,17 @@ pub async fn verify_anchor( }); } // Bound the untrusted response to 1 MiB to prevent memory exhaustion. + // Check Content-Length first so we never buffer a giant body. + if let Some(cl) = resp.content_length() { + if cl > 1_048_576 { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec!["response body exceeds 1 MiB limit".to_string()], + }); + } + } let body_bytes = resp.bytes().await?; if body_bytes.len() > 1_048_576 { return Ok(VerifyResult { @@ -301,6 +312,53 @@ pub async fn verify_anchor( let mut errors = Vec::new(); if let Some(ref c) = cert { + // 0. Cross-check the outer anchor fields against the embedded certificate. + // A valid anchor must commit to the same identities and ref state. + let outer_repo = anchor.get("repo").and_then(|v| v.as_str()); + let outer_ref = anchor.get("ref_name").and_then(|v| v.as_str()); + let outer_old = anchor.get("old_sha").and_then(|v| v.as_str()); + let outer_new = anchor.get("new_sha").and_then(|v| v.as_str()); + let outer_node = anchor.get("node_did").and_then(|v| v.as_str()); + if outer_repo.is_none() { + errors.push("anchor payload is missing top-level 'repo'".to_string()); + } else if outer_repo != Some(&c.repo_id) { + errors.push(format!( + "anchor outer repo ({}) does not match certificate repo_id ({})", + outer_repo.unwrap_or(""), + c.repo_id + )); + } + if outer_ref.is_none() { + errors.push("anchor payload is missing top-level 'ref_name'".to_string()); + } else if outer_ref != Some(&c.ref_name) { + errors.push(format!( + "anchor outer ref_name ({}) does not match certificate ref_name ({})", + outer_ref.unwrap_or(""), + c.ref_name + )); + } + if outer_old.is_some() && outer_old != Some(&c.old_sha) { + errors.push(format!( + "anchor outer old_sha ({}) does not match certificate old_sha ({})", + outer_old.unwrap_or(""), + c.old_sha + )); + } + if outer_new.is_some() && outer_new != Some(&c.new_sha) { + errors.push(format!( + "anchor outer new_sha ({}) does not match certificate new_sha ({})", + outer_new.unwrap_or(""), + c.new_sha + )); + } + if outer_node.is_some() && outer_node != Some(&c.node_did) { + errors.push(format!( + "anchor outer node_did ({}) does not match certificate node_did ({})", + outer_node.unwrap_or(""), + c.node_did + )); + } + // 1. Verify node signature on the certificate payload let payload = serde_json::json!({ "repo_id": c.repo_id, diff --git a/crates/gitlawb-node/src/cert.rs b/crates/gitlawb-node/src/cert.rs index a613ddda..9e1a85a0 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -1,3 +1,5 @@ +use std::ops::DerefMut; + use anyhow::Result; use chrono::Utc; use sha2::{Digest, Sha256}; @@ -6,67 +8,88 @@ use uuid::Uuid; use crate::db::RefCertificate; use crate::state::AppState; -/// Issue a signed ref-update certificate for a successful push. -/// -/// Acquires a per-repo advisory lock to atomically allocate the chain -/// sequence number. Retries once on unique-constraint violation (safety -/// net for the rare case the advisory lock yields a false collision). -pub async fn issue_ref_certificate( - state: &AppState, +/// Build the canonical signing payload for a certificate. +#[allow(clippy::too_many_arguments)] +fn cert_payload( repo_id: &str, ref_name: &str, old_sha: &str, new_sha: &str, pusher_did: &str, + node_did: &str, + issued_at: &str, + seq: i64, + prev: &str, pusher_sig: Option, - signature_input: Option, - content_digest: Option, - request_path: Option, -) -> Result { - // Serialize cert issuance per repo to avoid seq collisions - state.db.lock_repo_cert_issuance(repo_id).await?; +) -> serde_json::Value { + serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + }) +} - let node_did = state.node_did.to_string(); - let issued_at = Utc::now().to_rfc3339(); +/// Compute the SHA-256 prev hash from a predecessor certificate. +fn prev_hash(c: &RefCertificate) -> Result { + let prev_payload = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + Ok(hex::encode(Sha256::digest(&prev_bytes))) +} +/// Attempt a single cert-issuance within an active transaction. +#[allow(clippy::too_many_arguments)] +async fn issue_once( + state: &AppState, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + pusher_did: &str, + pusher_sig: &Option, + signature_input: &Option, + content_digest: &Option, + request_path: &Option, + conn: &mut sqlx::postgres::PgConnection, +) -> Result { // Look up the previous certificate to chain from it. - let prev_cert = state.db.get_most_recent_cert(repo_id).await?; - let seq = match &prev_cert { - Some(c) => c.seq + 1, - None => 1, - }; - let prev = match &prev_cert { - Some(c) => { - let prev_payload = serde_json::json!({ - "repo_id": c.repo_id, - "ref": c.ref_name, - "old": c.old_sha, - "new": c.new_sha, - "pusher": c.pusher_did, - "node": c.node_did, - "ts": c.issued_at, - }); - let prev_bytes = serde_json::to_vec(&prev_payload)?; - hex::encode(Sha256::digest(&prev_bytes)) - } + let prev_cert = state.db.get_most_recent_cert_tx(repo_id, conn).await?; + let seq = prev_cert.as_ref().map_or(1, |c| c.seq + 1); + let prev = match prev_cert.as_ref() { + Some(c) => prev_hash(c)?, None => "0".repeat(64), }; - // Build the canonical signing payload with chain info. - let payload = serde_json::json!({ - "repo_id": repo_id, - "ref": ref_name, - "old": old_sha, - "new": new_sha, - "pusher": pusher_did, - "node": node_did, - "ts": issued_at, - "seq": seq, - "prev": prev, - "pusher_sig": pusher_sig, - }); - let payload_bytes = serde_json::to_vec(&payload)?; + let node_did = state.node_did.to_string(); + let issued_at = Utc::now().to_rfc3339(); + let payload = cert_payload( + repo_id, + ref_name, + old_sha, + new_sha, + pusher_did, + &node_did, + &issued_at, + seq, + &prev, + pusher_sig.clone(), + ); + let payload_bytes = serde_json::to_vec(&payload)?; let signature = state.node_keypair.sign_b64(&payload_bytes); let cert = RefCertificate { @@ -76,69 +99,94 @@ pub async fn issue_ref_certificate( old_sha: old_sha.to_string(), new_sha: new_sha.to_string(), pusher_did: pusher_did.to_string(), - node_did: node_did.clone(), + node_did: node_did.to_string(), signature, - issued_at: issued_at.clone(), + issued_at: issued_at.to_string(), seq, prev, - pusher_sig, - signature_input, - content_digest, - request_path, + pusher_sig: pusher_sig.clone(), + signature_input: signature_input.clone(), + content_digest: content_digest.clone(), + request_path: request_path.clone(), }; - // Persist and return the row as it exists in the database. - // Under the advisory lock the INSERT should succeed; if a unique - // violation nevertheless occurs, retry once. - match state.db.insert_ref_certificate(&cert).await { - Ok(c) => Ok(c), + state.db.insert_ref_certificate_tx(&cert, conn).await +} + +/// Issue a signed ref-update certificate for a successful push. +/// +/// Acquires a per-repo advisory lock to atomically allocate the chain +/// sequence number within a single database transaction, preventing race +/// conditions with concurrent pushes to the same repository. +#[allow(clippy::too_many_arguments)] +pub async fn issue_ref_certificate( + state: &AppState, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + pusher_did: &str, + pusher_sig: Option, + signature_input: Option, + content_digest: Option, + request_path: Option, +) -> Result { + let mut tx = state.db.pool().begin().await?; + + // Serialize cert issuance per repo within the transaction so the + // advisory lock is held for the entire lock → lookup → insert sequence. + state + .db + .lock_repo_cert_issuance_tx(repo_id, tx.deref_mut()) + .await?; + + let result = issue_once( + state, + repo_id, + ref_name, + old_sha, + new_sha, + pusher_did, + &pusher_sig, + &signature_input, + &content_digest, + &request_path, + &mut tx, + ) + .await; + + match result { + Ok(cert) => { + tx.commit().await?; + Ok(cert) + } Err(e) => { - // Check for PostgreSQL unique violation (code 23505) + // Rollback the failed attempt before retrying + tx.rollback().await?; let err_str = e.to_string(); if err_str.contains("23505") || err_str.contains("unique") { - // Re-read the predecessor and retry with a fresh seq - let prev_cert = state.db.get_most_recent_cert(repo_id).await?; - let seq = match &prev_cert { - Some(c) => c.seq + 1, - None => 1, - }; - let prev = match &prev_cert { - Some(c) => { - let prev_payload = serde_json::json!({ - "repo_id": c.repo_id, - "ref": c.ref_name, - "old": c.old_sha, - "new": c.new_sha, - "pusher": c.pusher_did, - "node": c.node_did, - "ts": c.issued_at, - }); - let prev_bytes = serde_json::to_vec(&prev_payload)?; - hex::encode(Sha256::digest(&prev_bytes)) - } - None => "0".repeat(64), - }; - let payload = serde_json::json!({ - "repo_id": repo_id, - "ref": ref_name, - "old": old_sha, - "new": new_sha, - "pusher": pusher_did, - "node": node_did, - "ts": issued_at, - "seq": seq, - "prev": prev, - "pusher_sig": cert.pusher_sig, - }); - let payload_bytes = serde_json::to_vec(&payload)?; - let signature = state.node_keypair.sign_b64(&payload_bytes); - let retry_cert = RefCertificate { - seq, - prev, - signature, - ..cert - }; - state.db.insert_ref_certificate(&retry_cert).await + // Retry once with a fresh transaction + let mut tx = state.db.pool().begin().await?; + state + .db + .lock_repo_cert_issuance_tx(repo_id, tx.deref_mut()) + .await?; + let cert = issue_once( + state, + repo_id, + ref_name, + old_sha, + new_sha, + pusher_did, + &pusher_sig, + &signature_input, + &content_digest, + &request_path, + &mut tx, + ) + .await?; + tx.commit().await?; + Ok(cert) } else { Err(e) } diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 4d4b5c56..3d653f16 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -88,7 +88,13 @@ pub struct Config { /// Bundler URL for Arweave permanent anchoring (Turbo/upload.ardrive.io). /// Leave empty to disable anchoring. - #[arg(long, env = "GITLAWB_BUNDLER_URL", default_value = "")] + /// Deprecated alias: --irys-url (renamed after the Irys→Bundler rebrand). + #[arg( + long, + env = "GITLAWB_BUNDLER_URL", + default_value = "", + alias = "irys-url" + )] pub bundler_url: String, /// Arweave gateway URL for resolving arweave_tx_id to data items. diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 2a867b78..9d99c463 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2104,9 +2104,9 @@ impl Db { pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result { let row = sqlx::query( "INSERT INTO ref_certificates - (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path", + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path", ) .bind(&cert.id) .bind(&cert.repo_id) @@ -2128,6 +2128,40 @@ impl Db { Ok(row_to_cert(row)) } + /// Transaction-scoped variant of [`insert_ref_certificate`]. + /// Uses the same advisory-lock hash for the repo_id so the lock key + /// stays consistent with [`lock_repo_cert_issuance`]. + pub async fn insert_ref_certificate_tx( + &self, + cert: &RefCertificate, + conn: &mut sqlx::postgres::PgConnection, + ) -> Result { + let row = sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path", + ) + .bind(&cert.id) + .bind(&cert.repo_id) + .bind(&cert.ref_name) + .bind(&cert.old_sha) + .bind(&cert.new_sha) + .bind(&cert.pusher_did) + .bind(&cert.node_did) + .bind(&cert.signature) + .bind(&cert.issued_at) + .bind(cert.seq) + .bind(&cert.prev) + .bind(&cert.pusher_sig) + .bind(&cert.signature_input) + .bind(&cert.content_digest) + .bind(&cert.request_path) + .fetch_one(&mut *conn) + .await?; + Ok(row_to_cert(row)) + } + pub async fn list_ref_certificates( &self, repo_id: &str, @@ -2206,27 +2240,58 @@ impl Db { Ok(row.map(row_to_cert)) } + /// Transaction-scoped variant of [`get_most_recent_cert`]. + pub async fn get_most_recent_cert_tx( + &self, + repo_id: &str, + conn: &mut sqlx::postgres::PgConnection, + ) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC LIMIT 1", + ) + .bind(repo_id) + .fetch_optional(&mut *conn) + .await?; + Ok(row.map(row_to_cert)) + } + /// Acquire a per-repo advisory lock to serialize certificate issuance. /// This prevents two concurrent pushes to the same repo from racing on /// the sequence number allocation. + /// Uses a transaction-scoped lock (`pg_advisory_xact_lock`) so it MUST + /// be called within an active transaction to be effective. pub async fn lock_repo_cert_issuance(&self, repo_id: &str) -> Result<()> { - // Use a hash of repo_id as the advisory lock key so we get a stable - // i64 value. FNV-1a 64-bit is sufficient — collision risk is negligible - // and a false collision would only serialize unrelated repos. - // Use the std DefaultHasher (SipHash-2-4) for a stable hash. - // Collision risk is negligible and would only serialize unrelated repos. - let hash = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - repo_id.hash(&mut h); - h.finish() as i64 - }; + let hash = repo_lock_hash(repo_id); sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(hash) .execute(&self.pool) .await?; Ok(()) } + + /// Transaction-scoped variant of [`lock_repo_cert_issuance`]. + /// The lock is held until the enclosing transaction commits or rolls back. + pub async fn lock_repo_cert_issuance_tx( + &self, + repo_id: &str, + conn: &mut sqlx::postgres::PgConnection, + ) -> Result<()> { + let hash = repo_lock_hash(repo_id); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(hash) + .execute(&mut *conn) + .await?; + Ok(()) + } +} + +/// Deterministic 64-bit hash of a repo_id for advisory lock keys. +fn repo_lock_hash(repo_id: &str) -> i64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + repo_id.hash(&mut h); + h.finish() as i64 } // ── Peers ───────────────────────────────────────────────────────────────────── @@ -5565,6 +5630,13 @@ mod ref_certificate_tests { use super::{Db, RefCertificate, RepoRecord}; use chrono::Utc; use sqlx::PgPool; + use std::sync::atomic::{AtomicI64, Ordering}; + + static NEXT_SEQ: AtomicI64 = AtomicI64::new(1); + + fn next_seq() -> i64 { + NEXT_SEQ.fetch_add(1, Ordering::Relaxed) + } async fn db(pool: PgPool) -> Db { let db = Db::for_testing(pool); @@ -5590,7 +5662,7 @@ mod ref_certificate_tests { node_did: "did:key:zNODE".to_string(), signature: "sig".to_string(), issued_at: issued_at.to_string(), - seq: 1, + seq: next_seq(), prev: "0".repeat(64), pusher_sig: None, signature_input: None, @@ -5759,11 +5831,17 @@ mod ref_certificate_tests { async fn v10_dedup_removes_old_duplicates(pool: PgPool) { let db = db(pool.clone()).await; - // Drop the unique index so we can simulate pre-v10 duplicate rows. + // Drop the unique indexes so we can simulate pre-v10 duplicate rows. + // v13's (repo_id, seq) index must also be removed because raw INSERTS + // without an explicit seq all get DEFAULT 1. sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") .execute(&pool) .await .unwrap(); + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_seq") + .execute(&pool) + .await + .unwrap(); let repo_id = uuid::Uuid::new_v4().to_string(); db.create_repo(&RepoRecord { @@ -5863,12 +5941,17 @@ mod ref_certificate_tests { let db = Db::for_testing(pool.clone()); db.run_migrations().await.unwrap(); - // 2. Roll back to v9: remove the v10-unique index and the + // 2. Roll back to v9: remove unique indexes and the // schema_migrations record so that run_migrations() re-applies v10. + // Also drop v13's (repo_id, seq) index so raw INSERTS below work. sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") .execute(&pool) .await .unwrap(); + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_seq") + .execute(&pool) + .await + .unwrap(); sqlx::query("DELETE FROM schema_migrations WHERE version = 10") .execute(&pool) .await @@ -6028,33 +6111,26 @@ mod ref_certificate_tests { "non-duplicate singleton untouched" ); - // 6. Verify the unique index exists: the upsert helper must succeed - // (exercises ON CONFLICT) and a direct duplicate INSERT must fail. + // 6. Verify the unique indexes exist: an append-only INSERT for + // a new (repo_id, ref_name) succeeds, and a raw INSERT for an + // existing (repo_id, ref_name) must fail (catches regressions). db.insert_ref_certificate(&make_cert( - "post-migration-upsert", + "post-migration-insert", &r1, - "refs/heads/main", + "refs/heads/new-ref", "1111", "2222", "2026-07-03T10:00:00Z", )) .await .unwrap(); - let after_upsert = db.list_ref_certificates(&r1, 10).await.unwrap(); - let r1_main_after: Vec<_> = after_upsert - .iter() - .filter(|c| c.ref_name == "refs/heads/main") - .collect(); - assert_eq!( - r1_main_after.len(), - 1, - "upsert keeps exactly one row for main" - ); - assert_eq!( - r1_main_after[0].id, "dup-a-new", - "upsert preserves original id" + let after_migration = db.list_ref_certificates(&r1, 10).await.unwrap(); + assert!( + after_migration + .iter() + .any(|c| c.id == "post-migration-insert"), + "append-only insert for new ref succeeds" ); - assert_eq!(r1_main_after[0].old_sha, "1111", "upsert updated old_sha"); // A raw INSERT for the same (repo_id, ref_name) must now fail. let err = sqlx::query( diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index f53e2d95..5df19f04 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -1478,7 +1478,7 @@ mod tests { node_did: owner.to_string(), signature: "sig".to_string(), issued_at: Utc::now().to_rfc3339(), - seq: 1, + seq: next_cert_seq(), prev: "0".repeat(64), pusher_sig: None, signature_input: None, @@ -4964,6 +4964,13 @@ mod tests { // ── #147: list_certs respects ?limit ────────────────────────────────────── + use std::sync::atomic::{AtomicI64, Ordering}; + static NEXT_CERT_SEQ: AtomicI64 = AtomicI64::new(1); + + fn next_cert_seq() -> i64 { + NEXT_CERT_SEQ.fetch_add(1, Ordering::Relaxed) + } + fn seed_cert( id: &str, repo_id: &str, @@ -4980,7 +4987,7 @@ mod tests { node_did: "did:key:zNODE".into(), signature: "sig".into(), issued_at: issued_at.to_string(), - seq: 1, + seq: next_cert_seq(), prev: "0".repeat(64), pusher_sig: None, signature_input: None, From 511d379000c3a857e94a5b8a89d58dec07c74d84 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 23 Jul 2026 11:25:25 +0600 Subject: [PATCH 07/25] suppress dead_code warnings on pool-based cert methods used by tests --- crates/gitlawb-node/src/db/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 9d99c463..8a1d3188 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2101,6 +2101,7 @@ impl Db { /// Insert a ref certificate (append-only). The unique constraint on /// `(repo_id, seq)` prevents duplicate sequence numbers; callers must /// handle retry on collision. + #[allow(dead_code)] pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result { let row = sqlx::query( "INSERT INTO ref_certificates @@ -2229,6 +2230,7 @@ impl Db { Ok(row.map(row_to_cert)) } + #[allow(dead_code)] pub async fn get_most_recent_cert(&self, repo_id: &str) -> Result> { let row = sqlx::query( "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path @@ -2261,6 +2263,7 @@ impl Db { /// the sequence number allocation. /// Uses a transaction-scoped lock (`pg_advisory_xact_lock`) so it MUST /// be called within an active transaction to be effective. + #[allow(dead_code)] pub async fn lock_repo_cert_issuance(&self, repo_id: &str) -> Result<()> { let hash = repo_lock_hash(repo_id); sqlx::query("SELECT pg_advisory_xact_lock($1)") From 759ef3ff778d5b5e893d05e4b80e6bd155351e3d Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 23 Jul 2026 12:21:20 +0600 Subject: [PATCH 08/25] include signature_input, content_digest, request_path in cert JSON responses --- crates/gitlawb-node/src/api/certs.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/gitlawb-node/src/api/certs.rs b/crates/gitlawb-node/src/api/certs.rs index 237528de..dbf60c67 100644 --- a/crates/gitlawb-node/src/api/certs.rs +++ b/crates/gitlawb-node/src/api/certs.rs @@ -52,9 +52,12 @@ pub async fn list_certs( "node_did": c.node_did, "signature": c.signature, "issued_at": c.issued_at, - "seq": c.seq, - "prev": c.prev, - "pusher_sig": c.pusher_sig, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + "signature_input": c.signature_input, + "content_digest": c.content_digest, + "request_path": c.request_path, }) }) .collect(); @@ -95,8 +98,11 @@ pub async fn get_cert( "node_did": cert.node_did, "signature": cert.signature, "issued_at": cert.issued_at, - "seq": cert.seq, - "prev": cert.prev, - "pusher_sig": cert.pusher_sig, + "seq": cert.seq, + "prev": cert.prev, + "pusher_sig": cert.pusher_sig, + "signature_input": cert.signature_input, + "content_digest": cert.content_digest, + "request_path": cert.request_path, }))) } From cf51c26cb70fa76eb8aab881de1d9da58a848243 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 23 Jul 2026 14:10:49 +0600 Subject: [PATCH 09/25] address review findings: cert_id in anchor, graceful parse, arweave rate limit - serde_json parse failure in arweave verify returns VerifyResult instead of propagating as AppError::Internal - RecordAnchorInputV2 replaces unused gateway_url with cert_id, persisted in arweave_anchors INSERT, sourced from push-time certificate - arweave routes wrapped with per-IP IpRateLimiter --- crates/gitlawb-node/src/api/repos.rs | 4 ++-- crates/gitlawb-node/src/arweave.rs | 15 +++++++++++++-- crates/gitlawb-node/src/db/mod.rs | 21 +++++++++++---------- crates/gitlawb-node/src/server.rs | 10 +++++++++- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 3a32819d..3f318d3c 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1233,7 +1233,6 @@ pub async fn git_receive_pack( let pusher_did_clone = did.to_string(); let db_for_peers = state.db.clone(); let ref_update_tx = state.ref_update_tx.clone(); - let arweave_gateway = state.config.arweave_gateway.clone(); let bundler_url = state.config.bundler_url.clone(); let owner_did_for_arweave = record.owner_did.clone(); let self_public_url = state.config.public_url.clone(); @@ -1324,6 +1323,7 @@ pub async fn git_receive_pack( // repo-wide latest, so each anchor embeds the exact // certificate for its own ref transition. let cert = ref_certs_clone.get(ref_name).cloned(); + let cert_id = cert.as_ref().map(|c| c.id.clone()); let anchor = crate::arweave::RefAnchor { repo: repo_slug.clone(), owner_did: owner_did_for_arweave.clone(), @@ -1349,7 +1349,7 @@ pub async fn git_receive_pack( cid: cid.as_deref(), arweave_tx_id: &tx_id, node_did: &node_did_str, - gateway_url: &arweave_gateway, + cert_id, }) .await; } diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index b22d0565..dc0302fe 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -300,8 +300,19 @@ pub async fn verify_anchor( }); } - // Parse the payload — could be JSON or raw bytes depending on gateway - let anchor: serde_json::Value = serde_json::from_slice(&body_bytes)?; + // Parse the payload — could be JSON or raw bytes depending on gateway. + // Non-JSON responses are handled as an invalid result rather than an error. + let anchor: serde_json::Value = match serde_json::from_slice(&body_bytes) { + Ok(v) => v, + Err(e) => { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!("anchor payload is not valid JSON: {e}")], + }); + } + }; let cert_value = anchor.get("certificate"); let cert: Option = match cert_value { diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 8a1d3188..3cdcfb0f 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2929,8 +2929,8 @@ pub struct RecordAnchorInputV2<'a> { pub cid: Option<&'a str>, pub arweave_tx_id: &'a str, pub node_did: &'a str, - #[allow(dead_code)] - pub gateway_url: &'a str, + /// ID of the [`RefCertificate`] embedded in this anchor, if any. + pub cert_id: Option, } impl Db { @@ -2938,8 +2938,8 @@ impl Db { let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, cert_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)", ) .bind(&id) .bind(input.repo) @@ -2952,6 +2952,7 @@ impl Db { .bind(input.node_did) .bind(&now) .bind("pending") + .bind(input.cert_id.clone()) .execute(&self.pool) .await?; Ok(()) @@ -6232,7 +6233,7 @@ mod arweave_anchor_tests { cid: Some("bafyreib5..."), arweave_tx_id: "test-tx-id-123", node_did: "did:key:zNODE", - gateway_url: "https://arweave.net", + cert_id: None, }; db.record_arweave_anchor(&input).await.unwrap(); @@ -6258,7 +6259,7 @@ mod arweave_anchor_tests { cid: None, arweave_tx_id: "tx-confirm", node_did: "did:key:zNODE", - gateway_url: "https://arweave.net", + cert_id: None, }; db.record_arweave_anchor(&input).await.unwrap(); @@ -6293,7 +6294,7 @@ mod arweave_anchor_tests { cid: None, arweave_tx_id: "tx-fail", node_did: "did:key:zNODE", - gateway_url: "https://arweave.net", + cert_id: None, }; db.record_arweave_anchor(&input).await.unwrap(); @@ -6326,12 +6327,12 @@ mod arweave_anchor_tests { cid: None, arweave_tx_id: "tx-pending-1", node_did: "did:key:zNODE", - gateway_url: "https://arweave.net", + cert_id: None, }) .await .unwrap(); - // Record a second anchor for the same repo + // Record a second anchor for a different repo db.record_arweave_anchor(&RecordAnchorInputV2 { repo: "dave/repo-b", owner_did: "did:key:zOWNER", @@ -6341,7 +6342,7 @@ mod arweave_anchor_tests { cid: None, arweave_tx_id: "tx-pending-2", node_did: "did:key:zNODE", - gateway_url: "https://arweave.net", + cert_id: None, }) .await .unwrap(); diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 964bfb6d..22033357 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -220,12 +220,20 @@ pub fn build_router(state: AppState) -> Router { .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))); // ── Arweave permanent anchors ────────────────────────────────────────── + // Rate-limited per-IP to prevent the verify endpoint from being used as an + // open arweave gateway proxy or abused in a resource-exhaustion attack. + let arweave_limiter = rate_limit::IpRateLimiter { + limiter: state.rate_limiter.clone(), + trust: state.push_limiter_trust, + }; let arweave_routes = Router::new() .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) .route( "/api/v1/arweave/verify/{tx_id}", get(arweave::verify_anchor_endpoint), - ); + ) + .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) + .layer(axum::Extension(arweave_limiter)); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( From 7723d9bc03b277a887b5d86c7e4d627a4f951bc9 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 24 Jul 2026 13:09:28 +0600 Subject: [PATCH 10/25] address review findings: repo_id cross-check, gl cert payload, pusher proof, streaming body, v13 test, dead code cleanup, dedicated arweave limiter, P3 fixes P1: - arweave.rs: add repo_id to RefAnchor, cross-check compares UUID vs UUID - gl/src/cert.rs: update verify_signature to sign 10 fields (seq, prev, pusher_sig) P2: - cert.rs: bind signature_input/content_digest/request_path into node payload - arweave.rs: fail closed when pusher_sig present but context fields missing - arweave.rs: replace Content-Length body guard with streaming 1 MiB chunk cap - db/mod.rs: add v13_seq_backfill_via_migration upgrade-path test - db/mod.rs: remove dead confirm/fail/list_pending_anchors methods and tests - api/repos.rs: log record_arweave_anchor failures via tracing::warn - server.rs: mask credential URLs in contracts_info endpoint - state/main/server: add dedicated arweave_rate_limiter (GITLAWB_ARWEAVE_RATE_LIMIT) P3: - api/arweave.rs: validate tx_id (43-char base64url) before gateway fetch - arweave.rs: make old_sha/new_sha/node_did mandatory when cert is present - db/mod.rs: replace DefaultHasher with stable SHA-256 prefix for lock keys - db/mod.rs: document v1 schema inclusion of later columns - api/events.rs: include seq, prev, pusher_sig, context fields in local_cert events --- Cargo.lock | 15 ++ Cargo.toml | 2 +- crates/gitlawb-node/src/api/arweave.rs | 17 +- crates/gitlawb-node/src/api/events.rs | 28 ++- crates/gitlawb-node/src/api/repos.rs | 8 +- crates/gitlawb-node/src/arweave.rs | 236 +++++++++++-------- crates/gitlawb-node/src/auth/mod.rs | 1 + crates/gitlawb-node/src/cert.rs | 9 + crates/gitlawb-node/src/db/mod.rs | 286 +++++++++--------------- crates/gitlawb-node/src/main.rs | 18 ++ crates/gitlawb-node/src/server.rs | 19 +- crates/gitlawb-node/src/state.rs | 4 + crates/gitlawb-node/src/test_support.rs | 1 + crates/gl/src/cert.rs | 44 +++- 14 files changed, 393 insertions(+), 295 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cf2b442..c2830ad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5705,12 +5705,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.6", ] @@ -7477,6 +7479,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index b2fd6c07..8a06e307 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ chrono = { version = "0.4", features = ["serde"] } # uuid uuid = { version = "1", features = ["v4"] } # http client -reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls", "stream"], default-features = false } # HMAC hmac = "0.12" diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 7231b62c..f649cff3 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -6,9 +6,19 @@ use axum::{ }; use serde::Deserialize; -use crate::error::Result; +use crate::error::{AppError, Result}; use crate::state::AppState; +/// Validate an Arweave transaction ID: 43-character base64url string. +fn is_valid_tx_id(tx_id: &str) -> bool { + if tx_id.len() != 43 { + return false; + } + tx_id + .bytes() + .all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')) +} + /// GET /api/v1/arweave/verify/:tx_id /// /// Fetch the anchor from Arweave via the configured gateway, extract the embedded @@ -20,6 +30,11 @@ pub async fn verify_anchor_endpoint( State(state): State, Path(tx_id): Path, ) -> Result> { + if !is_valid_tx_id(&tx_id) { + return Err(AppError::BadRequest( + "invalid transaction ID: expected 43-character base64url".to_string(), + )); + } let gateway = &state.config.arweave_gateway; let result = crate::arweave::verify_anchor(&state.http_client, gateway, &tx_id, &state.db) .await diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 4a3d2a4b..0c998fac 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -241,17 +241,23 @@ pub async fn list_repo_events( .iter() .map(|c| { serde_json::json!({ - "type": "local_cert", - "id": c.id, - "repo": repo_id_str, - "ref_name": c.ref_name, - "old_sha": c.old_sha, - "new_sha": c.new_sha, - "pusher_did": c.pusher_did, - "node_did": c.node_did, - "timestamp": c.issued_at, - "owner_did": record.owner_did, - "source": "local", + "type": "local_cert", + "id": c.id, + "repo": repo_id_str, + "ref_name": c.ref_name, + "old_sha": c.old_sha, + "new_sha": c.new_sha, + "pusher_did": c.pusher_did, + "node_did": c.node_did, + "timestamp": c.issued_at, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + "signature_input": c.signature_input, + "content_digest": c.content_digest, + "request_path": c.request_path, + "owner_did": record.owner_did, + "source": "local", }) }) .collect(); diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 3f318d3c..e4884337 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1326,6 +1326,7 @@ pub async fn git_receive_pack( let cert_id = cert.as_ref().map(|c| c.id.clone()); let anchor = crate::arweave::RefAnchor { repo: repo_slug.clone(), + repo_id: record.id.clone(), owner_did: owner_did_for_arweave.clone(), ref_name: ref_name.clone(), old_sha: old_sha.clone(), @@ -1339,7 +1340,7 @@ pub async fn git_receive_pack( .await { Ok(tx_id) if !tx_id.is_empty() => { - let _ = db_clone + if let Err(e) = db_clone .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { repo: &repo_slug, owner_did: &owner_did_for_arweave, @@ -1351,7 +1352,10 @@ pub async fn git_receive_pack( node_did: &node_did_str, cert_id, }) - .await; + .await + { + tracing::warn!(repo=%repo_slug, tx_id=%tx_id, err=%e, "failed to persist arweave anchor"); + } } Ok(_) => {} Err(e) => tracing::warn!(repo=%repo_slug, err=%e, "Arweave anchor failed"), diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index dc0302fe..cef06090 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -19,6 +19,7 @@ use anyhow::Result; use base64::Engine as _; +use futures::StreamExt; use serde::Serialize; use serde_json::json; use sha2::Digest; @@ -29,6 +30,7 @@ use std::str::FromStr; #[derive(Debug, Clone)] pub struct RefAnchor { pub repo: String, + pub repo_id: String, pub owner_did: String, pub ref_name: String, pub old_sha: String, @@ -58,6 +60,7 @@ pub async fn anchor_ref_update( let mut payload = json!({ "schema": "gitlawb/ref-update/v1", "repo": anchor.repo, + "repo_id": anchor.repo_id, "owner_did": anchor.owner_did, "ref_name": anchor.ref_name, "old_sha": anchor.old_sha, @@ -278,10 +281,23 @@ pub async fn verify_anchor( errors: vec![format!("Arweave gateway returned {}", resp.status())], }); } - // Bound the untrusted response to 1 MiB to prevent memory exhaustion. - // Check Content-Length first so we never buffer a giant body. - if let Some(cl) = resp.content_length() { - if cl > 1_048_576 { + // Stream the response body with a running 1 MiB cap so a chunked or + // header-omitting gateway cannot drive multi-hundred-MB allocations. + let mut body_bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let data = match chunk { + Ok(d) => d, + Err(e) => { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!("failed to read response body: {e}")], + }); + } + }; + if body_bytes.len() + data.len() > 1_048_576 { return Ok(VerifyResult { valid: false, anchor: serde_json::Value::Null, @@ -289,15 +305,7 @@ pub async fn verify_anchor( errors: vec!["response body exceeds 1 MiB limit".to_string()], }); } - } - let body_bytes = resp.bytes().await?; - if body_bytes.len() > 1_048_576 { - return Ok(VerifyResult { - valid: false, - anchor: serde_json::Value::Null, - certificate: None, - errors: vec!["response body exceeds 1 MiB limit".to_string()], - }); + body_bytes.extend_from_slice(&data); } // Parse the payload — could be JSON or raw bytes depending on gateway. @@ -325,17 +333,19 @@ pub async fn verify_anchor( if let Some(ref c) = cert { // 0. Cross-check the outer anchor fields against the embedded certificate. // A valid anchor must commit to the same identities and ref state. - let outer_repo = anchor.get("repo").and_then(|v| v.as_str()); + // The outer repo_id (UUID) is compared against the cert's repo_id (UUID) + // to avoid comparing a human-readable slug against a UUID. + let outer_repo_id = anchor.get("repo_id").and_then(|v| v.as_str()); let outer_ref = anchor.get("ref_name").and_then(|v| v.as_str()); let outer_old = anchor.get("old_sha").and_then(|v| v.as_str()); let outer_new = anchor.get("new_sha").and_then(|v| v.as_str()); let outer_node = anchor.get("node_did").and_then(|v| v.as_str()); - if outer_repo.is_none() { - errors.push("anchor payload is missing top-level 'repo'".to_string()); - } else if outer_repo != Some(&c.repo_id) { + if outer_repo_id.is_none() { + errors.push("anchor payload is missing top-level 'repo_id'".to_string()); + } else if outer_repo_id != Some(&c.repo_id) { errors.push(format!( - "anchor outer repo ({}) does not match certificate repo_id ({})", - outer_repo.unwrap_or(""), + "anchor outer repo_id ({}) does not match certificate repo_id ({})", + outer_repo_id.unwrap_or(""), c.repo_id )); } @@ -348,21 +358,30 @@ pub async fn verify_anchor( c.ref_name )); } - if outer_old.is_some() && outer_old != Some(&c.old_sha) { + // Fail closed: old_sha, new_sha, and node_did are mandatory in the + // outer anchor when a certificate is embedded. A forger who omits + // them must not pass verification. + if outer_old.is_none() { + errors.push("anchor payload is missing top-level 'old_sha'".to_string()); + } else if outer_old != Some(&c.old_sha) { errors.push(format!( "anchor outer old_sha ({}) does not match certificate old_sha ({})", outer_old.unwrap_or(""), c.old_sha )); } - if outer_new.is_some() && outer_new != Some(&c.new_sha) { + if outer_new.is_none() { + errors.push("anchor payload is missing top-level 'new_sha'".to_string()); + } else if outer_new != Some(&c.new_sha) { errors.push(format!( "anchor outer new_sha ({}) does not match certificate new_sha ({})", outer_new.unwrap_or(""), c.new_sha )); } - if outer_node.is_some() && outer_node != Some(&c.node_did) { + if outer_node.is_none() { + errors.push("anchor payload is missing top-level 'node_did'".to_string()); + } else if outer_node != Some(&c.node_did) { errors.push(format!( "anchor outer node_did ({}) does not match certificate node_did ({})", outer_node.unwrap_or(""), @@ -379,9 +398,12 @@ pub async fn verify_anchor( "pusher": c.pusher_did, "node": c.node_did, "ts": c.issued_at, - "seq": c.seq, - "prev": c.prev, - "pusher_sig": c.pusher_sig, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + "signature_input": c.signature_input, + "content_digest": c.content_digest, + "request_path": c.request_path, }); let payload_bytes = serde_json::to_vec(&payload)?; @@ -460,92 +482,109 @@ pub async fn verify_anchor( } } - // 3. Verify the pusher authorization proof (RFC 9421 HTTP Signature) - // when all required context is available. - if let (Some(pusher_sig), Some(sig_input), Some(content_digest), Some(request_path)) = ( - &c.pusher_sig, - &c.signature_input, - &c.content_digest, - &c.request_path, - ) { - match gitlawb_core::http_sig::HttpSignature::parse( - sig_input, - &format!("sig1=:{pusher_sig}:"), - ) { - Ok(http_sig) => { - let mut request_values: HashMap = HashMap::new(); - request_values.insert("@method".to_string(), "POST".to_string()); - request_values.insert("@path".to_string(), request_path.clone()); - request_values.insert("content-digest".to_string(), content_digest.clone()); - - let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input); - let components_ref: Vec<&str> = - http_sig.components.iter().map(String::as_str).collect(); - - match gitlawb_core::http_sig::build_signing_string( - &components_ref, - sig_params_value, - &request_values, + // 3. Verify the pusher authorization proof (RFC 9421 HTTP Signature). + // The context fields (signature_input, content_digest, request_path) + // are bound into the node signing payload, so a certificate whose + // node signature verified already commits to them. When pusher_sig + // is present but a context field is missing, the proof cannot be + // checked and is treated as invalid rather than silently skipped. + if let Some(pusher_sig) = &c.pusher_sig { + match (&c.signature_input, &c.content_digest, &c.request_path) { + (Some(sig_input), Some(content_digest), Some(request_path)) => { + match gitlawb_core::http_sig::HttpSignature::parse( + sig_input, + &format!("sig1=:{pusher_sig}:"), ) { - Ok(signing_string) => { - let pusher_did = gitlawb_core::did::Did::from_str(&c.pusher_did); - let pusher_vk = pusher_did.and_then(|d| d.to_verifying_key()); - match pusher_vk { - Ok(vk) => { - let sig_bytes: [u8; 64] = - match base64::engine::general_purpose::STANDARD - .decode(pusher_sig) - { - Ok(bytes) => match bytes.as_slice().try_into() { - Ok(a) => a, - Err(_) => { - errors.push( + Ok(http_sig) => { + let mut request_values: HashMap = HashMap::new(); + request_values.insert("@method".to_string(), "POST".to_string()); + request_values.insert("@path".to_string(), request_path.clone()); + request_values + .insert("content-digest".to_string(), content_digest.clone()); + + let sig_params_value = + sig_input.strip_prefix("sig1=").unwrap_or(sig_input); + let components_ref: Vec<&str> = + http_sig.components.iter().map(String::as_str).collect(); + + match gitlawb_core::http_sig::build_signing_string( + &components_ref, + sig_params_value, + &request_values, + ) { + Ok(signing_string) => { + let pusher_did = + gitlawb_core::did::Did::from_str(&c.pusher_did); + let pusher_vk = pusher_did.and_then(|d| d.to_verifying_key()); + match pusher_vk { + Ok(vk) => { + let sig_bytes: [u8; 64] = + match base64::engine::general_purpose::STANDARD + .decode(pusher_sig) + { + Ok(bytes) => { + match bytes.as_slice().try_into() { + Ok(a) => a, + Err(_) => { + errors.push( "pusher signature is not 64 bytes" .to_string(), ); - return Ok(VerifyResult { - valid: false, - anchor, - certificate: cert, - errors, - }); - } - }, - Err(_) => { - errors.push( - "pusher signature is not valid base64" - .to_string(), - ); - return Ok(VerifyResult { - valid: false, - anchor, - certificate: cert, - errors, - }); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + } + } + Err(_) => { + errors.push( + "pusher signature is not valid base64" + .to_string(), + ); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + if let Err(e) = gitlawb_core::identity::verify( + &vk, + signing_string.as_bytes(), + &sig_bytes, + ) { + errors.push(format!( + "pusher signature verification failed: {e}" + )); } - }; - if let Err(e) = gitlawb_core::identity::verify( - &vk, - signing_string.as_bytes(), - &sig_bytes, - ) { - errors.push(format!( - "pusher signature verification failed: {e}" - )); + } + Err(e) => { + errors.push(format!("unresolvable pusher DID: {e}")); + } } } Err(e) => { - errors.push(format!("unresolvable pusher DID: {e}")); + errors.push(format!("failed to build signing string: {e}")); } } } Err(e) => { - errors.push(format!("failed to build signing string: {e}")); + errors.push(format!("failed to parse pusher Signature-Input: {e}")); } - } + } // inner match } - Err(e) => { - errors.push(format!("failed to parse pusher Signature-Input: {e}")); + (sig_input, content_digest, request_path) => { + errors.push(format!( + "pusher signature present but context fields incomplete \ + (signature_input={}, content_digest={}, request_path={})", + sig_input.is_some(), + content_digest.is_some(), + request_path.is_some(), + )); } } } @@ -570,6 +609,7 @@ mod tests { let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), owner_did: "did:key:z6Mk...".into(), ref_name: "refs/heads/main".into(), old_sha: "0000000000000000000000000000000000000000".into(), @@ -598,6 +638,7 @@ mod tests { let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), owner_did: "did:key:z6Mk...".into(), ref_name: "refs/heads/main".into(), old_sha: "0".repeat(40), @@ -640,6 +681,7 @@ mod tests { let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), owner_did: "did:key:z6Mk...".into(), ref_name: "refs/heads/main".into(), old_sha: real_old.into(), diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index d5826c93..64232bb8 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -541,6 +541,7 @@ mod tests { 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)), + arweave_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)), create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)), push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), push_limiter_trust: crate::rate_limit::TrustedProxy::None, diff --git a/crates/gitlawb-node/src/cert.rs b/crates/gitlawb-node/src/cert.rs index 9e1a85a0..e8d2b79b 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -21,6 +21,9 @@ fn cert_payload( seq: i64, prev: &str, pusher_sig: Option, + signature_input: Option, + content_digest: Option, + request_path: Option, ) -> serde_json::Value { serde_json::json!({ "repo_id": repo_id, @@ -33,6 +36,9 @@ fn cert_payload( "seq": seq, "prev": prev, "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, }) } @@ -88,6 +94,9 @@ async fn issue_once( seq, &prev, pusher_sig.clone(), + signature_input.clone(), + content_digest.clone(), + request_path.clone(), ); let payload_bytes = serde_json::to_vec(&payload)?; let signature = state.node_keypair.sign_b64(&payload_bytes); diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 3cdcfb0f..72117ddd 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -467,6 +467,14 @@ impl Db { // appended to v1. Operators can read `schema_migrations` to confirm a node // is at the expected version. // +// NOTE: the v1 migration in this branch already includes columns (seq, prev, +// pusher_sig on ref_certificates; status, deadline_height, receipt_sig, cert_id +// on arweave_anchors) that were historically added by migrations v12/v13. These +// were bundled into v1 for development convenience and kept there to avoid a +// schema reset. The v12/v13 migrations remain as no-ops for existing installs +// that upgrade from an older branch. See v12/v13 for the actual DDL that newer +// installs skip via IF NOT EXISTS. +// // Each migration runs in a single transaction, so statements that Postgres // forbids inside a transaction (notably `CREATE INDEX CONCURRENTLY`) cannot be // used here. Build such indexes the ordinary, transaction-safe way, or stage @@ -2290,11 +2298,12 @@ impl Db { } /// Deterministic 64-bit hash of a repo_id for advisory lock keys. +/// Uses the first 8 bytes of SHA-256 rather than DefaultHasher (which the +/// std docs do not guarantee stable across Rust versions or platforms). fn repo_lock_hash(repo_id: &str) -> i64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - repo_id.hash(&mut h); - h.finish() as i64 + use sha2::Digest; + let hash = sha2::Sha256::digest(repo_id.as_bytes()); + i64::from_ne_bytes(hash[..8].try_into().expect("sha256 output >= 8 bytes")) } // ── Peers ───────────────────────────────────────────────────────────────────── @@ -3002,65 +3011,6 @@ impl Db { }) .collect()) } - - #[allow(dead_code)] - /// Update the anchor status to confirmed with receipt details. - pub async fn confirm_arweave_anchor( - &self, - id: &str, - deadline_height: i64, - receipt_sig: &str, - ) -> Result<()> { - sqlx::query( - "UPDATE arweave_anchors SET status='confirmed', deadline_height=$1, receipt_sig=$2 WHERE id=$3", - ) - .bind(deadline_height) - .bind(receipt_sig) - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - #[allow(dead_code)] - /// Mark an anchor as failed (retries exhausted). - pub async fn fail_arweave_anchor(&self, id: &str) -> Result<()> { - sqlx::query("UPDATE arweave_anchors SET status='failed' WHERE id=$1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - #[allow(dead_code)] - /// List pending anchors that need confirmation check. - pub async fn list_pending_anchors(&self) -> Result> { - let rows = sqlx::query( - "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, deadline_height, receipt_sig, cert_id - FROM arweave_anchors WHERE status='pending' ORDER BY anchored_at ASC", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows - .into_iter() - .map(|r| ArweaveAnchor { - id: r.get("id"), - repo: r.get("repo"), - owner_did: r.get("owner_did"), - ref_name: r.get("ref_name"), - old_sha: r.get("old_sha"), - new_sha: r.get("new_sha"), - cid: r.get("cid"), - arweave_tx_id: r.get("arweave_tx_id"), - node_did: r.get("node_did"), - anchored_at: r.get("anchored_at"), - status: r.get("status"), - deadline_height: r.try_get("deadline_height").unwrap_or(None), - receipt_sig: r.try_get("receipt_sig").unwrap_or(None), - cert_id: r.try_get("cert_id").unwrap_or(None), - }) - .collect()) - } } // ── Row helpers ─────────────────────────────────────────────────────────────── @@ -6159,6 +6109,103 @@ mod ref_certificate_tests { ); } + /// INV-7: upgrade-path test for migration v13 — seed a database at v12 + /// with multiple same-repo/different-ref certificates (all at seq=1), + /// then let run_migrations() apply v13 and verify (a) seq values are + /// distinct per repo, (b) the (repo_id, seq) unique index exists and + /// rejects a raw INSERT with a colliding seq. + #[sqlx::test] + async fn v13_seq_backfill_via_migration(pool: PgPool) { + // 1. Bootstrap schema via the full migration chain. + let db = Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + // 2. Roll back to v12: drop the (repo_id, seq) index and the + // schema_migrations record for v13 so run_migrations() re-applies it. + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_seq") + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM schema_migrations WHERE version = 13") + .execute(&pool) + .await + .unwrap(); + + // 3. Seed repos and certs (all with seq=DEFAULT 1). + let r1 = uuid::Uuid::new_v4().to_string(); + db.create_repo(&RepoRecord { + id: r1.clone(), + name: "v13-upgrade-a".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/v13-upgrade-a".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + // Insert 3 certs for repo r1 on different refs — all with seq=1 (DEFAULT). + for (i, ref_name) in ["refs/heads/main", "refs/heads/feature", "refs/heads/dev"] + .iter() + .enumerate() + { + sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind(format!("v13-cert-{i}")) + .bind(&r1) + .bind(ref_name) + .bind("0000") + .bind("1111") + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig") + .bind(format!("2026-07-0{}T12:00:00Z", i + 1)) + .execute(&pool) + .await + .unwrap(); + } + + // 4. Re-run migrations — v13 backfills seq. + db.run_migrations().await.unwrap(); + + // 5. Assert distinct seq values per repo. + let certs = db.list_ref_certificates(&r1, 10).await.unwrap(); + assert_eq!(certs.len(), 3, "all three certs survive the migration"); + let mut seqs: Vec = certs.iter().map(|c| c.seq).collect(); + seqs.sort(); + assert_eq!(seqs, vec![1, 2, 3], "seq values are distinct and ascending"); + + // 6. Raw INSERT with colliding seq must be rejected by the unique index. + let err = sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("collide-seq") + .bind(&r1) + .bind("refs/heads/other") + .bind("xxxx") + .bind("yyyy") + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig-collide") + .bind("2026-07-10T12:00:00Z") + .execute(&pool) + .await; + assert!( + err.is_err(), + "raw INSERT with default seq=1 must be rejected by the unique index" + ); + } + #[sqlx::test] async fn get_most_recent_cert_returns_highest_seq(pool: PgPool) { let db = db(pool).await; @@ -6246,119 +6293,6 @@ mod arweave_anchor_tests { assert_eq!(anchors[0].status, "pending", "default status is pending"); assert_eq!(anchors[0].arweave_tx_id, "test-tx-id-123"); } - - #[sqlx::test] - async fn confirm_anchor_updates_status(pool: PgPool) { - let db = db(pool).await; - let input = RecordAnchorInputV2 { - repo: "bob/myrepo", - owner_did: "did:key:zOWNER", - ref_name: "refs/heads/main", - old_sha: "0000000000000000000000000000000000000000", - new_sha: "1111111111111111111111111111111111111111", - cid: None, - arweave_tx_id: "tx-confirm", - node_did: "did:key:zNODE", - cert_id: None, - }; - db.record_arweave_anchor(&input).await.unwrap(); - - let anchors = db - .list_arweave_anchors(Some("bob/myrepo"), 10) - .await - .unwrap(); - let id = &anchors[0].id; - - db.confirm_arweave_anchor(id, 1234567, "receipt-sig-value") - .await - .unwrap(); - - let updated = db - .list_arweave_anchors(Some("bob/myrepo"), 10) - .await - .unwrap(); - assert_eq!(updated[0].status, "confirmed"); - assert_eq!(updated[0].deadline_height, Some(1234567)); - assert_eq!(updated[0].receipt_sig, Some("receipt-sig-value".into())); - } - - #[sqlx::test] - async fn fail_anchor_updates_status(pool: PgPool) { - let db = db(pool).await; - let input = RecordAnchorInputV2 { - repo: "carol/myrepo", - owner_did: "did:key:zOWNER", - ref_name: "refs/heads/main", - old_sha: "0000000000000000000000000000000000000000", - new_sha: "1111111111111111111111111111111111111111", - cid: None, - arweave_tx_id: "tx-fail", - node_did: "did:key:zNODE", - cert_id: None, - }; - db.record_arweave_anchor(&input).await.unwrap(); - - let anchors = db - .list_arweave_anchors(Some("carol/myrepo"), 10) - .await - .unwrap(); - let id = &anchors[0].id; - - db.fail_arweave_anchor(id).await.unwrap(); - - let updated = db - .list_arweave_anchors(Some("carol/myrepo"), 10) - .await - .unwrap(); - assert_eq!(updated[0].status, "failed"); - } - - #[sqlx::test] - async fn list_pending_anchors_returns_only_pending(pool: PgPool) { - let db = db(pool).await; - - // Record two anchors for different repos - db.record_arweave_anchor(&RecordAnchorInputV2 { - repo: "dave/repo-a", - owner_did: "did:key:zOWNER", - ref_name: "refs/heads/main", - old_sha: "0000000000000000000000000000000000000000", - new_sha: "1111111111111111111111111111111111111111", - cid: None, - arweave_tx_id: "tx-pending-1", - node_did: "did:key:zNODE", - cert_id: None, - }) - .await - .unwrap(); - - // Record a second anchor for a different repo - db.record_arweave_anchor(&RecordAnchorInputV2 { - repo: "dave/repo-b", - owner_did: "did:key:zOWNER", - ref_name: "refs/heads/feature", - old_sha: "aaaa", - new_sha: "bbbb", - cid: None, - arweave_tx_id: "tx-pending-2", - node_did: "did:key:zNODE", - cert_id: None, - }) - .await - .unwrap(); - - let pending = db.list_pending_anchors().await.unwrap(); - assert_eq!(pending.len(), 2, "both anchors are pending"); - - // Confirm one anchor - let first_id = pending[0].id.clone(); - db.confirm_arweave_anchor(&first_id, 100, "sig") - .await - .unwrap(); - - let pending_after = db.list_pending_anchors().await.unwrap(); - assert_eq!(pending_after.len(), 1, "only one pending remains"); - } } #[cfg(test)] mod ref_update_db_tests { diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index d9d805ad..a1219216 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -292,6 +292,23 @@ async fn main() -> Result<()> { let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone()); + // Per-client-IP limiter for the Arweave verify endpoint. The route is + // unauthenticated (anyone can check a tx_id) and the per-DID creation + // limiter is too restrictive (10/hr). GITLAWB_ARWEAVE_RATE_LIMIT overrides; + // 0 disables. Bounded key set — the key is a client-influenced IP. + let arweave_limit = std::env::var("GITLAWB_ARWEAVE_RATE_LIMIT") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(120); + let arweave_rate_limiter = rate_limit::RateLimiter::new_bounded( + arweave_limit, + std::time::Duration::from_secs(3600), + 200_000, + ); + if arweave_limit == 0 { + tracing::warn!("GITLAWB_ARWEAVE_RATE_LIMIT=0 — arweave IP rate limiting disabled"); + } + // Per-DID limiter for the creation endpoints. Keyed on the authenticated // DID (attacker-varied), so bound its key set to cap memory. let rate_limiter = @@ -382,6 +399,7 @@ async fn main() -> Result<()> { machine_id, repo_store, rate_limiter, + arweave_rate_limiter, create_ip_rate_limiter, push_rate_limiter, push_limiter_trust, diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 22033357..41a6f5ed 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -223,7 +223,7 @@ pub fn build_router(state: AppState) -> Router { // Rate-limited per-IP to prevent the verify endpoint from being used as an // open arweave gateway proxy or abused in a resource-exhaustion attack. let arweave_limiter = rate_limit::IpRateLimiter { - limiter: state.rate_limiter.clone(), + limiter: state.arweave_rate_limiter.clone(), trust: state.push_limiter_trust, }; let arweave_routes = Router::new() @@ -580,6 +580,17 @@ pub(crate) async fn stats(State(state): State) -> Json String { + if let Some(at_pos) = url.find('@') { + // Strip userinfo: keep everything after '@' + url[at_pos + 1..].to_string() + } else { + url.to_string() + } +} + async fn contracts_info(State(state): State) -> Json { let did_registry = &state.config.contract_did_registry; let name_registry = &state.config.contract_name_registry; @@ -592,15 +603,15 @@ async fn contracts_info(State(state): State) -> Json, pool: PgPool) -> AppState { 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)), + arweave_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)), create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)), push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), push_limiter_trust: crate::rate_limit::TrustedProxy::None, diff --git a/crates/gl/src/cert.rs b/crates/gl/src/cert.rs index 87ad5aec..e952ba7b 100644 --- a/crates/gl/src/cert.rs +++ b/crates/gl/src/cert.rs @@ -156,6 +156,9 @@ async fn cmd_show( let node_did = cert["node_did"].as_str().unwrap_or("?"); let signature = cert["signature"].as_str().unwrap_or("?"); let issued_at = cert["issued_at"].as_str().unwrap_or("?"); + let seq = cert["seq"].as_i64().unwrap_or(0); + let prev = cert["prev"].as_str().unwrap_or("?"); + let pusher_sig = cert["pusher_sig"].as_str().map(|s| s.to_string()); println!("Ref Certificate: {cert_id}"); println!(" Ref: {ref_name}"); @@ -163,6 +166,7 @@ async fn cmd_show( println!(" New SHA: {new_sha}"); println!(" Pusher: {pusher}"); println!(" Node DID: {node_did}"); + println!(" Seq: {seq}"); println!(" Issued at: {issued_at}"); println!(" Signature: {signature}"); println!(); @@ -174,7 +178,17 @@ async fn cmd_show( // names; the node-DID comparison below covers *which* node that is. let repo_id = cert["repo_id"].as_str().unwrap_or(""); let verdict = verify_signature( - repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature, + repo_id, + ref_name, + old_sha, + new_sha, + pusher, + node_did, + issued_at, + seq, + prev, + pusher_sig.as_deref(), + signature, ); println!("Signature verification:"); @@ -251,6 +265,9 @@ fn verify_signature( pusher: &str, node_did: &str, issued_at: &str, + seq: i64, + prev: &str, + pusher_sig: Option<&str>, signature_b64: &str, ) -> std::result::Result<(), String> { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; @@ -264,6 +281,9 @@ fn verify_signature( "pusher": pusher, "node": node_did, "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, }); let payload_bytes = serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?; @@ -334,11 +354,16 @@ mod tests { "pusher": "did:key:z6MkPusher", "node": "did:key:z6MkNode", "ts": "2026-07-22T00:00:00+00:00", + "seq": 1, + "prev": "0000000000000000000000000000000000000000000000000000000000000000", + "pusher_sig": serde_json::Value::Null, }); let frozen = concat!( r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#, - r#""pusher":"did:key:z6MkPusher","ref":"refs/heads/main","#, - r#""repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}"#, + r#""prev":"0000000000000000000000000000000000000000000000000000000000000000","#, + r#""pusher":"did:key:z6MkPusher","pusher_sig":null,"#, + r#""ref":"refs/heads/main","repo_id":"repo-1","seq":1,"#, + r#""ts":"2026-07-22T00:00:00+00:00"}"#, ); assert_eq!(serde_json::to_string(&payload).unwrap(), frozen); } @@ -349,6 +374,7 @@ mod tests { fn verify_signature_round_trip_and_tamper() { let kp = gitlawb_core::identity::Keypair::generate(); let node_did = kp.did().as_str().to_string(); + let prev = "0000000000000000000000000000000000000000000000000000000000000000"; let payload = serde_json::json!({ "repo_id": "repo-1", @@ -358,6 +384,9 @@ mod tests { "pusher": "did:key:z6MkPusher", "node": node_did, "ts": "2026-07-22T00:00:00+00:00", + "seq": 1, + "prev": prev, + "pusher_sig": serde_json::Value::Null, }); let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); @@ -369,6 +398,9 @@ mod tests { "did:key:z6MkPusher", &node_did, "2026-07-22T00:00:00+00:00", + 1, + prev, + None, &sig, ); assert!(ok.is_ok(), "expected valid signature, got: {ok:?}"); @@ -381,6 +413,9 @@ mod tests { "did:key:z6MkPusher", &node_did, "2026-07-22T00:00:00+00:00", + 1, + prev, + None, &sig, ); assert!(tampered.is_err(), "tampered payload must not verify"); @@ -393,6 +428,9 @@ mod tests { "did:key:z6MkPusher", &node_did, "2026-07-22T00:00:00+00:00", + 1, + prev, + None, "not-base64url!!!", ); assert!(garbage.is_err(), "malformed signature must not verify"); From 9fa2577c09e58739f7a58bcad99877631d0f66de Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 24 Jul 2026 19:47:09 +0600 Subject: [PATCH 11/25] fix: resolve certificate verification payload mismatch, update config docs, and fix node_did 500 on verify_anchor --- .env.example | 9 ++- README.md | 3 +- crates/gitlawb-node/src/arweave.rs | 93 ++++++++++++++++++++++++++---- crates/gl/src/cert.rs | 84 +++++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index bbd9a342..c966f462 100644 --- a/.env.example +++ b/.env.example @@ -43,9 +43,12 @@ GITLAWB_DB_RETRY_MAX_SECS=60 GITLAWB_PINATA_JWT= GITLAWB_PINATA_UPLOAD_URL=https://uploads.pinata.cloud/v3/files -# ── Arweave permanent anchoring (Irys devnet) ───────────────────────────── -# Leave empty to disable Arweave anchoring. -GITLAWB_IRYS_URL=https://devnet.irys.xyz +# ── Arweave permanent anchoring (Bundler / Arweave gateway) ─────────────────── +# Bundler URL for permanent anchoring. Leave empty to disable anchoring. +# (Legacy name: GITLAWB_IRYS_URL) +GITLAWB_BUNDLER_URL=https://devnet.irys.xyz +# Arweave gateway URL for resolving arweave_tx_id to data items. +GITLAWB_ARWEAVE_GATEWAY=https://arweave.net # ── Base L2 smart contracts ─────────────────────────────────────────────── GITLAWB_CHAIN_RPC_URL=https://sepolia.base.org diff --git a/README.md b/README.md index 57ce2885..d31b18da 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,8 @@ Important node settings: | `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. | | `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. | | `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. | -| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. | +| `GITLAWB_BUNDLER_URL` | Bundler URL for Arweave permanent anchoring (e.g., https://devnet.irys.xyz). Leave empty to disable. (Legacy name: `GITLAWB_IRYS_URL`). | +| `GITLAWB_ARWEAVE_GATEWAY` | Arweave gateway URL for resolving anchors (defaults to `https://arweave.net`). | Production note: change the default Postgres password before exposing a node publicly. diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index cef06090..09f7f587 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -1,6 +1,6 @@ -//! Arweave permanent anchoring via Irys. +//! Arweave permanent anchoring via Bundler (Irys). //! -//! Every ref-update event (push) is anchored to Arweave through the Irys +//! Every ref-update event (push) is anchored to Arweave through the Bundler //! network. The anchor payload is a small JSON object containing: //! //! { repo, owner_did, ref_name, old_sha, new_sha, cid, timestamp, node_did } @@ -8,12 +8,15 @@ //! Irys allows free uploads for data < 100 KiB on both devnet and mainnet //! (via Turbo). No wallet is required for payloads under the free threshold. //! -//! Set `GITLAWB_IRYS_URL` to override the default endpoint: +//! Set `GITLAWB_BUNDLER_URL` (deprecated name: `GITLAWB_IRYS_URL`) to override the default endpoint: //! - devnet (free, no cost): https://devnet.irys.xyz //! - mainnet: https://node2.irys.xyz //! -//! Each anchor returns an Irys transaction ID (43-char base58 string). -//! The permanent Arweave URL is: https://arweave.net/ +//! Configure `GITLAWB_ARWEAVE_GATEWAY` to override the gateway used for resolving anchors +//! (defaults to https://arweave.net). +//! +//! Each anchor returns a transaction ID (43-char base58 string). +//! The permanent Arweave URL is: / //! //! Anchors are stored in the `arweave_anchors` table for auditability. @@ -408,11 +411,30 @@ pub async fn verify_anchor( let payload_bytes = serde_json::to_vec(&payload)?; // Resolve node DID to public key - let node_did = gitlawb_core::did::Did::from_str(&c.node_did) - .map_err(|e| anyhow::anyhow!("invalid node DID: {e}"))?; - let verifying_key = node_did - .to_verifying_key() - .map_err(|e| anyhow::anyhow!("unresolvable node DID: {e}"))?; + let node_did = match gitlawb_core::did::Did::from_str(&c.node_did) { + Ok(did) => did, + Err(e) => { + errors.push(format!("invalid node DID: {e}")); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + let verifying_key = match node_did.to_verifying_key() { + Ok(vk) => vk, + Err(e) => { + errors.push(format!("unresolvable node DID: {e}")); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; let sig_array: [u8; 64] = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&c.signature) { @@ -819,4 +841,55 @@ mod tests { } } } + + #[tokio::test] + async fn test_verify_anchor_malformed_node_did() { + let mut server = mockito::Server::new_async().await; + + let bad_cert_json = serde_json::json!({ + "certificate": { + "id": "cert-1", + "repo_id": "repo-uuid", + "ref_name": "refs/heads/main", + "old_sha": "0000000000000000000000000000000000000000", + "new_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "pusher_did": "did:key:zPusher", + "node_did": "malformed-node-did", + "signature": "c2lnbmF0dXJl", + "issued_at": "2026-06-11T00:00:00Z", + "seq": 1, + "prev": "0000000000000000000000000000000000000000000000000000000000000000", + }, + "repo_id": "repo-uuid", + "ref_name": "refs/heads/main", + "old_sha": "0000000000000000000000000000000000000000", + "new_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "node_did": "malformed-node-did", + }); + + let _mock = server + .mock("GET", "/test-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&bad_cert_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + + let result = verify_anchor(&client, &server.url(), "test-tx", &db).await; + assert!(result.is_ok(), "Expected Ok response, got Err: {:?}", result); + + let verify_result = result.unwrap(); + assert!(!verify_result.valid, "VerifyResult should be invalid"); + assert!( + verify_result.errors.iter().any(|e| e.contains("invalid node DID")), + "Expected 'invalid node DID' error in: {:?}", + verify_result.errors + ); + } } diff --git a/crates/gl/src/cert.rs b/crates/gl/src/cert.rs index e952ba7b..33e0cb0e 100644 --- a/crates/gl/src/cert.rs +++ b/crates/gl/src/cert.rs @@ -158,7 +158,10 @@ async fn cmd_show( let issued_at = cert["issued_at"].as_str().unwrap_or("?"); let seq = cert["seq"].as_i64().unwrap_or(0); let prev = cert["prev"].as_str().unwrap_or("?"); - let pusher_sig = cert["pusher_sig"].as_str().map(|s| s.to_string()); + let pusher_sig = cert["pusher_sig"].as_str(); + let signature_input = cert["signature_input"].as_str(); + let content_digest = cert["content_digest"].as_str(); + let request_path = cert["request_path"].as_str(); println!("Ref Certificate: {cert_id}"); println!(" Ref: {ref_name}"); @@ -187,7 +190,10 @@ async fn cmd_show( issued_at, seq, prev, - pusher_sig.as_deref(), + pusher_sig, + signature_input, + content_digest, + request_path, signature, ); @@ -268,6 +274,9 @@ fn verify_signature( seq: i64, prev: &str, pusher_sig: Option<&str>, + signature_input: Option<&str>, + content_digest: Option<&str>, + request_path: Option<&str>, signature_b64: &str, ) -> std::result::Result<(), String> { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; @@ -284,6 +293,9 @@ fn verify_signature( "seq": seq, "prev": prev, "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, }); let payload_bytes = serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?; @@ -357,12 +369,15 @@ mod tests { "seq": 1, "prev": "0000000000000000000000000000000000000000000000000000000000000000", "pusher_sig": serde_json::Value::Null, + "signature_input": serde_json::Value::Null, + "content_digest": serde_json::Value::Null, + "request_path": serde_json::Value::Null, }); let frozen = concat!( - r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#, + r#"{"content_digest":null,"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#, r#""prev":"0000000000000000000000000000000000000000000000000000000000000000","#, - r#""pusher":"did:key:z6MkPusher","pusher_sig":null,"#, - r#""ref":"refs/heads/main","repo_id":"repo-1","seq":1,"#, + r#""pusher":"did:key:z6MkPusher","pusher_sig":null,"ref":"refs/heads/main","#, + r#""repo_id":"repo-1","request_path":null,"seq":1,"signature_input":null,"#, r#""ts":"2026-07-22T00:00:00+00:00"}"#, ); assert_eq!(serde_json::to_string(&payload).unwrap(), frozen); @@ -387,6 +402,9 @@ mod tests { "seq": 1, "prev": prev, "pusher_sig": serde_json::Value::Null, + "signature_input": serde_json::Value::Null, + "content_digest": serde_json::Value::Null, + "request_path": serde_json::Value::Null, }); let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); @@ -401,6 +419,9 @@ mod tests { 1, prev, None, + None, + None, + None, &sig, ); assert!(ok.is_ok(), "expected valid signature, got: {ok:?}"); @@ -416,6 +437,9 @@ mod tests { 1, prev, None, + None, + None, + None, &sig, ); assert!(tampered.is_err(), "tampered payload must not verify"); @@ -431,8 +455,58 @@ mod tests { 1, prev, None, + None, + None, + None, "not-base64url!!!", ); assert!(garbage.is_err(), "malformed signature must not verify"); } + + #[test] + fn verify_signature_all_fields_populated() { + let kp = gitlawb_core::identity::Keypair::generate(); + let node_did = kp.did().as_str().to_string(); + let prev = "0000000000000000000000000000000000000000000000000000000000000000"; + + let pusher_sig = "sig-123"; + let signature_input = "sig-input-123"; + let content_digest = "sha256-123"; + let request_path = "/repo.git/git-receive-pack"; + + let payload = serde_json::json!({ + "repo_id": "repo-1", + "ref": "refs/heads/main", + "old": "0".repeat(40), + "new": "a".repeat(40), + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": "2026-07-22T00:00:00+00:00", + "seq": 1, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, + }); + let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + + let ok = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"a".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + 1, + prev, + Some(pusher_sig), + Some(signature_input), + Some(content_digest), + Some(request_path), + &sig, + ); + assert!(ok.is_ok(), "expected valid signature, got: {ok:?}"); + } } From 21c72482d4613b68a310349878eb92207044bc6a Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 24 Jul 2026 19:48:03 +0600 Subject: [PATCH 12/25] style: fix formatting in arweave tests --- crates/gitlawb-node/src/arweave.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 09f7f587..16e83ddc 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -882,12 +882,19 @@ mod tests { let db = crate::db::Db::for_testing(pool); let result = verify_anchor(&client, &server.url(), "test-tx", &db).await; - assert!(result.is_ok(), "Expected Ok response, got Err: {:?}", result); + assert!( + result.is_ok(), + "Expected Ok response, got Err: {:?}", + result + ); let verify_result = result.unwrap(); assert!(!verify_result.valid, "VerifyResult should be invalid"); assert!( - verify_result.errors.iter().any(|e| e.contains("invalid node DID")), + verify_result + .errors + .iter() + .any(|e| e.contains("invalid node DID")), "Expected 'invalid node DID' error in: {:?}", verify_result.errors ); From d7236d949b4952a6582acb2580e347dc6862339c Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 26 Jul 2026 02:23:23 +0600 Subject: [PATCH 13/25] address third review round: P1 issuer check, payload fallback, pusher binding; P2/P3 items --- crates/gitlawb-node/src/api/arweave.rs | 8 +- crates/gitlawb-node/src/arweave.rs | 159 +++++++++++++++++-------- crates/gitlawb-node/src/db/mod.rs | 38 ++---- crates/gitlawb-node/src/server.rs | 3 +- 4 files changed, 127 insertions(+), 81 deletions(-) diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index f649cff3..1f0a7624 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -36,9 +36,11 @@ pub async fn verify_anchor_endpoint( )); } let gateway = &state.config.arweave_gateway; - let result = crate::arweave::verify_anchor(&state.http_client, gateway, &tx_id, &state.db) - .await - .map_err(crate::error::AppError::Internal)?; + let node_did = state.node_did.to_string(); + let result = + crate::arweave::verify_anchor(&state.http_client, gateway, &tx_id, &state.db, &node_did) + .await + .map_err(crate::error::AppError::Internal)?; Ok(Json(serde_json::json!({ "valid": result.valid, diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 16e83ddc..c79cbdd5 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -267,15 +267,23 @@ pub async fn verify_anchor( gateway_url: &str, tx_id: &str, db: &crate::db::Db, + node_did: &str, ) -> Result { // Fetch the data item from the Arweave gateway's data path. // Gateways serve data at /{tx_id}, not /v1/tx/{id} (which is the bundler API). let url = format!("{}/{}", gateway_url.trim_end_matches('/'), tx_id); - let resp = client - .get(&url) - .send() - .await - .map_err(|e| anyhow::anyhow!("failed to fetch data from Arweave gateway: {e}"))?; + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!("Arweave gateway connection failed: {e}"); + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!("Arweave gateway connection failed: {e}")], + }); + } + }; if !resp.status().is_success() { return Ok(VerifyResult { valid: false, @@ -334,7 +342,15 @@ pub async fn verify_anchor( let mut errors = Vec::new(); if let Some(ref c) = cert { - // 0. Cross-check the outer anchor fields against the embedded certificate. + // 0a. Verify the certificate was issued by this node. + if c.node_did != node_did { + errors.push(format!( + "certificate node_did ({}) does not match this node ({})", + c.node_did, node_did + )); + } + + // 0b. Cross-check the outer anchor fields against the embedded certificate. // A valid anchor must commit to the same identities and ref state. // The outer repo_id (UUID) is compared against the cert's repo_id (UUID) // to avoid comparing a human-readable slug against a UUID. @@ -392,23 +408,16 @@ pub async fn verify_anchor( )); } - // 1. Verify node signature on the certificate payload - let payload = serde_json::json!({ - "repo_id": c.repo_id, - "ref": c.ref_name, - "old": c.old_sha, - "new": c.new_sha, - "pusher": c.pusher_did, - "node": c.node_did, - "ts": c.issued_at, - "seq": c.seq, - "prev": c.prev, - "pusher_sig": c.pusher_sig, - "signature_input": c.signature_input, - "content_digest": c.content_digest, - "request_path": c.request_path, - }); - let payload_bytes = serde_json::to_vec(&payload)?; + // 1. Verify node signature on the certificate payload. + // Certificates produced after this PR use a 13-field payload + // that includes seq, prev, and proof fields. Pre-PR certificates + // used a 7-field payload (repo_id, ref, old, new, pusher, node, ts) + // with NULL proof fields. Try the 13-field check first; if it + // fails and all proof fields are NULL, fall back to 7-field. + let proof_fields_null = c.pusher_sig.is_none() + && c.signature_input.is_none() + && c.content_digest.is_none() + && c.request_path.is_none(); // Resolve node DID to public key let node_did = match gitlawb_core::did::Did::from_str(&c.node_did) { @@ -461,11 +470,53 @@ pub async fn verify_anchor( } }; - if let Err(e) = gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_array) { + // Try 13-field payload first. + let payload_13 = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + "signature_input": c.signature_input, + "content_digest": c.content_digest, + "request_path": c.request_path, + }); + let payload_bytes_13 = serde_json::to_vec(&payload_13)?; + let sig_valid_13 = + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_13, &sig_array); + + if proof_fields_null && sig_valid_13.is_err() { + // Fall back to 7-field payload for pre-PR certificates. + let payload_7 = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + }); + let payload_bytes_7 = serde_json::to_vec(&payload_7)?; + if let Err(e) = + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_7, &sig_array) + { + errors.push(format!( + "certificate signature verification failed (7-field): {e}" + )); + } + } else if let Err(e) = sig_valid_13 { errors.push(format!("certificate signature verification failed: {e}")); } // 2. Verify prev hash linkage against the predecessor at seq - 1. + // The prev hash covers the 7-field payload (repo_id, ref, old, new, + // pusher, node, ts) — seq, prev, and proof fields are excluded so + // that the hash chain is stable across certificate versions. // Fail closed: a missing declared predecessor is treated as invalid. if c.seq > 1 { match db.get_cert_by_seq(&c.repo_id, c.seq - 1).await { @@ -496,10 +547,8 @@ pub async fn verify_anchor( )); } Err(e) => { - errors.push(format!( - "error looking up predecessor seq {}: {e}", - c.seq - 1 - )); + tracing::warn!("predecessor lookup failed for seq {}: {e}", c.seq - 1); + errors.push(format!("error looking up predecessor seq {}", c.seq - 1)); } } } @@ -507,9 +556,15 @@ pub async fn verify_anchor( // 3. Verify the pusher authorization proof (RFC 9421 HTTP Signature). // The context fields (signature_input, content_digest, request_path) // are bound into the node signing payload, so a certificate whose - // node signature verified already commits to them. When pusher_sig - // is present but a context field is missing, the proof cannot be - // checked and is treated as invalid rather than silently skipped. + // node signature verified already commits to them. + // Additionally, the ref transition (ref_name, old_sha, new_sha) is + // bound into the HTTP signature signing string as derived components + // so a captured pusher proof cannot be replayed for a different ref. + // When proof fields are present, pusher_sig is REQUIRED; a missing + // pusher_sig is treated as invalid rather than silently skipped. + if !proof_fields_null && c.pusher_sig.is_none() { + errors.push("pusher signature is required when proof fields are present".to_string()); + } if let Some(pusher_sig) = &c.pusher_sig { match (&c.signature_input, &c.content_digest, &c.request_path) { (Some(sig_input), Some(content_digest), Some(request_path)) => { @@ -523,6 +578,9 @@ pub async fn verify_anchor( request_values.insert("@path".to_string(), request_path.clone()); request_values .insert("content-digest".to_string(), content_digest.clone()); + request_values.insert("@ref_name".to_string(), c.ref_name.clone()); + request_values.insert("@old_sha".to_string(), c.old_sha.clone()); + request_values.insert("@new_sha".to_string(), c.new_sha.clone()); let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input); @@ -814,10 +872,11 @@ mod tests { #[tokio::test] async fn test_verify_anchor_uses_correct_gateway_url() { let mut server = mockito::Server::new_async().await; - // Gateways serve data at /{tx_id}, not /v1/tx/{id}. - let _mock = server + let mock = server .mock("GET", "/does-not-exist") - .with_status(404) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid":false}"#) .create_async() .await; @@ -826,20 +885,18 @@ mod tests { .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - let result = verify_anchor(&client, &server.url(), "does-not-exist", &db).await; - - match result { - Ok(r) => { - assert!(!r.valid); - } - Err(e) => { - let msg = e.to_string(); - assert!( - msg.contains("pool") || msg.contains("error"), - "unexpected error: {msg}" - ); - } - } + let result = verify_anchor( + &client, + &server.url(), + "does-not-exist", + &db, + "did:key:zNODE", + ) + .await; + + let r = result.expect("verify_anchor should return Ok for gateway errors"); + assert!(!r.valid, "non-certificate JSON should be invalid"); + mock.assert_async().await; } #[tokio::test] @@ -881,7 +938,7 @@ mod tests { .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - let result = verify_anchor(&client, &server.url(), "test-tx", &db).await; + let result = verify_anchor(&client, &server.url(), "test-tx", &db, "did:key:zNODE").await; assert!( result.is_ok(), "Expected Ok response, got Err: {:?}", @@ -894,8 +951,8 @@ mod tests { verify_result .errors .iter() - .any(|e| e.contains("invalid node DID")), - "Expected 'invalid node DID' error in: {:?}", + .any(|e| e.contains("does not match this node") || e.contains("invalid node DID")), + "Expected issuer or DID error in: {:?}", verify_result.errors ); } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 72117ddd..d938b286 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -467,13 +467,13 @@ impl Db { // appended to v1. Operators can read `schema_migrations` to confirm a node // is at the expected version. // -// NOTE: the v1 migration in this branch already includes columns (seq, prev, -// pusher_sig on ref_certificates; status, deadline_height, receipt_sig, cert_id -// on arweave_anchors) that were historically added by migrations v12/v13. These -// were bundled into v1 for development convenience and kept there to avoid a -// schema reset. The v12/v13 migrations remain as no-ops for existing installs -// that upgrade from an older branch. See v12/v13 for the actual DDL that newer -// installs skip via IF NOT EXISTS. +// NOTE: the v1 migration includes columns (seq, prev, pusher_sig on +// ref_certificates) that were historically added by later migrations. These +// were bundled into v1 for development convenience. cert_id on arweave_anchors +// is added by migration v12 as ALTER TABLE; signature_input, content_digest, +// and request_path are added by v13. New installs reach v12/v13 via sequential +// migration; existing installs with the columns already present are no-ops via +// IF NOT EXISTS. // // Each migration runs in a single transaction, so statements that Postgres // forbids inside a transaction (notably `CREATE INDEX CONCURRENTLY`) cannot be @@ -663,11 +663,7 @@ const MIGRATIONS: &[Migration] = &[ cid TEXT, arweave_tx_id TEXT NOT NULL, node_did TEXT NOT NULL, - anchored_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - deadline_height BIGINT, - receipt_sig TEXT, - cert_id TEXT + anchored_at TEXT NOT NULL )"#, "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_repo ON arweave_anchors(repo)", "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_new_sha ON arweave_anchors(new_sha)", @@ -2303,7 +2299,7 @@ impl Db { fn repo_lock_hash(repo_id: &str) -> i64 { use sha2::Digest; let hash = sha2::Sha256::digest(repo_id.as_bytes()); - i64::from_ne_bytes(hash[..8].try_into().expect("sha256 output >= 8 bytes")) + i64::from_be_bytes(hash[..8].try_into().expect("sha256 output >= 8 bytes")) } // ── Peers ───────────────────────────────────────────────────────────────────── @@ -2922,9 +2918,6 @@ pub struct ArweaveAnchor { pub arweave_tx_id: String, pub node_did: String, pub anchored_at: String, - pub status: String, - pub deadline_height: Option, - pub receipt_sig: Option, pub cert_id: Option, } @@ -2947,8 +2940,8 @@ impl Db { let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, cert_id) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)", + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", ) .bind(&id) .bind(input.repo) @@ -2960,7 +2953,6 @@ impl Db { .bind(input.arweave_tx_id) .bind(input.node_did) .bind(&now) - .bind("pending") .bind(input.cert_id.clone()) .execute(&self.pool) .await?; @@ -2974,7 +2966,7 @@ impl Db { ) -> Result> { let rows = if let Some(repo) = repo { sqlx::query( - "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, deadline_height, receipt_sig, cert_id + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id FROM arweave_anchors WHERE repo=$1 ORDER BY anchored_at DESC LIMIT $2", ) .bind(repo) @@ -2983,7 +2975,7 @@ impl Db { .await? } else { sqlx::query( - "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, status, deadline_height, receipt_sig, cert_id + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id FROM arweave_anchors ORDER BY anchored_at DESC LIMIT $1", ) .bind(limit) @@ -3004,9 +2996,6 @@ impl Db { arweave_tx_id: r.get("arweave_tx_id"), node_did: r.get("node_did"), anchored_at: r.get("anchored_at"), - status: r.get("status"), - deadline_height: r.try_get("deadline_height").unwrap_or(None), - receipt_sig: r.try_get("receipt_sig").unwrap_or(None), cert_id: r.try_get("cert_id").unwrap_or(None), }) .collect()) @@ -6290,7 +6279,6 @@ mod arweave_anchor_tests { .await .unwrap(); assert_eq!(anchors.len(), 1, "one anchor recorded"); - assert_eq!(anchors[0].status, "pending", "default status is pending"); assert_eq!(anchors[0].arweave_tx_id, "test-tx-id-123"); } } diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 41a6f5ed..d1ee52dc 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -583,8 +583,7 @@ pub(crate) async fn stats(State(state): State) -> Json String { - if let Some(at_pos) = url.find('@') { - // Strip userinfo: keep everything after '@' + if let Some(at_pos) = url.rfind('@') { url[at_pos + 1..].to_string() } else { url.to_string() From b9b14463821bb0b50221bb450f7119931b5bdf07 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 26 Jul 2026 11:13:03 +0600 Subject: [PATCH 14/25] address fourth review round: legacy cert fallback, pusher binding comment, env.example defaults, anchor compat fields --- .env.example | 6 +- crates/gitlawb-node/src/api/arweave.rs | 10 ++ crates/gitlawb-node/src/arweave.rs | 16 +-- crates/gitlawb-node/src/db/mod.rs | 8 ++ crates/gl/src/cert.rs | 132 +++++++++++++++++++++---- 5 files changed, 146 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index c966f462..50d8339f 100644 --- a/.env.example +++ b/.env.example @@ -46,9 +46,13 @@ GITLAWB_PINATA_UPLOAD_URL=https://uploads.pinata.cloud/v3/files # ── Arweave permanent anchoring (Bundler / Arweave gateway) ─────────────────── # Bundler URL for permanent anchoring. Leave empty to disable anchoring. # (Legacy name: GITLAWB_IRYS_URL) +# Default: Irys devnet (free, data deleted ~60 days). For production, use +# https://node2.irys.xyz and provide a funded wallet credential. GITLAWB_BUNDLER_URL=https://devnet.irys.xyz # Arweave gateway URL for resolving arweave_tx_id to data items. -GITLAWB_ARWEAVE_GATEWAY=https://arweave.net +# Must match the network used by GITLAWB_BUNDLER_URL so anchors are verifiable. +# Default: Irys devnet gateway; for production use https://arweave.net. +GITLAWB_ARWEAVE_GATEWAY=https://devnet.irys.xyz # ── Base L2 smart contracts ─────────────────────────────────────────────── GITLAWB_CHAIN_RPC_URL=https://sepolia.base.org diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 1f0a7624..df41f3f0 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -72,6 +72,16 @@ pub async fn list_anchors( .await .map_err(crate::error::AppError::Internal)?; + let gateway = state.config.arweave_gateway.trim_end_matches('/'); + let anchors: Vec = anchors + .into_iter() + .map(|mut a| { + a.irys_tx_id = Some(a.arweave_tx_id.clone()); + a.arweave_url = Some(format!("{}/{}", gateway, a.arweave_tx_id)); + a + }) + .collect(); + Ok(Json(serde_json::json!({ "anchors": anchors, "count": anchors.len(), diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index c79cbdd5..e0acfc94 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -557,9 +557,16 @@ pub async fn verify_anchor( // The context fields (signature_input, content_digest, request_path) // are bound into the node signing payload, so a certificate whose // node signature verified already commits to them. - // Additionally, the ref transition (ref_name, old_sha, new_sha) is - // bound into the HTTP signature signing string as derived components - // so a captured pusher proof cannot be replayed for a different ref. + // + // The ref transition is NOT directly signed by the pusher — the + // shipped pusher signs only @method, @path, and content-digest. + // Instead the binding works through the node certificate: the node + // verifies the pusher proof during push, then issues a certificate + // whose 13-field signed payload includes ref_name, old_sha, new_sha. + // A captured pusher proof for one ref transition cannot be reused + // to authorize a different transition because the node signature on + // the mismatch would fail verification in step 1 above. + // // When proof fields are present, pusher_sig is REQUIRED; a missing // pusher_sig is treated as invalid rather than silently skipped. if !proof_fields_null && c.pusher_sig.is_none() { @@ -578,9 +585,6 @@ pub async fn verify_anchor( request_values.insert("@path".to_string(), request_path.clone()); request_values .insert("content-digest".to_string(), content_digest.clone()); - request_values.insert("@ref_name".to_string(), c.ref_name.clone()); - request_values.insert("@old_sha".to_string(), c.old_sha.clone()); - request_values.insert("@new_sha".to_string(), c.new_sha.clone()); let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input); diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index d938b286..61648bb2 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2919,6 +2919,12 @@ pub struct ArweaveAnchor { pub node_did: String, pub anchored_at: String, pub cert_id: Option, + /// Backward-compat alias for arweave_tx_id. v1 clients expect this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub irys_tx_id: Option, + /// Permanent Arweave URL derived from the gateway and tx_id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arweave_url: Option, } /// Input parameters for recording an Arweave anchor. @@ -2997,6 +3003,8 @@ impl Db { node_did: r.get("node_did"), anchored_at: r.get("anchored_at"), cert_id: r.try_get("cert_id").unwrap_or(None), + irys_tx_id: None, + arweave_url: None, }) .collect()) } diff --git a/crates/gl/src/cert.rs b/crates/gl/src/cert.rs index 33e0cb0e..5f31e11f 100644 --- a/crates/gl/src/cert.rs +++ b/crates/gl/src/cert.rs @@ -262,6 +262,11 @@ async fn cmd_show( /// Rebuild the node's canonical signing payload (field order must match /// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the /// certificate's Ed25519 signature against the key embedded in `node_did`. +/// +/// Certificates after this PR use a 13-field payload. Pre-PR certificates +/// were signed over 7 fields (repo_id, ref, old, new, pusher, node, ts) with +/// NULL proof columns. Try 13-field first; if it fails and all proof fields +/// are None, retry with the 7-field payload. #[allow(clippy::too_many_arguments)] fn verify_signature( repo_id: &str, @@ -282,23 +287,10 @@ fn verify_signature( use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use std::str::FromStr; - let payload = serde_json::json!({ - "repo_id": repo_id, - "ref": ref_name, - "old": old_sha, - "new": new_sha, - "pusher": pusher, - "node": node_did, - "ts": issued_at, - "seq": seq, - "prev": prev, - "pusher_sig": pusher_sig, - "signature_input": signature_input, - "content_digest": content_digest, - "request_path": request_path, - }); - let payload_bytes = - serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?; + let proof_fields_null = pusher_sig.is_none() + && signature_input.is_none() + && content_digest.is_none() + && request_path.is_none(); let did = gitlawb_core::did::Did::from_str(node_did).map_err(|e| format!("bad node DID: {e}"))?; @@ -313,8 +305,47 @@ fn verify_signature( .try_into() .map_err(|_| "signature is not 64 bytes".to_string())?; - gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_bytes) - .map_err(|_| "Ed25519 signature does not match the signed payload".to_string()) + // Try 13-field payload first. + let payload_13 = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, + }); + let payload_bytes_13 = + serde_json::to_vec(&payload_13).map_err(|e| format!("could not serialize payload: {e}"))?; + + let sig_valid_13 = + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_13, &sig_bytes); + + if proof_fields_null && sig_valid_13.is_err() { + // Fall back to 7-field payload for pre-PR certificates. + let payload_7 = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher, + "node": node_did, + "ts": issued_at, + }); + let payload_bytes_7 = serde_json::to_vec(&payload_7) + .map_err(|e| format!("could not serialize payload: {e}"))?; + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_7, &sig_bytes).map_err(|_| { + "Ed25519 signature does not match the signed payload (7-field)".to_string() + }) + } else { + sig_valid_13.map_err(|_| "Ed25519 signature does not match the signed payload".to_string()) + } } async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result { @@ -509,4 +540,67 @@ mod tests { ); assert!(ok.is_ok(), "expected valid signature, got: {ok:?}"); } + + #[test] + fn verify_signature_7_field_legacy_fallback() { + // A true 7-field (pre-PR) payload — no seq, prev, or proof fields. + // The fallback must detect the 13-field mismatch and retry with 7. + let kp = gitlawb_core::identity::Keypair::generate(); + let node_did = kp.did().as_str().to_string(); + + let payload_7 = serde_json::json!({ + "repo_id": "repo-1", + "ref": "refs/heads/main", + "old": "0".repeat(40), + "new": "a".repeat(40), + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": "2026-07-22T00:00:00+00:00", + }); + let sig = kp.sign_b64(&serde_json::to_vec(&payload_7).unwrap()); + + // All proof fields None → triggers 7-field fallback. + let ok = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"a".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + 1, + "0000000000000000000000000000000000000000000000000000000000000000", + None, + None, + None, + None, + &sig, + ); + assert!( + ok.is_ok(), + "legacy 7-field certificate must verify via fallback, got: {ok:?}" + ); + + // Tampered new_sha must still fail. + let tampered = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"b".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + 1, + "0000000000000000000000000000000000000000000000000000000000000000", + None, + None, + None, + None, + &sig, + ); + assert!( + tampered.is_err(), + "tampered 7-field payload must not verify" + ); + } } From 00f866bdd6105d354019c00a3c28fdfbe4823264 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 27 Jul 2026 14:54:10 +0600 Subject: [PATCH 15/25] address fifth review round: legacy prev, Irys gateway pairing, skip unverifiable anchors, separate rate limiter --- crates/gitlawb-node/src/api/repos.rs | 11 +++++ crates/gitlawb-node/src/arweave.rs | 70 +++++++++++++++++----------- crates/gitlawb-node/src/main.rs | 20 +++++++- crates/gitlawb-node/src/server.rs | 22 +++++---- 4 files changed, 86 insertions(+), 37 deletions(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index e4884337..cbf7161b 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1323,6 +1323,17 @@ pub async fn git_receive_pack( // repo-wide latest, so each anchor embeds the exact // certificate for its own ref transition. let cert = ref_certs_clone.get(ref_name).cloned(); + if cert.is_none() { + // Certificate issuance failed for this ref update. + // Anchoring without a cert would produce a permanent + // artifact that verify_anchor must reject — skip + // instead of publishing an unverifiable anchor. + tracing::warn!( + ref_name, + "skipping arweave anchor — no certificate was issued" + ); + continue; + } let cert_id = cert.as_ref().map(|c| c.id.clone()); let anchor = crate::arweave::RefAnchor { repo: repo_slug.clone(), diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index e0acfc94..b5237da0 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -518,37 +518,53 @@ pub async fn verify_anchor( // pusher, node, ts) — seq, prev, and proof fields are excluded so // that the hash chain is stable across certificate versions. // Fail closed: a missing declared predecessor is treated as invalid. + // + // Legacy certificates backfilled by the v13 migration have the + // default all-zeros prev even when seq > 1 because the migration + // only assigns sequence numbers without computing prev hashes. + // For these rows the chain link is unknown — skip the check and + // warn rather than reporting a valid signature as invalid. if c.seq > 1 { - match db.get_cert_by_seq(&c.repo_id, c.seq - 1).await { - Ok(Some(pred)) => { - let prev_payload = serde_json::json!({ - "repo_id": pred.repo_id, - "ref": pred.ref_name, - "old": pred.old_sha, - "new": pred.new_sha, - "pusher": pred.pusher_did, - "node": pred.node_did, - "ts": pred.issued_at, - }); - let prev_bytes = serde_json::to_vec(&prev_payload)?; - let expected_prev = hex::encode(sha2::Sha256::digest(&prev_bytes)); - if c.prev != expected_prev { + if c.prev == "0000000000000000000000000000000000000000000000000000000000000000" { + // Prevent legacy false-positives: the migration that assigned + // seq never backfilled prev, so every pre-upgrade cert after + // the first in a repo has default all-zeros. + tracing::warn!( + "legacy certificate seq {} has default prev — chain continuity not verifiable, skipping prev check", + c.seq + ); + } else { + match db.get_cert_by_seq(&c.repo_id, c.seq - 1).await { + Ok(Some(pred)) => { + let prev_payload = serde_json::json!({ + "repo_id": pred.repo_id, + "ref": pred.ref_name, + "old": pred.old_sha, + "new": pred.new_sha, + "pusher": pred.pusher_did, + "node": pred.node_did, + "ts": pred.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + let expected_prev = hex::encode(sha2::Sha256::digest(&prev_bytes)); + if c.prev != expected_prev { + errors.push(format!( + "prev hash mismatch: claimed {} expected {}", + c.prev, expected_prev + )); + } + } + Ok(None) => { errors.push(format!( - "prev hash mismatch: claimed {} expected {}", - c.prev, expected_prev + "predecessor cert seq {} not found for repo {}", + c.seq - 1, + c.repo_id )); } - } - Ok(None) => { - errors.push(format!( - "predecessor cert seq {} not found for repo {}", - c.seq - 1, - c.repo_id - )); - } - Err(e) => { - tracing::warn!("predecessor lookup failed for seq {}: {e}", c.seq - 1); - errors.push(format!("error looking up predecessor seq {}", c.seq - 1)); + Err(e) => { + tracing::warn!("predecessor lookup failed for seq {}: {e}", c.seq - 1); + errors.push(format!("error looking up predecessor seq {}", c.seq - 1)); + } } } } diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index a1219216..175c8299 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -71,13 +71,31 @@ async fn main() -> Result<()> { let mut config = Config::parse(); // Fallback to legacy GITLAWB_IRYS_URL for backward compatibility during rename - if config.bundler_url.is_empty() { + let used_legacy_irys = if config.bundler_url.is_empty() { if let Ok(legacy) = std::env::var("GITLAWB_IRYS_URL") { if !legacy.is_empty() { config.bundler_url = legacy; tracing::warn!("GITLAWB_IRYS_URL is deprecated, use GITLAWB_BUNDLER_URL instead"); + true + } else { + false } + } else { + false } + } else { + false + }; + + // When the legacy GITLAWB_IRYS_URL was used and GITLAWB_ARWEAVE_GATEWAY was + // not explicitly set, pair the gateway to the same network so that anchors + // uploaded to Irys devnet are verifiable through the verify endpoint. + if used_legacy_irys && std::env::var("GITLAWB_ARWEAVE_GATEWAY").is_err() { + config.arweave_gateway = config.bundler_url.clone(); + tracing::warn!( + "GITLAWB_ARWEAVE_GATEWAY unset — inferred from legacy GITLAWB_IRYS_URL as {}", + config.arweave_gateway + ); } // Merge the embedded seed list of public network nodes into the runtime diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index d1ee52dc..cb59309d 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -220,20 +220,24 @@ pub fn build_router(state: AppState) -> Router { .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))); // ── Arweave permanent anchors ────────────────────────────────────────── - // Rate-limited per-IP to prevent the verify endpoint from being used as an - // open arweave gateway proxy or abused in a resource-exhaustion attack. - let arweave_limiter = rate_limit::IpRateLimiter { + // Only the gateway-fetching /verify endpoint is rate-limited per-IP to + // prevent abuse as an open proxy or resource-exhaustion vector. + // The /anchors listing is cheap (DB read) and shares no quota. + let arweave_verify_limiter = rate_limit::IpRateLimiter { limiter: state.arweave_rate_limiter.clone(), trust: state.push_limiter_trust, }; let arweave_routes = Router::new() .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) - .route( - "/api/v1/arweave/verify/{tx_id}", - get(arweave::verify_anchor_endpoint), - ) - .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) - .layer(axum::Extension(arweave_limiter)); + .merge( + Router::new() + .route( + "/api/v1/arweave/verify/{tx_id}", + get(arweave::verify_anchor_endpoint), + ) + .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) + .layer(axum::Extension(arweave_verify_limiter)), + ); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( From 12f1e38291fc6fa318f5ff89543e2c7042c5bb91 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 7 Aug 2026 17:36:30 +0600 Subject: [PATCH 16/25] fix: renumber arweave migrations to v18/v19 after rebasing onto main --- crates/gitlawb-node/src/db/mod.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 61648bb2..e408e6ed 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -470,8 +470,8 @@ impl Db { // NOTE: the v1 migration includes columns (seq, prev, pusher_sig on // ref_certificates) that were historically added by later migrations. These // were bundled into v1 for development convenience. cert_id on arweave_anchors -// is added by migration v12 as ALTER TABLE; signature_input, content_digest, -// and request_path are added by v13. New installs reach v12/v13 via sequential +// is added by migration v18 as ALTER TABLE; signature_input, content_digest, +// and request_path are added by v19. New installs reach v18/v19 via sequential // migration; existing installs with the columns already present are no-ops via // IF NOT EXISTS. // @@ -3906,7 +3906,7 @@ mod migration_tests { "pre-migration row must exist" ); - // ── Apply pending migrations (v10 ref_cert_unique_per_ref, v11 owner_did, v12 arweave) ── + // ── Apply pending migrations (v10 ref_cert_unique_per_ref, v11 owner_did, v18 arweave) ── db.migrate().await.unwrap(); // ── Assertions ──────────────────────────────────────────────────── @@ -5783,7 +5783,7 @@ mod ref_certificate_tests { let db = db(pool.clone()).await; // Drop the unique indexes so we can simulate pre-v10 duplicate rows. - // v13's (repo_id, seq) index must also be removed because raw INSERTS + // v19's (repo_id, seq) index must also be removed because raw INSERTS // without an explicit seq all get DEFAULT 1. sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") .execute(&pool) @@ -5894,7 +5894,7 @@ mod ref_certificate_tests { // 2. Roll back to v9: remove unique indexes and the // schema_migrations record so that run_migrations() re-applies v10. - // Also drop v13's (repo_id, seq) index so raw INSERTS below work. + // Also drop v19's (repo_id, seq) index so raw INSERTS below work. sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") .execute(&pool) .await @@ -6106,9 +6106,9 @@ mod ref_certificate_tests { ); } - /// INV-7: upgrade-path test for migration v13 — seed a database at v12 + /// INV-7: upgrade-path test for migration v19 — seed a database at v18 /// with multiple same-repo/different-ref certificates (all at seq=1), - /// then let run_migrations() apply v13 and verify (a) seq values are + /// then let run_migrations() apply v19 and verify (a) seq values are /// distinct per repo, (b) the (repo_id, seq) unique index exists and /// rejects a raw INSERT with a colliding seq. #[sqlx::test] @@ -6117,13 +6117,13 @@ mod ref_certificate_tests { let db = Db::for_testing(pool.clone()); db.run_migrations().await.unwrap(); - // 2. Roll back to v12: drop the (repo_id, seq) index and the - // schema_migrations record for v13 so run_migrations() re-applies it. + // 2. Roll back to v18: drop the (repo_id, seq) index and the + // schema_migrations record for v19 so run_migrations() re-applies it. sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_seq") .execute(&pool) .await .unwrap(); - sqlx::query("DELETE FROM schema_migrations WHERE version = 13") + sqlx::query("DELETE FROM schema_migrations WHERE version = 19") .execute(&pool) .await .unwrap(); @@ -6170,7 +6170,7 @@ mod ref_certificate_tests { .unwrap(); } - // 4. Re-run migrations — v13 backfills seq. + // 4. Re-run migrations — v19 backfills seq. db.run_migrations().await.unwrap(); // 5. Assert distinct seq values per repo. From 86d4935664e548ede62bb510b175893c034fe1cf Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 10 Aug 2026 11:52:24 +0600 Subject: [PATCH 17/25] address sixth review round: legacy cert corroboration, repo/owner cross-check, v20 index drop, gateway pairing, positive accept test, P3 fixes --- .env.example | 3 + README.md | 1 + crates/gitlawb-node/src/api/arweave.rs | 16 +- crates/gitlawb-node/src/api/repos.rs | 5 +- crates/gitlawb-node/src/arweave.rs | 283 ++++++++++++++++++++++++- crates/gitlawb-node/src/config.rs | 7 + crates/gitlawb-node/src/db/mod.rs | 28 ++- crates/gitlawb-node/src/main.rs | 33 ++- 8 files changed, 344 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 50d8339f..f910eb96 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,9 @@ GITLAWB_BUNDLER_URL=https://devnet.irys.xyz # Must match the network used by GITLAWB_BUNDLER_URL so anchors are verifiable. # Default: Irys devnet gateway; for production use https://arweave.net. GITLAWB_ARWEAVE_GATEWAY=https://devnet.irys.xyz +# Per-client-IP rate limit for the unauthenticated /api/v1/arweave/verify/:tx_id +# endpoint, in requests per hour. 0 disables. Default 120. +GITLAWB_ARWEAVE_RATE_LIMIT=120 # ── Base L2 smart contracts ─────────────────────────────────────────────── GITLAWB_CHAIN_RPC_URL=https://sepolia.base.org diff --git a/README.md b/README.md index d31b18da..a6fd8b99 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ Important node settings: | `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. | | `GITLAWB_BUNDLER_URL` | Bundler URL for Arweave permanent anchoring (e.g., https://devnet.irys.xyz). Leave empty to disable. (Legacy name: `GITLAWB_IRYS_URL`). | | `GITLAWB_ARWEAVE_GATEWAY` | Arweave gateway URL for resolving anchors (defaults to `https://arweave.net`). | +| `GITLAWB_ARWEAVE_RATE_LIMIT` | Per-client-IP rate limit for the verify endpoint, requests per hour (defaults to 120; `0` disables). | Production note: change the default Postgres password before exposing a node publicly. diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index df41f3f0..476ab502 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -23,9 +23,15 @@ fn is_valid_tx_id(tx_id: &str) -> bool { /// /// Fetch the anchor from Arweave via the configured gateway, extract the embedded /// certificate, and verify: -/// 1. The node's Ed25519 signature on the certificate payload -/// 2. The `prev` hash chains correctly against the most recent local cert -/// 3. The `pusher_sig` can be verified (optional, informational) +/// 1. The node's Ed25519 signature on the certificate payload (with a +/// 7-field legacy fallback when the proof fields are absent) +/// 2. Chain continuity: `prev` hashes against the predecessor cert (seq > 1) +/// and, on the legacy path, the stored row is corroborated +/// 3. The RFC 9421 `pusher_sig` — REQUIRED (not optional) whenever the +/// signature context fields are present +/// +/// The verdict only ever covers fields the certificate actually signed; the +/// outer repo/owner_did are corroborated against the node's own record. pub async fn verify_anchor_endpoint( State(state): State, Path(tx_id): Path, @@ -65,7 +71,9 @@ pub async fn list_anchors( State(state): State, Query(q): Query, ) -> Result> { - let limit = q.limit.min(200); + // Clamp to a sane bound; a negative value would become LIMIT -1 in SQL, + // which Postgres rejects. Treat anything below 1 as the default. + let limit = q.limit.clamp(1, 200); let anchors = state .db .list_arweave_anchors(q.repo.as_deref(), limit) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index cbf7161b..c608c92c 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1273,6 +1273,9 @@ pub async fn git_receive_pack( if announce { if let Some(p2p) = &p2p_handle { + // Publish the exact cert issued for this ref transition so + // peers can resolve the anchored certificate by id. + let cert_id = ref_certs_clone.get(ref_name).map(|c| c.id.clone()); p2p.publish_ref_update(crate::p2p::RefUpdateEvent { node_did: node_did_str.clone(), pusher_did: pusher_did_clone.clone(), @@ -1282,7 +1285,7 @@ pub async fn git_receive_pack( old_sha: old_sha.clone(), new_sha: new_sha.clone(), timestamp: chrono::Utc::now().to_rfc3339(), - cert_id: None, + cert_id, cid: cid.map(|s| s.to_string()), }) .await; diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index b5237da0..f94f10fa 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -408,6 +408,48 @@ pub async fn verify_anchor( )); } + // 0c. Corroborate outer repo slug and owner_did against the node's own + // record for the certificate's repo_id. The certificate signs the + // repo_id UUID but not the human-readable slug or owner DID, so a + // forger could otherwise echo attacker-chosen identities next to a + // valid:true verdict. When the node hosts the repo, the outer + // identity fields must agree with what it recorded. + let outer_repo = anchor.get("repo").and_then(|v| v.as_str()); + let outer_owner = anchor.get("owner_did").and_then(|v| v.as_str()); + match db.get_repo_by_id(&c.repo_id).await { + Ok(Some(record)) => { + let expected_repo = format!( + "{}/{}", + crate::db::normalize_owner_key(&record.owner_did), + record.name + ); + if let Some(outer_repo) = outer_repo { + if outer_repo != expected_repo { + errors.push(format!( + "anchor outer repo ({outer_repo}) does not match recorded repo ({expected_repo})" + )); + } + } + if let Some(outer_owner) = outer_owner { + if outer_owner != record.owner_did { + errors.push(format!( + "anchor outer owner_did ({outer_owner}) does not match recorded owner_did ({})", + record.owner_did + )); + } + } + } + Ok(None) => { + tracing::warn!( + repo_id = %c.repo_id, + "cannot corroborate anchor repo/owner_did — repo_id not found in node database" + ); + } + Err(e) => { + tracing::warn!("repo lookup failed for {}: {e}", c.repo_id); + } + } + // 1. Verify node signature on the certificate payload. // Certificates produced after this PR use a 13-field payload // that includes seq, prev, and proof fields. Pre-PR certificates @@ -490,6 +532,7 @@ pub async fn verify_anchor( let sig_valid_13 = gitlawb_core::identity::verify(&verifying_key, &payload_bytes_13, &sig_array); + let mut legacy_7_field_verified = false; if proof_fields_null && sig_valid_13.is_err() { // Fall back to 7-field payload for pre-PR certificates. let payload_7 = serde_json::json!({ @@ -502,17 +545,53 @@ pub async fn verify_anchor( "ts": c.issued_at, }); let payload_bytes_7 = serde_json::to_vec(&payload_7)?; - if let Err(e) = - gitlawb_core::identity::verify(&verifying_key, &payload_bytes_7, &sig_array) + if gitlawb_core::identity::verify(&verifying_key, &payload_bytes_7, &sig_array).is_ok() { - errors.push(format!( - "certificate signature verification failed (7-field): {e}" - )); + legacy_7_field_verified = true; + } else { + errors.push("certificate signature verification failed (7-field)".to_string()); } } else if let Err(e) = sig_valid_13 { errors.push(format!("certificate signature verification failed: {e}")); } + // 1b. Corroborate chain position for legacy certificates. + // The 7-field fallback covers only repo_id, ref, old, new, pusher, + // node, ts. seq and prev are NOT covered on that path, so a tampered + // legacy cert could otherwise pass with a blanket valid: true. Look + // up the node's own stored row and require seq/prev agreement. + if legacy_7_field_verified { + match db.get_ref_certificate(&c.id).await { + Ok(Some(stored)) => { + if stored.seq != c.seq { + errors.push(format!( + "certificate seq {} disagrees with stored seq {}", + c.seq, stored.seq + )); + } + if stored.prev != c.prev { + errors.push(format!( + "certificate prev {} disagrees with stored prev {}", + c.prev, stored.prev + )); + } + } + Ok(None) => { + errors.push(format!( + "certificate {} not found in node database — cannot corroborate legacy chain position", + c.id + )); + } + Err(e) => { + tracing::warn!("certificate lookup failed for {}: {e}", c.id); + errors.push(format!( + "error looking up certificate {} in node database", + c.id + )); + } + } + } + // 2. Verify prev hash linkage against the predecessor at seq - 1. // The prev hash covers the 7-field payload (repo_id, ref, old, new, // pusher, node, ts) — seq, prev, and proof fields are excluded so @@ -976,4 +1055,198 @@ mod tests { verify_result.errors ); } + + /// A true end-to-end accept: a cert signed by a real node keypair over a + /// real 13-field payload, with a real RFC 9421 pusher proof, served through + /// a mock gateway, must verify to `valid: true` with empty errors. + #[tokio::test] + async fn test_verify_anchor_accepts_authentic_13_field_certificate() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + let pusher_did = pusher_kp.did().as_str().to_string(); + + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let seq = 1i64; + let prev = "0".repeat(64); + + // Build a real RFC 9421 pusher proof over an arbitrary push body. + let request_path = "/repo-uuid.git/git-receive-pack"; + let signed = + gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); + // The stored pusher_sig is the raw STANDARD base64 of the 64-byte + // signature, unwrapped from the `sig1=:...:` header form. + let pusher_sig = signed + .signature + .strip_prefix("sig1=:") + .and_then(|s| s.strip_suffix(':')) + .unwrap() + .to_string(); + + // Sign the 13-field payload exactly as the node does. + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signed.signature_input, + "content_digest": signed.content_digest, + "request_path": request_path, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + + let cert = crate::db::RefCertificate { + id: "cert-accept-1".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did, + node_did: node_did.clone(), + signature, + issued_at: issued_at.to_string(), + seq, + prev, + pusher_sig: Some(pusher_sig), + signature_input: Some(signed.signature_input), + content_digest: Some(signed.content_digest), + request_path: Some(request_path.to_string()), + }; + + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/accept-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + + let result = verify_anchor(&client, &server.url(), "accept-tx", &db, &node_did).await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + r.valid, + "authentic 13-field cert must verify, errors: {:?}", + r.errors + ); + assert!( + r.errors.is_empty(), + "expected no errors, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } + + /// A tampered seq on an authentic legacy 7-field cert must fail: the + /// 7-field signature does not cover seq/prev, so the node's stored row + /// must be corroborated rather than accepting a blanket valid: true. + #[tokio::test] + async fn test_verify_anchor_legacy_seq_tamper_fails_closed() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + + // Sign the 7-field payload exactly as pre-PR nodes did. + let payload_7 = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": issued_at, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload_7).unwrap()); + + let cert = crate::db::RefCertificate { + id: "cert-legacy-tamper".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did: "did:key:z6MkPusher".to_string(), + node_did: node_did.clone(), + signature, + issued_at: issued_at.to_string(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/legacy-tamper-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + + // The cert id is not present in the (lazy) node database, so the + // legacy corroboration must fail closed instead of returning valid. + let result = + verify_anchor(&client, &server.url(), "legacy-tamper-tx", &db, &node_did).await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "legacy cert not present in node DB must not verify as valid" + ); + assert!( + r.errors + .iter() + .any(|e| e.contains("not found in node database") + || e.contains("error looking up certificate")), + "expected a corroboration error, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } } diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 3d653f16..f22fd0a5 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -155,6 +155,13 @@ pub struct Config { #[arg(long, env = "GITLAWB_MAX_PACK_BYTES", default_value_t = 2_147_483_648)] pub max_pack_bytes: usize, + /// Per-client-IP rate limit for the Arweave verify endpoint + /// (`GET /api/v1/arweave/verify/:tx_id`), in requests per hour. The route is + /// unauthenticated, so it is throttled by the resolved client IP. `0` + /// disables. Default: 120. + #[arg(long, env = "GITLAWB_ARWEAVE_RATE_LIMIT", default_value_t = 120)] + pub arweave_rate_limit: usize, + /// Per-client-IP rate limit for `POST /api/v1/sync/trigger`, in requests per /// hour. `/sync/trigger` requires a signature and drives an O(peers) outbound /// fan-out per call, so it gets a tight bucket. `0` disables. Default: 60. diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index e408e6ed..229c14d4 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -955,10 +955,10 @@ const MIGRATIONS: &[Migration] = &[ FROM ref_certificates ) subq WHERE ref_certificates.id = subq.id"#, - // Make cert chain append-only: drop the (repo_id, ref_name) unique index + // Make cert chain append-only: keep the (repo_id, ref_name) unique + // index for now (dropped in v20, after any rolling-upgrade window) // and add a unique constraint on (repo_id, seq) so concurrent pushes // cannot collide on the same sequence number. - "DROP INDEX IF EXISTS idx_ref_certs_repo_ref", "CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_seq ON ref_certificates(repo_id, seq)", // Store the full HTTP Signature context so a third party can verify // the pusher authorization proof (RFC 9421). @@ -967,6 +967,17 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS request_path TEXT", ], }, + Migration { + version: 20, + name: "drop_ref_certs_repo_ref_unique", + stmts: &[ + // Remove the superseded (repo_id, ref_name) unique index plus its + // per-ref append-only rails. Deferred to v20 so nodes are not + // running a mix of old/new code that each VACUUMed and relied on + // this unique index during a rolling upgrade (see v19). + "DROP INDEX IF EXISTS idx_ref_certs_repo_ref", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -1145,6 +1156,19 @@ impl Db { Ok(row.map(row_to_repo)) } + /// Fetch a repo by its UUID (the `repo_id` committed to by certificates). + pub async fn get_repo_by_id(&self, id: &str) -> Result> { + let row = sqlx::query( + "SELECT id, name, owner_did, description, is_public, default_branch, + created_at, updated_at, disk_path, forked_from, machine_id + FROM repos WHERE id = $1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_repo)) + } + #[allow(dead_code)] pub async fn list_repos(&self, owner_did: &str) -> Result> { let rows = sqlx::query( diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 175c8299..97455dcd 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -71,29 +71,25 @@ async fn main() -> Result<()> { let mut config = Config::parse(); // Fallback to legacy GITLAWB_IRYS_URL for backward compatibility during rename - let used_legacy_irys = if config.bundler_url.is_empty() { + if config.bundler_url.is_empty() { if let Ok(legacy) = std::env::var("GITLAWB_IRYS_URL") { if !legacy.is_empty() { config.bundler_url = legacy; tracing::warn!("GITLAWB_IRYS_URL is deprecated, use GITLAWB_BUNDLER_URL instead"); - true - } else { - false } - } else { - false } - } else { - false - }; + } - // When the legacy GITLAWB_IRYS_URL was used and GITLAWB_ARWEAVE_GATEWAY was - // not explicitly set, pair the gateway to the same network so that anchors - // uploaded to Irys devnet are verifiable through the verify endpoint. - if used_legacy_irys && std::env::var("GITLAWB_ARWEAVE_GATEWAY").is_err() { + // When a bundler URL is configured (whether via the modern + // GITLAWB_BUNDLER_URL or the legacy GITLAWB_IRYS_URL alias) and + // GITLAWB_ARWEAVE_GATEWAY was not explicitly set, pair the gateway to the + // same network so that anchors uploaded to a devnet bundler (whose + // transactions are not resolvable via the arweave.net default gateway) are + // verifiable through the verify endpoint. + if !config.bundler_url.is_empty() && std::env::var("GITLAWB_ARWEAVE_GATEWAY").is_err() { config.arweave_gateway = config.bundler_url.clone(); tracing::warn!( - "GITLAWB_ARWEAVE_GATEWAY unset — inferred from legacy GITLAWB_IRYS_URL as {}", + "GITLAWB_ARWEAVE_GATEWAY unset — inferred from bundler URL as {}", config.arweave_gateway ); } @@ -312,12 +308,9 @@ async fn main() -> Result<()> { // Per-client-IP limiter for the Arweave verify endpoint. The route is // unauthenticated (anyone can check a tx_id) and the per-DID creation - // limiter is too restrictive (10/hr). GITLAWB_ARWEAVE_RATE_LIMIT overrides; - // 0 disables. Bounded key set — the key is a client-influenced IP. - let arweave_limit = std::env::var("GITLAWB_ARWEAVE_RATE_LIMIT") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .unwrap_or(120); + // limiter is too restrictive (10/hr). 0 disables. Bounded key set — the + // key is a client-influenced IP. + let arweave_limit = config.arweave_rate_limit; let arweave_rate_limiter = rate_limit::RateLimiter::new_bounded( arweave_limit, std::time::Duration::from_secs(3600), From 99acf93d1a1b78c9fbbecb7253a3762f0ab8a78c Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 12 Aug 2026 11:23:59 +0600 Subject: [PATCH 18/25] feat(node): anchor ref updates and manifests as signed ANS-104 data items Bundlers (Irys, Turbo) require the upload to be a signed Arweave data item; the previous unsigned JSON POST with an x-bundler-tags header was neither a supported upload protocol nor authenticated. Build and sign ANS-104 items with the node keypair (the signature IS the upload credential) and embed the indexing metadata as item tags inside the signed item. - ans104: deepHash matching @irys/arbundles (length-tagged SHA-384 chain), Avro-style tag serialization, build_signed_data_item/verify_data_item, pinned to an independent reference vector plus tamper/forge rejection tests - anchor_ref_update/anchor_encrypted_manifest now POST signed data items; x-bundler-tags header helpers removed - repos.rs passes the node keypair at both anchor call sites - bundler POST tests now run against a real in-process server that parses the item, verifies the signature, and checks tags/payload (denies on any failure); adds an end-to-end wrong-key rejection test --- crates/gitlawb-node/src/ans104.rs | 434 ++++++++++++++ crates/gitlawb-node/src/api/arweave.rs | 65 ++- crates/gitlawb-node/src/api/repos.rs | 10 +- crates/gitlawb-node/src/arweave.rs | 767 ++++++++++++++++++++++--- crates/gitlawb-node/src/auth/mod.rs | 100 +++- crates/gitlawb-node/src/config.rs | 47 ++ crates/gitlawb-node/src/db/mod.rs | 53 +- crates/gitlawb-node/src/main.rs | 16 +- crates/gitlawb-node/src/server.rs | 64 ++- 9 files changed, 1429 insertions(+), 127 deletions(-) create mode 100644 crates/gitlawb-node/src/ans104.rs diff --git a/crates/gitlawb-node/src/ans104.rs b/crates/gitlawb-node/src/ans104.rs new file mode 100644 index 00000000..a8764178 --- /dev/null +++ b/crates/gitlawb-node/src/ans104.rs @@ -0,0 +1,434 @@ +//! ANS-104 signed data items for Arweave bundler uploads. +//! +//! Bundlers (Irys, Turbo, ...) accept a raw **Arweave data item** on their +//! upload endpoint and verify the embedded Ed25519 signature before accepting +//! the upload, so the node's own keypair *is* the upload credential — there is +//! no separate wallet/token. This is the "signed/authenticated upload protocol" +//! the Arweave surface is required to use. +//! +//! Binary layout (per the ANS-104 spec, ed25519 = signature type 2): +//! +//! ```text +//! 0 2 signature type (u16 LE) = 2 +//! 2 66 signature (64 bytes) +//! 66 98 owner public key (32 bytes) +//! 98 target presence byte (0 = absent) +//! 99 anchor presence byte (0 = absent) +//! 100 108 number of tags (u64 LE) +//! 108 116 number of tag bytes (u64 LE) +//! 116 ... serialized tags (Avro-style, see `serialize_tags`) +//! ... data (runs to end of buffer) +//! ``` +//! +//! The signature covers `deepHash(["dataitem", "1", type, owner, target, +//! anchor, tags, data])` using the bundler deepHash (length-tagged SHA-384, +//! identical to `@irys/arbundles`), so a bundler, gateway, or the node itself +//! can re-derive it from the item's own fields and verify against the owner. + +use anyhow::Result; +#[cfg(test)] +use anyhow::{anyhow, bail}; +use sha2::{Digest, Sha384}; + +/// SignatureConfig value for Ed25519 data items (ANS-104). +pub const SIGNATURE_TYPE_ED25519: u16 = 2; +const SIGNATURE_LEN: usize = 64; +const OWNER_LEN: usize = 32; + +/// Parsed contents of a verified data item. Verification is exercised by the +/// enforcement tests (see `verify_data_item`), which is gated on `cfg(test)`. +#[cfg(test)] +#[derive(Debug, PartialEq, Eq)] +pub struct DataItem { + pub signature: [u8; 64], + pub owner: [u8; 32], + pub tags: Vec<(String, String)>, + pub data: Vec, +} + +/// Build and sign an ANS-104 data item carrying `data` plus the given tags. +/// +/// The tags are embedded *inside* the item (where the bundler verifies them +/// against the signature); nothing is passed out-of-band. +pub fn build_signed_data_item( + keypair: &gitlawb_core::identity::Keypair, + tags: &[(&str, &str)], + data: &[u8], +) -> Result> { + let owner = keypair.verifying_key().to_bytes(); + let serialized_tags = serialize_tags(tags)?; + + let mut item = Vec::with_capacity( + 2 + SIGNATURE_LEN + OWNER_LEN + 2 + 16 + serialized_tags.len() + data.len(), + ); + item.extend_from_slice(&SIGNATURE_TYPE_ED25519.to_le_bytes()); // 0..2 + item.extend_from_slice(&[0u8; SIGNATURE_LEN]); // 2..66, filled below + item.extend_from_slice(&owner); // 66..98 + item.push(0u8); // target presence: absent + item.push(0u8); // anchor presence: absent + item.extend_from_slice(&(tags.len() as u64).to_le_bytes()); // 100..108 + item.extend_from_slice(&(serialized_tags.len() as u64).to_le_bytes()); // 108..116 + item.extend_from_slice(&serialized_tags); + item.extend_from_slice(data); + + let signature_data = deep_hash(&[ + b"dataitem", + b"1", + SIGNATURE_TYPE_ED25519.to_string().as_bytes(), + &owner, + &[], + &[], + &serialized_tags, + data, + ]); + let signature = keypair.sign(&signature_data).to_bytes(); + item[2..2 + SIGNATURE_LEN].copy_from_slice(&signature); + Ok(item) +} + +/// Parse a data item and verify its Ed25519 signature against `verifying_key` +/// over the deepHash of its own fields. Returns the parsed item (tags + data) +/// on success. This is exactly what a bundler/gateway does on receipt, so a +/// test can use it to enforce the signed-upload contract. +#[cfg(test)] +pub fn verify_data_item( + verifying_key: &ed25519_dalek::VerifyingKey, + item: &[u8], +) -> Result { + if item.len() < 2 + SIGNATURE_LEN + OWNER_LEN + 2 + 16 { + bail!("data item too short"); + } + let signature_type = u16::from_le_bytes(item[0..2].try_into()?); + if signature_type != SIGNATURE_TYPE_ED25519 { + bail!("unsupported signature type {signature_type}"); + } + let signature: [u8; SIGNATURE_LEN] = item[2..2 + SIGNATURE_LEN].try_into()?; + let owner: [u8; OWNER_LEN] = + item[2 + SIGNATURE_LEN..2 + SIGNATURE_LEN + OWNER_LEN].try_into()?; + + let mut p = 2 + SIGNATURE_LEN + OWNER_LEN; + let target_present = item[p]; + p += 1; + let raw_target: &[u8] = match target_present { + 0 => &[], + 1 => { + let end = p + OWNER_LEN; + if end > item.len() { + bail!("data item truncated in target"); + } + let t = &item[p..end]; + p = end; + t + } + other => bail!("invalid target presence byte {other}"), + }; + let anchor_present = item[p]; + p += 1; + let raw_anchor: &[u8] = match anchor_present { + 0 => &[], + 1 => { + let end = p + OWNER_LEN; + if end > item.len() { + bail!("data item truncated in anchor"); + } + let a = &item[p..end]; + p = end; + a + } + other => bail!("invalid anchor presence byte {other}"), + }; + + let num_tags = u64::from_le_bytes(item[p..p + 8].try_into()?); + p += 8; + let num_tag_bytes = u64::from_le_bytes(item[p..p + 8].try_into()?); + p += 8; + let tags_end = p + .checked_add(num_tag_bytes as usize) + .ok_or_else(|| anyhow!("tag byte count overflow"))?; + if tags_end > item.len() { + bail!("data item truncated in tags"); + } + let raw_tags = &item[p..tags_end]; + let raw_data = &item[tags_end..]; + + let tags = deserialize_tags(raw_tags)?; + if tags.len() != num_tags as usize { + bail!( + "tag count {} disagrees with serialized length {}", + tags.len(), + num_tags + ); + } + + let signature_data = deep_hash(&[ + b"dataitem", + b"1", + signature_type.to_string().as_bytes(), + &owner, + raw_target, + raw_anchor, + raw_tags, + raw_data, + ]); + let sig = ed25519_dalek::Signature::from_bytes(&signature); + verifying_key + .verify_strict(&signature_data, &sig) + .map_err(|e| anyhow!("data item signature verification failed: {e}"))?; + + Ok(DataItem { + signature, + owner, + tags, + data: raw_data.to_vec(), + }) +} + +/// deepHash of a flat list of byte blobs — the bundler's `deepHash` over the +/// data item's signature fields. `deepHash(list)` = SHA-384 chain seeded by +/// SHA-384("list"); each blob is hashed as SHA-384(SHA-384("blob") || +/// SHA-384(data)). +pub fn deep_hash(elems: &[&[u8]]) -> [u8; 48] { + let mut acc = sha384(format!("list{}", elems.len()).as_bytes()); + for elem in elems { + let chunk = deep_hash_blob(elem); + let mut pair = [0u8; 96]; + pair[..48].copy_from_slice(&acc); + pair[48..].copy_from_slice(&chunk); + acc = sha384(&pair); + } + acc +} + +fn deep_hash_blob(data: &[u8]) -> [u8; 48] { + let mut tagged = [0u8; 96]; + tagged[..48].copy_from_slice(&sha384(format!("blob{}", data.len()).as_bytes())); + tagged[48..].copy_from_slice(&sha384(data)); + sha384(&tagged) +} + +fn sha384(data: &[u8]) -> [u8; 48] { + let mut h = Sha384::new(); + h.update(data); + h.finalize().into() +} + +/// Avro-style tag encoding matching `@irys/arbundles` `serializeTags`. +/// +/// For `n > 0` tags: zigzag-varint(n), then for each tag the zigzag-varint +/// length + UTF-8 bytes of name and value, then a terminating zigzag-varint(0). +/// Zero tags serializes to an empty buffer. +fn serialize_tags(tags: &[(&str, &str)]) -> Result> { + let mut out = Vec::new(); + if tags.is_empty() { + return Ok(out); + } + write_long(&mut out, tags.len() as i64)?; + for (name, value) in tags { + write_string(&mut out, name)?; + write_string(&mut out, value)?; + } + write_long(&mut out, 0)?; + Ok(out) +} + +#[cfg(test)] +fn deserialize_tags(buf: &[u8]) -> Result> { + let mut pos = 0usize; + let mut tags = Vec::new(); + loop { + let n = read_long(buf, &mut pos)?; + if n == 0 { + break; + } + let mut count = n; + if n < 0 { + // Negative array length: block count + a block byte-size to skip. + count = -n; + let _block_size = read_long(buf, &mut pos)?; + } + for _ in 0..count { + let name = read_string(buf, &mut pos)?; + let value = read_string(buf, &mut pos)?; + tags.push((name, value)); + } + } + Ok(tags) +} + +fn write_string(out: &mut Vec, s: &str) -> Result<()> { + let bytes = s.as_bytes(); + write_long(out, bytes.len() as i64)?; + out.extend_from_slice(bytes); + Ok(()) +} + +#[cfg(test)] +fn read_string(buf: &[u8], pos: &mut usize) -> Result { + let len = read_long(buf, pos)?; + if len < 0 { + bail!("negative string length"); + } + let len = len as usize; + let end = pos + .checked_add(len) + .ok_or_else(|| anyhow!("string length overflow"))?; + if end > buf.len() { + bail!("tag stream truncated in string"); + } + let s = std::str::from_utf8(&buf[*pos..end])?.to_string(); + *pos = end; + Ok(s) +} + +/// Zigzag + base-128 varint (Avro `writeLong`). +fn write_long(out: &mut Vec, n: i64) -> Result<()> { + let mut m = ((n as u64) << 1) ^ ((n >> 63) as u64); + loop { + let mut byte = (m & 0x7f) as u8; + m >>= 7; + if m != 0 { + byte |= 0x80; + } + out.push(byte); + if m == 0 { + break; + } + } + Ok(()) +} + +/// Zigzag + base-128 varint (Avro `readLong`). +#[cfg(test)] +fn read_long(buf: &[u8], pos: &mut usize) -> Result { + let mut value: u64 = 0; + let mut shift = 0u32; + loop { + if *pos >= buf.len() { + bail!("tag stream truncated in varint"); + } + let byte = buf[*pos]; + *pos += 1; + value |= ((byte & 0x7f) as u64) << shift; + if byte & 0x80 == 0 { + break; + } + shift += 7; + if shift >= 64 { + bail!("tag stream varint overlong"); + } + } + Ok(((value >> 1) as i64) ^ -((value & 1) as i64)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gitlawb_core::identity::Keypair; + + /// Independent reference vector, generated with a separate implementation + /// (Python/OpenSSL hashlib, not the code under test). Pins the deepHash + /// wire format against `@irys/arbundles` so an accidental divergence in + /// the length-tagging (e.g. reintroducing the old pairwise chaining) + /// turns this test red and every previously-signed anchor would no longer + /// verify. + #[test] + fn deep_hash_matches_independent_reference_vector() { + let owner = [0x41u8; 32]; + // Elements: "dataitem", "1", "2", owner, target, anchor, tags, data. + // 0 tags -> serialized tag bytes are empty; data = b"hi". + let hash = deep_hash(&[b"dataitem", b"1", b"2", &owner, &[], &[], &[], b"hi"]); + let expected = "98a0a3b931f9c5cc370e822ca06b6e9635f690f81979b70b6dfe92d0af3f601169b0d8dc72d518241e3caba7f9daad1d"; + assert_eq!(hex::encode(hash), expected); + } + + #[test] + fn serialize_tags_matches_reference_layout() { + assert!(serialize_tags(&[]).unwrap().is_empty()); + // 1 tag: zigzag(1)=0x02, then name/value as varint-len + utf8, + // then terminating 0x00. + let one = serialize_tags(&[("App-Name", "gitlawb")]).unwrap(); + assert_eq!( + one, + [ + 0x02, // zigzag(1) = array count 1 + 0x10, // zigzag(8) = "App-Name".len() + b'A', b'p', b'p', b'-', b'N', b'a', b'm', b'e', + 0x0e, // zigzag(7) = "gitlawb".len() + b'g', b'i', b't', b'l', b'a', b'w', b'b', 0x00, // end of array + ] + ); + } + + #[test] + fn build_then_verify_round_trip() { + let kp = Keypair::generate(); + let data = br#"{"schema":"gitlawb/ref-update/v1","repo":"alice/myrepo"}"#; + let item = build_signed_data_item( + &kp, + &[("App-Name", "gitlawb"), ("Repo", "alice/myrepo")], + data, + ) + .unwrap(); + + // Layout sanity: sig type first, owner at its fixed offset. + assert_eq!(&item[0..2], &[0x02, 0x00]); + assert_eq!( + &item[2 + SIGNATURE_LEN..2 + SIGNATURE_LEN + OWNER_LEN], + &kp.verifying_key().to_bytes() + ); + + let parsed = verify_data_item(&kp.verifying_key(), &item).unwrap(); + assert_eq!( + parsed.tags, + vec![ + ("App-Name".to_string(), "gitlawb".to_string()), + ("Repo".to_string(), "alice/myrepo".to_string()), + ] + ); + assert_eq!(parsed.data, data); + assert_eq!(parsed.owner, kp.verifying_key().to_bytes()); + } + + #[test] + fn verify_rejects_tampered_signature() { + let kp = Keypair::generate(); + let item = build_signed_data_item(&kp, &[("App-Name", "gitlawb")], b"payload").unwrap(); + let mut forged = item.clone(); + forged[3] ^= 0x01; + assert!(verify_data_item(&kp.verifying_key(), &forged).is_err()); + } + + #[test] + fn verify_rejects_item_signed_by_other_key() { + let node = Keypair::generate(); + let attacker = Keypair::generate(); + let item = + build_signed_data_item(&attacker, &[("App-Name", "gitlawb")], b"payload").unwrap(); + assert!( + verify_data_item(&node.verifying_key(), &item).is_err(), + "item signed by a different key must not verify against the node key" + ); + } + + #[test] + fn verify_rejects_altered_data_or_tags() { + let kp = Keypair::generate(); + let item = build_signed_data_item(&kp, &[("Repo", "alice/real")], b"original").unwrap(); + let mut tampered_data = item.clone(); + let n = tampered_data.len(); + tampered_data[n - 1] ^= 0x01; + assert!(verify_data_item(&kp.verifying_key(), &tampered_data).is_err()); + // Tag value flipped inside the item. + let mut tampered_tag = item; + tampered_tag[120] = b'x'; + assert!(verify_data_item(&kp.verifying_key(), &tampered_tag).is_err()); + } + + #[test] + fn verify_rejects_truncated_and_garbage_items() { + let kp = Keypair::generate(); + let item = build_signed_data_item(&kp, &[("App-Name", "gitlawb")], b"payload").unwrap(); + assert!(verify_data_item(&kp.verifying_key(), &item[..item.len() - 1]).is_err()); + assert!(verify_data_item(&kp.verifying_key(), b"not a data item").is_err()); + } +} diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index acac06f4..2428fe87 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -81,7 +81,11 @@ pub async fn list_anchors( .list_arweave_anchors(q.repo.as_deref(), limit) .await?; - let gateway = state.config.arweave_gateway.trim_end_matches('/'); + // The gateway config may carry credentials (e.g. an Irys user:pass). Those + // must never leak into a public listing, so only the credential-free origin + // is embedded in each anchor's URL. + let gateway = + crate::server::mask_credential_url(state.config.arweave_gateway.trim_end_matches('/')); let anchors: Vec = anchors .into_iter() .map(|mut a| { @@ -141,4 +145,63 @@ mod closed_pool_tests { }) ); } + + /// A credentialed gateway (user:pass in the URL) must not leak into the + /// public anchors listing — every `arweave_url` is built from the masked + /// origin, never the raw config. + #[sqlx::test] + async fn list_anchors_does_not_leak_gateway_credentials(pool: PgPool) { + use clap::Parser as _; + + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--arweave-gateway", + "https://user:supersecret@arweave.net", + ])); + + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: Some("bafy1test"), + arweave_tx_id: &"f".repeat(43), + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let body: String = String::from_utf8(bytes.to_vec()).expect("utf8 body"); + assert!( + !body.contains("supersecret"), + "anchors listing must not disclose gateway credentials" + ); + assert!( + body.contains("https://arweave.net/"), + "arweave_url should carry the credential-free origin" + ); + let v: Value = serde_json::from_str(&body).expect("json body"); + assert_eq!(v["count"], 1); + } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index d8c79fcc..46d4a311 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1249,6 +1249,7 @@ async fn pin_and_encrypt_objects( &ctx.http_client, &ctx.irys_url, &manifest, + &ctx.node_keypair, ) .await { @@ -2553,8 +2554,13 @@ async fn post_receive_replication_tail( node_did: node_did_str.clone(), certificate: cert, }; - match crate::arweave::anchor_ref_update(&http_client, &bundler_url, &anchor) - .await + match crate::arweave::anchor_ref_update( + &http_client, + &bundler_url, + &anchor, + &node_keypair, + ) + .await { Ok(tx_id) if !tx_id.is_empty() => { if let Err(e) = db_clone diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index f94f10fa..30ff735b 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -5,8 +5,11 @@ //! //! { repo, owner_did, ref_name, old_sha, new_sha, cid, timestamp, node_did } //! -//! Irys allows free uploads for data < 100 KiB on both devnet and mainnet -//! (via Turbo). No wallet is required for payloads under the free threshold. +//! Uploads are signed ANS-104 data items (see [`crate::ans104`]): the node +//! signs the item with its own keypair and embeds the metadata as item tags, so +//! no separate wallet or upload credential is needed — the signature is the +//! authentication the bundler enforces. Irys allows free uploads for data +//! < 100 KiB on both devnet and mainnet (via Turbo). //! //! Set `GITLAWB_BUNDLER_URL` (deprecated name: `GITLAWB_IRYS_URL`) to override the default endpoint: //! - devnet (free, no cost): https://devnet.irys.xyz @@ -49,12 +52,16 @@ pub struct RefAnchor { /// Anchor a ref-update to Arweave via Irys. /// -/// Returns the Irys/Arweave transaction ID on success. +/// The payload is uploaded as a signed ANS-104 data item: `node_keypair` signs +/// the item and the indexing metadata (App-Name, Schema, Repo, Ref, SHA, +/// Node-DID) is embedded as data-item tags inside the signed item — never in a +/// request header. Returns the Irys/Arweave transaction ID on success. /// Returns `Ok("")` if `bundler_url` is empty (anchoring disabled). pub async fn anchor_ref_update( client: &reqwest::Client, bundler_url: &str, anchor: &RefAnchor, + node_keypair: &gitlawb_core::identity::Keypair, ) -> Result { if bundler_url.is_empty() { return Ok(String::new()); @@ -81,14 +88,30 @@ pub async fn anchor_ref_update( let body = serde_json::to_vec(&payload)?; + let tags: Vec<(String, String)> = [ + "App-Name:gitlawb".to_string(), + "Schema:gitlawb/ref-update/v1".to_string(), + format!("Repo:{}", sanitize_tag(&anchor.repo)), + format!("Ref:{}", sanitize_tag(&anchor.ref_name)), + format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]), + format!("Node-DID:{}", sanitize_tag(&anchor.node_did)), + ] + .iter() + .map(|pair| { + let (name, value) = pair.split_once(':').unwrap_or((pair.as_str(), "")); + (name.to_string(), value.to_string()) + }) + .collect(); + + let data_item = crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body)?; + // Irys upload endpoint let url = format!("{}/v1/tx", bundler_url.trim_end_matches('/')); let resp = client .post(&url) .header("Content-Type", "application/octet-stream") - .header("x-bundler-tags", build_tags_header(anchor)) - .body(body) + .body(data_item) .send() .await .map_err(|e| anyhow::anyhow!("Bundler upload failed: {e}"))?; @@ -139,12 +162,16 @@ pub struct EncryptedManifest<'a> { /// the anchor is permanent and public, and the v2 envelopes no longer expose /// recipients, so the reader set must not be written to Arweave either. /// +/// The manifest is uploaded as a signed ANS-104 data item (same scheme as +/// [`anchor_ref_update`]); the discovery tags are embedded inside the item. +/// /// Returns the Arweave transaction ID, or `Ok("")` when `bundler_url` is empty /// (anchoring disabled) or there are no blobs to anchor. pub async fn anchor_encrypted_manifest( client: &reqwest::Client, bundler_url: &str, manifest: &EncryptedManifest<'_>, + node_keypair: &gitlawb_core::identity::Keypair, ) -> Result { if bundler_url.is_empty() || manifest.blobs.is_empty() { return Ok(String::new()); @@ -166,13 +193,28 @@ pub async fn anchor_encrypted_manifest( }); let body = serde_json::to_vec(&payload)?; + + let tags: Vec<(String, String)> = [ + "App-Name:gitlawb".to_string(), + "Schema:gitlawb/encrypted-manifest/v1".to_string(), + format!("Repo:{}", sanitize_tag(manifest.repo)), + format!("Owner-DID:{}", sanitize_tag(manifest.owner_did)), + format!("Node-DID:{}", sanitize_tag(manifest.node_did)), + ] + .iter() + .map(|pair| { + let (name, value) = pair.split_once(':').unwrap_or((pair.as_str(), "")); + (name.to_string(), value.to_string()) + }) + .collect(); + + let data_item = crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body)?; let url = format!("{}/v1/tx", bundler_url.trim_end_matches('/')); let resp = client .post(&url) .header("Content-Type", "application/octet-stream") - .header("x-bundler-tags", build_manifest_tags_header(manifest)) - .body(body) + .body(data_item) .send() .await .map_err(|e| anyhow::anyhow!("Bundler upload failed: {e}"))?; @@ -210,31 +252,10 @@ fn manifest_blob_json(oid: &str, cid: &str) -> serde_json::Value { json!({ "oid": oid, "cid": cid }) } -/// Build the bundler tag header for an encrypted-blob manifest. `Repo` and `Schema` -/// are the tags the `gl` recovery query filters on. -fn build_manifest_tags_header(manifest: &EncryptedManifest<'_>) -> String { - [ - "App-Name:gitlawb".to_string(), - "Schema:gitlawb/encrypted-manifest/v1".to_string(), - format!("Repo:{}", sanitize_tag(manifest.repo)), - format!("Owner-DID:{}", sanitize_tag(manifest.owner_did)), - format!("Node-DID:{}", sanitize_tag(manifest.node_did)), - ] - .join(",") -} - -/// Build the bundler tag header value for Arweave indexing. -/// Format: comma-separated "name:value" pairs. -fn build_tags_header(anchor: &RefAnchor) -> String { - [ - "App-Name:gitlawb".to_string(), - "Schema:gitlawb/ref-update/v1".to_string(), - format!("Repo:{}", sanitize_tag(&anchor.repo)), - format!("Ref:{}", sanitize_tag(&anchor.ref_name)), - format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]), - format!("Node-DID:{}", sanitize_tag(&anchor.node_did)), - ] - .join(",") +/// Borrow `(name, value)` string slices from owned tag pairs for +/// [`crate::ans104::build_signed_data_item`]. +fn tag_refs(tags: &[(String, String)]) -> Vec<(&str, &str)> { + tags.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect() } /// Strip characters that are invalid in bundler/Arweave tag values. @@ -559,9 +580,22 @@ pub async fn verify_anchor( // The 7-field fallback covers only repo_id, ref, old, new, pusher, // node, ts. seq and prev are NOT covered on that path, so a tampered // legacy cert could otherwise pass with a blanket valid: true. Look - // up the node's own stored row and require seq/prev agreement. + // up the node's own stored row by the FIELDS THE SIGNATURE COVERS + // (repo_id, ref_name, old_sha, new_sha, issued_at) — never by `id`, + // which appears in no signed payload and would let a forger choose + // which stored row their seq/prev claims are measured against — and + // require seq/prev agreement. if legacy_7_field_verified { - match db.get_ref_certificate(&c.id).await { + match db + .get_cert_by_signed_tuple( + &c.repo_id, + &c.ref_name, + &c.old_sha, + &c.new_sha, + &c.issued_at, + ) + .await + { Ok(Some(stored)) => { if stored.seq != c.seq { errors.push(format!( @@ -577,10 +611,10 @@ pub async fn verify_anchor( } } Ok(None) => { - errors.push(format!( - "certificate {} not found in node database — cannot corroborate legacy chain position", - c.id - )); + errors.push( + "no stored certificate matches the signed (repo_id, ref_name, old_sha, new_sha, ts) — cannot corroborate legacy chain position" + .to_string(), + ); } Err(e) => { tracing::warn!("certificate lookup failed for {}: {e}", c.id); @@ -782,9 +816,79 @@ pub async fn verify_anchor( #[cfg(test)] mod tests { use super::*; + use axum::http::StatusCode; + use gitlawb_core::identity::Keypair; + + /// Spin up an in-process bundler that *enforces* the signed data item + /// contract: it parses the posted bytes as an ANS-104 item, verifies the + /// Ed25519 signature against `kp`, checks that every `expected_tag` is + /// present inside the item, and requires the embedded JSON payload to pass + /// `validate`. Any failure returns 400 (surfacing as `Err` from the anchor + /// functions); success returns `{"id": }`. + async fn spawn_enforcing_bundler( + kp: &Keypair, + expected_tags: &[(&str, &str)], + validate: impl Fn(&serde_json::Value) -> bool + Send + Sync + Clone + 'static, + tx_id: &'static str, + ) -> String { + let vk = kp.verifying_key(); + let expected: Vec<(String, String)> = expected_tags + .iter() + .map(|(n, v)| (n.to_string(), v.to_string())) + .collect(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = axum::Router::new().route( + "/v1/tx", + axum::routing::post(move |body: axum::body::Bytes| { + let vk = vk; + let expected = expected.clone(); + async move { + let parsed = match crate::ans104::verify_data_item(&vk, &body) { + Ok(p) => p, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + format!("unsigned/invalid item: {e}"), + ); + } + }; + for (name, value) in &expected { + if !parsed.tags.iter().any(|(tn, tv)| tn == name && tv == value) { + return ( + StatusCode::BAD_REQUEST, + format!("missing signed tag {name}:{value}"), + ); + } + } + let json: serde_json::Value = match serde_json::from_slice(&parsed.data) { + Ok(j) => j, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + format!("item data is not JSON: {e}"), + ); + } + }; + if !validate(&json) { + return ( + StatusCode::BAD_REQUEST, + "payload validation failed".to_string(), + ); + } + (StatusCode::OK, format!(r#"{{"id":"{tx_id}"}}"#)) + } + }), + ); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") + } #[tokio::test] async fn test_anchor_noop_when_url_empty() { + let kp = Keypair::generate(); let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), @@ -798,21 +902,25 @@ mod tests { node_did: "did:key:z6MknndwexV9...".into(), certificate: None, }; - let result = anchor_ref_update(&client, "", &anchor).await; + let result = anchor_ref_update(&client, "", &anchor, &kp).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), ""); } #[tokio::test] async fn test_anchor_success() { - let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("POST", "/v1/tx") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"id":"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk","timestamp":1710000000000,"version":"1.0.0"}"#) - .create_async() - .await; + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + &[ + ("App-Name", "gitlawb"), + ("Schema", "gitlawb/ref-update/v1"), + ("Repo", "alice/myrepo"), + ], + |j| j["repo"] == "alice/myrepo", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk", + ) + .await; let client = reqwest::Client::new(); let anchor = RefAnchor { @@ -828,13 +936,12 @@ mod tests { certificate: None, }; - let result = anchor_ref_update(&client, &server.url(), &anchor).await; + let result = anchor_ref_update(&client, &server, &anchor, &kp).await; assert!(result.is_ok(), "anchor should succeed: {result:?}"); assert_eq!( result.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk" ); - _mock.assert_async().await; } #[tokio::test] @@ -842,20 +949,18 @@ mod tests { // The anchored body must serialize the real old→new transition the // node was handed, never a zero placeholder. Regression guard for the // push handler that used to hardcode `old_sha` to 64 zeros (#26). - let mut server = mockito::Server::new_async().await; + // The enforcing bundler rejects the upload unless the signed item's + // JSON data carries both real SHAs. let real_old = "1111111111111111111111111111111111111111"; let real_new = "2222222222222222222222222222222222222222"; - let _mock = server - .mock("POST", "/v1/tx") - .match_body(mockito::Matcher::AllOf(vec![ - mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{real_old}"}}"#)), - mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{real_new}"}}"#)), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"id":"TX_REAL_OLD_SHA","timestamp":1710000000000,"version":"1.0.0"}"#) - .create_async() - .await; + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + &[("App-Name", "gitlawb")], + move |j| j["old_sha"] == real_old && j["new_sha"] == real_new, + "TX_REAL_OLD_SHA", + ) + .await; let client = reqwest::Client::new(); let anchor = RefAnchor { @@ -871,10 +976,43 @@ mod tests { certificate: None, }; - let result = anchor_ref_update(&client, &server.url(), &anchor).await; + let result = anchor_ref_update(&client, &server, &anchor, &kp).await; assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA"); - // The mock only matches when the posted JSON carries both real SHAs. - _mock.assert_async().await; + } + + #[tokio::test] + async fn test_anchor_rejected_when_signed_by_other_key() { + // The bundler enforces the node's public key; an item signed by a + // different credential must be denied end-to-end, not silently accepted. + let node_kp = Keypair::generate(); + let impostor_kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &node_kp, + &[("App-Name", "gitlawb")], + |_| true, + "NEVER_RETURNED", + ) + .await; + + let client = reqwest::Client::new(); + let anchor = RefAnchor { + repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), + owner_did: "did:key:z6Mk...".into(), + ref_name: "refs/heads/main".into(), + old_sha: "0".repeat(40), + new_sha: "a1b2c3d4".repeat(8), + cid: None, + timestamp: "2026-03-14T00:00:00Z".into(), + node_did: "did:key:z6Mknnd...".into(), + certificate: None, + }; + + let result = anchor_ref_update(&client, &server, &anchor, &impostor_kp).await; + assert!( + result.is_err(), + "upload signed by the wrong key must be denied by the bundler" + ); } #[test] @@ -892,6 +1030,7 @@ mod tests { #[tokio::test] async fn test_manifest_anchor_noop_when_url_empty() { let client = reqwest::Client::new(); + let kp = Keypair::generate(); let blobs = vec![("oid1".to_string(), "cid1".to_string())]; let m = EncryptedManifest { repo: "alice/r", @@ -901,7 +1040,9 @@ mod tests { blobs: &blobs, }; assert_eq!( - anchor_encrypted_manifest(&client, "", &m).await.unwrap(), + anchor_encrypted_manifest(&client, "", &m, &kp) + .await + .unwrap(), "" ); } @@ -909,6 +1050,7 @@ mod tests { #[tokio::test] async fn test_manifest_anchor_noop_when_no_blobs() { let client = reqwest::Client::new(); + let kp = Keypair::generate(); let blobs: Vec<(String, String)> = vec![]; let m = EncryptedManifest { repo: "alice/r", @@ -919,7 +1061,7 @@ mod tests { }; // Non-empty URL, but no blobs: still a no-op. assert_eq!( - anchor_encrypted_manifest(&client, "https://example.invalid", &m) + anchor_encrypted_manifest(&client, "https://example.invalid", &m, &kp) .await .unwrap(), "" @@ -928,14 +1070,20 @@ mod tests { #[tokio::test] async fn test_manifest_anchor_success() { - let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("POST", "/v1/tx") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"id":"MANIFESTTX123","timestamp":1710000000000,"version":"1.0.0"}"#) - .create_async() - .await; + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + &[ + ("App-Name", "gitlawb"), + ("Schema", "gitlawb/encrypted-manifest/v1"), + ("Repo", "alice/r"), + ("Owner-DID", "did:key:zO"), + ("Node-DID", "did:key:zN"), + ], + |j| j["repo"] == "alice/r" && j["blobs"].as_array().is_some_and(|b| b.len() == 1), + "MANIFESTTX123", + ) + .await; let client = reqwest::Client::new(); let blobs = vec![("oid1".to_string(), "cid1".to_string())]; @@ -946,9 +1094,8 @@ mod tests { timestamp: "2026-06-11T00:00:00Z", blobs: &blobs, }; - let r = anchor_encrypted_manifest(&client, &server.url(), &m).await; + let r = anchor_encrypted_manifest(&client, &server, &m, &kp).await; assert_eq!(r.unwrap(), "MANIFESTTX123"); - _mock.assert_async().await; } #[test] @@ -1037,7 +1184,12 @@ mod tests { .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - let result = verify_anchor(&client, &server.url(), "test-tx", &db, "did:key:zNODE").await; + // Verify as "malformed-node-did" itself so the issuer check passes and + // the DID-parse guard is what must fire. This pins the `invalid node + // DID` error push: with the anchor claiming the node IS the malformed + // DID, only parsing the certificate's node_did can reject it. + let result = + verify_anchor(&client, &server.url(), "test-tx", &db, "malformed-node-did").await; assert!( result.is_ok(), "Expected Ok response, got Err: {:?}", @@ -1050,12 +1202,313 @@ mod tests { verify_result .errors .iter() - .any(|e| e.contains("does not match this node") || e.contains("invalid node DID")), - "Expected issuer or DID error in: {:?}", + .any(|e| e.contains("invalid node DID")), + "Expected the DID-parse error, got: {:?}", verify_result.errors ); } + /// Pins the issuer guard (`c.node_did != node_did`): a cert that is fully + /// authentic — real node signature over the real 13-field payload, real + /// pusher proof — but names a DIFFERENT node as its issuer must fail with + /// exactly the issuer-mismatch error. If the guard were removed, the cert + /// would verify clean (the signature resolves against its own node_did), + /// so this test turns that regression red. + #[tokio::test] + async fn test_verify_anchor_rejects_cert_issued_by_different_node() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let other_kp = gitlawb_core::identity::Keypair::generate(); + let other_did = other_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + let pusher_did = pusher_kp.did().as_str().to_string(); + + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let seq = 1i64; + let prev = "0".repeat(64); + + let request_path = "/repo-uuid.git/git-receive-pack"; + let signed = + gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); + let pusher_sig = signed + .signature + .strip_prefix("sig1=:") + .and_then(|s| s.strip_suffix(':')) + .unwrap() + .to_string(); + + // Signed by `other_kp`, which the payload names as node_did — so the + // cert is internally self-consistent and its signature verifies. + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": other_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signed.signature_input, + "content_digest": signed.content_digest, + "request_path": request_path, + }); + let signature = other_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + + let cert = crate::db::RefCertificate { + id: "cert-other-node".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did, + node_did: other_did.clone(), + signature, + issued_at: issued_at.to_string(), + seq, + prev, + pusher_sig: Some(pusher_sig), + signature_input: Some(signed.signature_input), + content_digest: Some(signed.content_digest), + request_path: Some(request_path.to_string()), + }; + + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": other_did, + "certificate": cert, + }); + + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/other-node-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + + let result = verify_anchor(&client, &server.url(), "other-node-tx", &db, &node_did).await; + let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !verify_result.valid, + "cert issued by a different node must not verify as valid" + ); + assert!( + verify_result + .errors + .iter() + .any(|e| e.contains("does not match this node")), + "expected the issuer-mismatch error, got: {:?}", + verify_result.errors + ); + _mock.assert_async().await; + } + + /// Pins the 13-field signature-failure error push: an authentic cert whose + /// node signature was tampered must fail with the 13-field signature error. + /// If the push were removed, no other guard would catch it (the proof + /// fields are present, so no 7-field fallback runs and the tamper would be + /// silent). + #[tokio::test] + async fn test_verify_anchor_rejects_tampered_13_field_signature() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + let pusher_did = pusher_kp.did().as_str().to_string(); + + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let seq = 1i64; + let prev = "0".repeat(64); + + let request_path = "/repo-uuid.git/git-receive-pack"; + let signed = + gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); + let pusher_sig = signed + .signature + .strip_prefix("sig1=:") + .and_then(|s| s.strip_suffix(':')) + .unwrap() + .to_string(); + + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signed.signature_input, + "content_digest": signed.content_digest, + "request_path": request_path, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + // Tamper: flip one byte in the node signature. + let tampered_signature = format!("A{}", &signature[1..]); + + let cert = crate::db::RefCertificate { + id: "cert-tampered-13".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did, + node_did: node_did.clone(), + signature: tampered_signature, + issued_at: issued_at.to_string(), + seq, + prev, + pusher_sig: Some(pusher_sig), + signature_input: Some(signed.signature_input), + content_digest: Some(signed.content_digest), + request_path: Some(request_path.to_string()), + }; + + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/tampered-13-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + + let result = verify_anchor(&client, &server.url(), "tampered-13-tx", &db, &node_did).await; + let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !verify_result.valid, + "tampered 13-field cert must not verify as valid" + ); + assert!( + verify_result + .errors + .iter() + .any(|e| e.contains("certificate signature verification failed")), + "expected the 13-field signature error, got: {:?}", + verify_result.errors + ); + _mock.assert_async().await; + } + + /// Pins the 7-field signature-failure error push: a legacy cert (proof + /// fields NULL) whose node signature was tampered must fail with the + /// 7-field signature error. + #[tokio::test] + async fn test_verify_anchor_rejects_tampered_7_field_signature() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": issued_at, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + let tampered_signature = format!("A{}", &signature[1..]); + + let cert = crate::db::RefCertificate { + id: "cert-tampered-7".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did: "did:key:z6MkPusher".to_string(), + node_did: node_did.clone(), + signature: tampered_signature, + issued_at: issued_at.to_string(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/tampered-7-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + + let result = verify_anchor(&client, &server.url(), "tampered-7-tx", &db, &node_did).await; + let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !verify_result.valid, + "tampered 7-field cert must not verify as valid" + ); + assert!( + verify_result.errors.iter().any(|e| e.contains("(7-field)")), + "expected the 7-field signature error, got: {:?}", + verify_result.errors + ); + _mock.assert_async().await; + } + /// A true end-to-end accept: a cert signed by a real node keypair over a /// real 13-field payload, with a real RFC 9421 pusher proof, served through /// a mock gateway, must verify to `valid: true` with empty errors. @@ -1230,7 +1683,8 @@ mod tests { .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - // The cert id is not present in the (lazy) node database, so the + // The cert is not present in the (lazy) node database — no stored row + // matches its signed (repo_id, ref_name, old_sha, new_sha, ts), so the // legacy corroboration must fail closed instead of returning valid. let result = verify_anchor(&client, &server.url(), "legacy-tamper-tx", &db, &node_did).await; @@ -1242,11 +1696,162 @@ mod tests { assert!( r.errors .iter() - .any(|e| e.contains("not found in node database") + .any(|e| e.contains("no stored certificate matches the signed") || e.contains("error looking up certificate")), "expected a corroboration error, got: {:?}", r.errors ); _mock.assert_async().await; } + + /// The legacy corroboration must key on the fields the 7-field signature + /// actually covers — never on `id`, which appears in no signed payload. + /// A forged cert that copies `id`/`seq`/`prev` from a stored row at seq 7 + /// while its signed tuple describes a DIFFERENT transition must fail: the + /// old `get_ref_certificate(id)` lookup measured the forger against the row + /// they chose, returning valid:true. + #[sqlx::test] + async fn test_verify_anchor_forged_legacy_cert_cannot_borrow_stored_chain_position( + pool: sqlx::PgPool, + ) { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let db = crate::db::Db::for_testing(pool.clone()); + db.run_migrations().await.expect("migrations should apply"); + + // Build a full stored chain seq 1..7 for the repo so every chain check + // the forged cert must survive (prev-linkage against seq-1, predecessor + // lookups) has a real row to pass against. Each cert's `prev` is the + // sha256 of its predecessor's 7-field payload, as production issuance + // computes it. + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let mut prev = "0".repeat(64); + let mut stored_at_seq_7: Option = None; + for seq in 1..=7 { + let old = format!("{:040}", seq); + let new = format!("{:040}", seq + 1); + let ts = format!("2026-01-{:02}T00:00:00+00:00", seq); + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old, + "new": new, + "pusher": "did:key:z6MkStored", + "node": node_did, + "ts": ts, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + let cert = crate::db::RefCertificate { + id: format!("stored-cert-{seq}"), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old.clone(), + new_sha: new.clone(), + pusher_did: "did:key:z6MkStored".to_string(), + node_did: node_did.clone(), + signature, + issued_at: ts.clone(), + seq, + prev: prev.clone(), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + db.insert_ref_certificate(&cert) + .await + .expect("stored cert insert should succeed"); + prev = hex::encode(sha2::Sha256::digest(serde_json::to_vec(&payload).unwrap())); + if seq == 7 { + stored_at_seq_7 = Some(cert); + } + } + let stored_seq_7 = stored_at_seq_7.expect("seq-7 cert was inserted"); + + // The forged anchor: signed tuple says the transition (repo, ref, + // forged_old, forged_new, forged_ts) — a DIFFERENT, never-recorded + // transition — but id/seq/prev are copied verbatim from the seq-7 + // stored row. The forger mints their own keypair (permissionless + // identities) and signs that payload as node_did. + let forged_kp = gitlawb_core::identity::Keypair::generate(); + let forged_did = forged_kp.did().as_str().to_string(); + let forged_old = "2222222222222222222222222222222222222222"; + let forged_new = "3333333333333333333333333333333333333333"; + let forged_ts = "2026-02-02T00:00:00+00:00"; + let forged_payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": forged_old, + "new": forged_new, + "pusher": "did:key:z6MkForged", + "node": forged_did, + "ts": forged_ts, + }); + let forged_signature = forged_kp.sign_b64(&serde_json::to_vec(&forged_payload).unwrap()); + + let forged_cert = crate::db::RefCertificate { + id: stored_seq_7.id.clone(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: forged_old.to_string(), + new_sha: forged_new.to_string(), + pusher_did: "did:key:z6MkForged".to_string(), + node_did: forged_did.clone(), + signature: forged_signature, + issued_at: forged_ts.to_string(), + seq: stored_seq_7.seq, + prev: stored_seq_7.prev.clone(), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": forged_old, + "new_sha": forged_new, + "node_did": forged_did, + "certificate": forged_cert, + }); + + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/forged-borrowed-position-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + // Verify as the forger's own node: node_did, the issuer check, the + // outer-field cross-check, the signature, and the chain-position + // checks all line up. ONLY the signed-tuple corroboration can catch + // that this cert claims a chain position it never earned. + let result = verify_anchor( + &client, + &server.url(), + "forged-borrowed-position-tx", + &db, + &forged_did, + ) + .await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "forged cert borrowing a stored chain position must not verify as valid: {:?}", + r.errors + ); + assert!( + r.errors + .iter() + .any(|e| e.contains("no stored certificate matches the signed")), + "expected the signed-tuple corroboration to reject the forged cert, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } } diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index 8ec7bd11..6e90f975 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -181,17 +181,42 @@ pub async fn require_signature(request: Request, next: Next) -> Response { .unwrap_or("/") .to_string(); - let content_digest = parts - .headers - .get("content-digest") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); + // The signature always covers content-digest (see COVERED_COMPONENTS), so a + // request that claims a valid RFC 9421 signature but sends no Content-Digest + // header is not bound to any particular body. Accepting the empty-string + // substitute would let a signed receive-pack produce a certificate/anchor + // proof that commits to no pushed bytes, so a missing or unreadable header + // is rejected before any proof is issued or presented. + let content_digest = match parts.headers.get("content-digest") { + Some(v) => match v.to_str() { + Ok(s) => s.to_string(), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "content_digest_invalid", + "message": "Content-Digest header is not a valid string", + })), + ) + .into_response() + } + }, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "content_digest_missing", + "message": "Content-Digest header is required when the signature covers content-digest", + })), + ) + .into_response() + } + }; let mut request_values: HashMap = HashMap::new(); request_values.insert("@method".to_string(), method.clone()); request_values.insert("@path".to_string(), path_and_query.clone()); - request_values.insert("content-digest".to_string(), content_digest.clone()); + request_values.insert("content-digest".to_string(), content_digest.to_string()); // The @signature-params value is the part of Signature-Input after "sig1=" let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input); @@ -236,23 +261,20 @@ pub async fn require_signature(request: Request, next: Next) -> Response { .into_response(); } - // Verify Content-Digest matches the actual request body - if let Some(claimed) = parts - .headers - .get("content-digest") - .and_then(|v| v.to_str().ok()) - { - let actual = compute_content_digest(&body_bytes); - if claimed != actual { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "content_digest_mismatch", - "message": "Content-Digest does not match request body", - })), - ) - .into_response(); - } + // Verify Content-Digest matches the actual request body. The header is + // mandatory above, so this comparison always runs: a signature over the + // empty-string substitute (or a forged digest) never reaches the body check + // with a clean pass, and a present-but-wrong digest is rejected here. + let actual = compute_content_digest(&body_bytes); + if content_digest != actual { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "content_digest_mismatch", + "message": "Content-Digest does not match request body", + })), + ) + .into_response(); } tracing::info!(did = %sig.key_id, "✓ authenticated request"); @@ -654,4 +676,34 @@ mod tests { let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(body_json["error"], "invalid_ucan"); } + + #[tokio::test] + async fn require_signature_rejects_signed_request_without_content_digest() { + // A request whose Signature-Input covers content-digest but which omits + // the Content-Digest header must be rejected up front. Accepting it would + // let a signed receive-pack produce a certificate/anchor proof that + // commits to no pushed bytes. + let kp = Keypair::generate(); + let _state = make_test_state(kp.did()); + let app = Router::new() + .route("/", axum::routing::post(|| async { StatusCode::OK })) + .layer(middleware::from_fn(require_signature)); + + let signed = gitlawb_core::http_sig::sign_request(&kp, "POST", "/", b"push-body"); + let req = Request::builder() + .method("POST") + .uri("/") + // Content-Digest deliberately omitted + .header("Signature-Input", signed.signature_input) + .header("Signature", signed.signature) + .body(axum::body::Body::from("push-body")) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + 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"], "content_digest_missing"); + } } diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 73c75187..b8ac70f0 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -578,6 +578,24 @@ impl Config { PathBuf::from(&self.key_path) } + /// Whether `arweave_gateway` was set explicitly by the operator — via the + /// `--arweave-gateway` CLI flag or the `GITLAWB_ARWEAVE_GATEWAY` env var — + /// rather than falling back to the clap default. Startup inference (pairing + /// the gateway to the bundler URL) must not overwrite an explicitly chosen + /// gateway. Pass `std::env::args_os()` at runtime. + pub fn arweave_gateway_explicitly_set(args: I) -> bool + where + I: IntoIterator, + T: Into + Clone, + { + use clap::parser::ValueSource; + use clap::CommandFactory; + Config::command() + .get_matches_from(args) + .value_source("arweave_gateway") + .is_some_and(|s| s != ValueSource::DefaultValue) + } + /// DB connections reserved for everything other than held write-locks: auth /// lookups, visibility-rule reads, the post-receive tail's own DB writes, and /// admin tooling. A write pins one pooled connection for its whole duration, so @@ -995,4 +1013,33 @@ mod tests { "db_max_connections at the floor (pushes + headroom) must validate" ); } + + /// #247: an explicit `--arweave-gateway` must not be overwritten by the + /// bundler-URL inference. The inference keys on the value source, so only + /// the clap default (no CLI flag, no env var) counts as "not explicit". + /// (The env-var arm of the detection is exercised indirectly: clap's `env` + /// feature routes `GITLAWB_ARWEAVE_GATEWAY` through the same + /// `ValueSource::EnvVariable` arm that the CLI-flag tests cover, and mutating + /// process env from a parallel test would race other cases.) + #[test] + fn arweave_gateway_explicit_set_detection() { + use std::ffi::OsString; + + // No flag, env unset (in the test process) → not explicit. + assert!(!Config::arweave_gateway_explicitly_set(["gitlawb-node"])); + + // CLI flag → explicit. + assert!(Config::arweave_gateway_explicitly_set([ + "gitlawb-node", + "--arweave-gateway", + "https://custom.example.com", + ])); + + // CLI flag via = form → explicit. + assert!(Config::arweave_gateway_explicitly_set([ + "gitlawb-node".into(), + "--arweave-gateway=https://custom.example.com".into(), + ] + as [OsString; 2])); + } } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 778683f5..62b82c35 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -955,10 +955,15 @@ const MIGRATIONS: &[Migration] = &[ FROM ref_certificates ) subq WHERE ref_certificates.id = subq.id"#, - // Make cert chain append-only: keep the (repo_id, ref_name) unique - // index for now (dropped in v20, after any rolling-upgrade window) - // and add a unique constraint on (repo_id, seq) so concurrent pushes - // cannot collide on the same sequence number. + // Make cert chain append-only: add a unique constraint on + // (repo_id, seq) so concurrent pushes cannot collide on the same + // sequence number. The superseded (repo_id, ref_name) unique index + // is dropped in v20 of this same release — it cannot be deferred + // any longer because append-only REQUIRES multiple rows per + // (repo_id, ref_name), which a unique index forbids; the two are + // mutually exclusive. Nodes share no database (each runs its own + // local Postgres), so the drop cannot strand a mixed-version + // writer mid-rollout. "CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_seq ON ref_certificates(repo_id, seq)", // Store the full HTTP Signature context so a third party can verify // the pusher authorization proof (RFC 9421). @@ -971,10 +976,13 @@ const MIGRATIONS: &[Migration] = &[ version: 20, name: "drop_ref_certs_repo_ref_unique", stmts: &[ - // Remove the superseded (repo_id, ref_name) unique index plus its - // per-ref append-only rails. Deferred to v20 so nodes are not - // running a mix of old/new code that each VACUUMed and relied on - // this unique index during a rolling upgrade (see v19). + // Remove the superseded (repo_id, ref_name) unique index. v19 makes + // the cert chain append-only, which requires multiple rows per + // (repo_id, ref_name); the unique index would reject the second + // insert for a ref. Deferring the drop is impossible for the same + // reason the old index could not survive this feature in any later + // release, and nodes each run their own local Postgres so there is + // no mixed-version shared database to strand a writer. "DROP INDEX IF EXISTS idx_ref_certs_repo_ref", ], }, @@ -2245,6 +2253,35 @@ impl Db { Ok(row.map(row_to_cert)) } + /// Look up the node's own certificate row for a legacy cert by the fields the + /// 7-field signature actually covers: `(repo_id, ref_name, old_sha, new_sha, + /// issued_at)`. Corroboration must NOT key on `id` — that column is not part + /// of any signed payload, so a forger could otherwise pick which stored row + /// their chain-position claims are measured against. + pub async fn get_cert_by_signed_tuple( + &self, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + issued_at: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates + WHERE repo_id = $1 AND ref_name = $2 AND old_sha = $3 AND new_sha = $4 AND issued_at = $5 + LIMIT 1", + ) + .bind(repo_id) + .bind(ref_name) + .bind(old_sha) + .bind(new_sha) + .bind(issued_at) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_cert)) + } + /// Retrieve the most recent certificate for a repo (highest seq). pub async fn get_cert_by_seq(&self, repo_id: &str, seq: i64) -> Result> { let row = sqlx::query( diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 07d9453c..96f1ae83 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1,3 +1,4 @@ +mod ans104; mod api; mod arweave; mod auth; @@ -82,12 +83,15 @@ async fn main() -> Result<()> { } // When a bundler URL is configured (whether via the modern - // GITLAWB_BUNDLER_URL or the legacy GITLAWB_IRYS_URL alias) and - // GITLAWB_ARWEAVE_GATEWAY was not explicitly set, pair the gateway to the - // same network so that anchors uploaded to a devnet bundler (whose - // transactions are not resolvable via the arweave.net default gateway) are - // verifiable through the verify endpoint. - if !config.bundler_url.is_empty() && std::env::var("GITLAWB_ARWEAVE_GATEWAY").is_err() { + // GITLAWB_BUNDLER_URL or the legacy GITLAWB_IRYS_URL alias) and the gateway + // was not explicitly set — neither via --arweave-gateway nor the + // GITLAWB_ARWEAVE_GATEWAY env var — pair the gateway to the same network so + // that anchors uploaded to a devnet bundler (whose transactions are not + // resolvable via the arweave.net default gateway) are verifiable through + // the verify endpoint. An operator-chosen gateway is never overwritten. + if !config.bundler_url.is_empty() + && !Config::arweave_gateway_explicitly_set(std::env::args_os()) + { config.arweave_gateway = config.bundler_url.clone(); tracing::warn!( "GITLAWB_ARWEAVE_GATEWAY unset — inferred from bundler URL as {}", diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 19dba91c..49d0bcfb 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -595,11 +595,26 @@ pub(crate) async fn stats(State(state): State) -> Json String { - if let Some(at_pos) = url.rfind('@') { - url[at_pos + 1..].to_string() +/// Mask a URL that might contain embedded credentials by stripping the userinfo +/// component: `https://user:pass@host/path` → `https://host/path`. URLs without +/// credentials are returned unchanged. +pub(crate) fn mask_credential_url(url: &str) -> String { + let scheme_end = match url.find("://") { + Some(pos) => pos + 3, + None => 0, + }; + let authority_end = url[scheme_end..] + .find('/') + .map(|p| scheme_end + p) + .unwrap_or(url.len()); + let authority = &url[scheme_end..authority_end]; + if let Some(at) = authority.rfind('@') { + format!( + "{}{}{}", + &url[..scheme_end], + &authority[at + 1..], + &url[authority_end..] + ) } else { url.to_string() } @@ -647,3 +662,42 @@ async fn p2p_info(State(state): State) -> Json { None => Json(json!({ "enabled": false })), } } + +#[cfg(test)] +mod tests { + use super::mask_credential_url; + + #[test] + fn masks_userinfo_preserving_scheme_and_path() { + assert_eq!( + mask_credential_url("https://user:pass@arweave.net"), + "https://arweave.net" + ); + assert_eq!( + mask_credential_url("https://user:pass@arweave.net/"), + "https://arweave.net/" + ); + assert_eq!( + mask_credential_url("https://u:p@host:9443/gateway"), + "https://host:9443/gateway" + ); + } + + #[test] + fn leaves_credential_free_urls_unchanged() { + assert_eq!( + mask_credential_url("https://arweave.net"), + "https://arweave.net" + ); + assert_eq!( + mask_credential_url("http://localhost:3000"), + "http://localhost:3000" + ); + assert_eq!(mask_credential_url("arweave.net"), "arweave.net"); + // '@' inside the path (not userinfo) must be preserved + assert_eq!( + mask_credential_url("https://arweave.net/a@b"), + "https://arweave.net/a@b" + ); + } +} From 33a6f24045d957ad855bc4ed343843ce7b94a8d8 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 13 Aug 2026 16:13:43 +0600 Subject: [PATCH 19/25] fix(node): fund anchoring, redact gateway URLs, fail closed, keep v1 immutable Review follow-ups on the signed ANS-104 anchoring work: - Funded upload model: the node's ANS-104 signature is authorship, not payment. Add GITLAWB_BUNDLER_ACCOUNT, send it as x-bundler-address on every upload, refuse to start when a bundler URL is set without a funded account, and correct the docs that claimed signature-as-authentication. - Redact gateway/bundler URLs everywhere they surface publicly: drop userinfo, query, and fragment in mask_credential_url, the anchors listing, the gateway-inference log, and the verify error body (which reqwest seeded with the raw URL). - Fail closed in verify_anchor: when outer repo/owner identity is present but the repo row cannot be corroborated, the result is invalid instead of silently skipping the check. - Keep the released v1 migration byte-identical to origin/main; the cert chain and anchor column work lives in v18+, with an upgrade test that replays the deployed v1 schema and proves certs and anchors round-trip. --- crates/gitlawb-node/src/ans104.rs | 10 +- crates/gitlawb-node/src/api/arweave.rs | 61 ++++ crates/gitlawb-node/src/api/repos.rs | 20 +- crates/gitlawb-node/src/arweave.rs | 415 ++++++++++++++++++++----- crates/gitlawb-node/src/config.rs | 64 ++++ crates/gitlawb-node/src/db/mod.rs | 171 +++++++++- crates/gitlawb-node/src/main.rs | 11 +- crates/gitlawb-node/src/server.rs | 88 +++++- 8 files changed, 728 insertions(+), 112 deletions(-) diff --git a/crates/gitlawb-node/src/ans104.rs b/crates/gitlawb-node/src/ans104.rs index a8764178..01db20da 100644 --- a/crates/gitlawb-node/src/ans104.rs +++ b/crates/gitlawb-node/src/ans104.rs @@ -2,9 +2,13 @@ //! //! Bundlers (Irys, Turbo, ...) accept a raw **Arweave data item** on their //! upload endpoint and verify the embedded Ed25519 signature before accepting -//! the upload, so the node's own keypair *is* the upload credential — there is -//! no separate wallet/token. This is the "signed/authenticated upload protocol" -//! the Arweave surface is required to use. +//! the upload, so the item provably originates from this node's keypair. The +//! signature authenticates the item's authorship — it is NOT payment. The +//! bundler charges each upload against a funded account and rejects items whose +//! account is unfunded. The node therefore carries a funded account in its +//! config (`GITLAWB_BUNDLER_ACCOUNT`) and sends it on every upload as +//! `x-bundler-address`; `Config::validate()` refuses to start with a bundler +//! URL but no funded account. //! //! Binary layout (per the ANS-104 spec, ed25519 = signature type 2): //! diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 2428fe87..9668d7be 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -204,4 +204,65 @@ mod closed_pool_tests { let v: Value = serde_json::from_str(&body).expect("json body"); assert_eq!(v["count"], 1); } + + /// Query and fragment credentials on a gateway with a path prefix must not + /// leak into the public listing, and the safe path prefix must survive so + /// the returned arweave_url still routes to the intended gateway. + #[sqlx::test] + async fn list_anchors_drops_query_and_fragment_credentials(pool: PgPool) { + use clap::Parser as _; + + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--arweave-gateway", + "https://user:supersecret@gateway.example/data?token=SECRET#frag", + ])); + + let tx_id = "f".repeat(43); + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: Some("bafy1test"), + arweave_tx_id: &tx_id, + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let body: String = String::from_utf8(bytes.to_vec()).expect("utf8 body"); + for secret in ["supersecret", "SECRET"] { + assert!( + !body.contains(secret), + "anchors listing must not disclose {secret}" + ); + } + // Path prefix preserved, query/fragment gone, tx_id appended cleanly. + assert!( + body.contains(&format!("https://gateway.example/data/{tx_id}")), + "arweave_url should carry the safe origin plus path prefix, got: {body}" + ); + } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 72974e4b..93135856 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -858,6 +858,7 @@ struct EncryptTaskCtx { owner_did: String, repo_name: String, irys_url: String, + bundler_account: String, http_client: Arc, node_did: String, node_keypair: Arc, @@ -1248,6 +1249,7 @@ async fn pin_and_encrypt_objects( match crate::arweave::anchor_encrypted_manifest( &ctx.http_client, &ctx.irys_url, + &ctx.bundler_account, &manifest, &ctx.node_keypair, ) @@ -2320,6 +2322,7 @@ async fn post_receive_replication_tail( owner_did: record.owner_did.clone(), repo_name: record.name.clone(), irys_url: state.config.bundler_url.clone(), + bundler_account: state.config.bundler_account.clone(), http_client: std::sync::Arc::clone(&state.http_client), node_did: state.node_did.to_string(), node_keypair: std::sync::Arc::clone(&state.node_keypair), @@ -2376,6 +2379,7 @@ async fn post_receive_replication_tail( let db_for_peers = state.db.clone(); let ref_update_tx = state.ref_update_tx.clone(); let bundler_url = state.config.bundler_url.clone(); + let bundler_account = state.config.bundler_account.clone(); let owner_did_for_arweave = record.owner_did.clone(); let self_public_url = state.config.public_url.clone(); let node_keypair = Arc::clone(&state.node_keypair); @@ -2557,6 +2561,7 @@ async fn post_receive_replication_tail( match crate::arweave::anchor_ref_update( &http_client, &bundler_url, + &bundler_account, &anchor, &node_keypair, ) @@ -2582,7 +2587,19 @@ async fn post_receive_replication_tail( } Ok(_) => {} Err(e) => { - tracing::warn!(repo=%repo_slug, err=%e, "Arweave anchor failed") + // A push must never fail over anchoring, but a + // failure here is permanent data loss: the anchor + // is never written. Name the two common causes + // (unfunded bundler account, config only checks it + // at boot) so operators can tell them apart. + tracing::warn!( + repo=%repo_slug, + bundler_account=%bundler_account, + err=%e, + "Arweave anchor failed — if the bundler reports 'Not enough \ + balance', fund GITLAWB_BUNDLER_ACCOUNT; an unfunded node \ + silently loses every anchor" + ) } } } @@ -7099,6 +7116,7 @@ mod tests { owner_did: rec.owner_did.clone(), repo_name: rec.name.clone(), irys_url: String::new(), + bundler_account: String::new(), http_client: std::sync::Arc::clone(&state.http_client), node_did: state.node_did.to_string(), node_keypair: std::sync::Arc::clone(&state.node_keypair), diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 30ff735b..833c0a88 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -7,13 +7,18 @@ //! //! Uploads are signed ANS-104 data items (see [`crate::ans104`]): the node //! signs the item with its own keypair and embeds the metadata as item tags, so -//! no separate wallet or upload credential is needed — the signature is the -//! authentication the bundler enforces. Irys allows free uploads for data -//! < 100 KiB on both devnet and mainnet (via Turbo). +//! the item is verifiably authored by this node. That signature is NOT payment: +//! the bundler only serves items backed by a funded account, and refuses +//! under-funded uploads with "Not enough balance" — which the push path degrades +//! to a warning, so an unfunded node silently loses every anchor. Funding is +//! therefore mandatory configuration, not optional: set `GITLAWB_BUNDLER_ACCOUNT` +//! to the funded account you created for this node (top up via the bundler's +//! devnet faucet on devnet hosts); `Config::validate()` refuses to start with a +//! bundler URL but no funded account. //! //! Set `GITLAWB_BUNDLER_URL` (deprecated name: `GITLAWB_IRYS_URL`) to override the default endpoint: -//! - devnet (free, no cost): https://devnet.irys.xyz -//! - mainnet: https://node2.irys.xyz +//! - devnet (faucet-funded): https://devnet.irys.xyz +//! - mainnet: https://node2.irys.xyz //! //! Configure `GITLAWB_ARWEAVE_GATEWAY` to override the gateway used for resolving anchors //! (defaults to https://arweave.net). @@ -60,6 +65,7 @@ pub struct RefAnchor { pub async fn anchor_ref_update( client: &reqwest::Client, bundler_url: &str, + bundler_account: &str, anchor: &RefAnchor, node_keypair: &gitlawb_core::identity::Keypair, ) -> Result { @@ -111,6 +117,7 @@ pub async fn anchor_ref_update( let resp = client .post(&url) .header("Content-Type", "application/octet-stream") + .header("x-bundler-address", bundler_account) .body(data_item) .send() .await @@ -138,6 +145,7 @@ pub async fn anchor_ref_update( ref_name = %anchor.ref_name, new_sha = %anchor.new_sha, tx_id = %tx_id, + bundler_account = %bundler_account, "anchored ref update to Arweave via bundler" ); @@ -170,6 +178,7 @@ pub struct EncryptedManifest<'a> { pub async fn anchor_encrypted_manifest( client: &reqwest::Client, bundler_url: &str, + bundler_account: &str, manifest: &EncryptedManifest<'_>, node_keypair: &gitlawb_core::identity::Keypair, ) -> Result { @@ -214,6 +223,7 @@ pub async fn anchor_encrypted_manifest( let resp = client .post(&url) .header("Content-Type", "application/octet-stream") + .header("x-bundler-address", bundler_account) .body(data_item) .send() .await @@ -239,6 +249,7 @@ pub async fn anchor_encrypted_manifest( repo = %manifest.repo, tx_id = %tx_id, blobs = manifest.blobs.len(), + bundler_account = %bundler_account, "anchored encrypted manifest to Arweave via bundler" ); @@ -293,15 +304,24 @@ pub async fn verify_anchor( // Fetch the data item from the Arweave gateway's data path. // Gateways serve data at /{tx_id}, not /v1/tx/{id} (which is the bundler API). let url = format!("{}/{}", gateway_url.trim_end_matches('/'), tx_id); + // Public-facing display form of the same URL: reqwest's connection error + // embeds the request URL verbatim, so if the gateway config carries + // credentials the error text would otherwise leak them into VerifyResult. + let display_url = format!( + "{}/{}", + crate::server::mask_credential_url(gateway_url).trim_end_matches('/'), + tx_id + ); let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => { - tracing::warn!("Arweave gateway connection failed: {e}"); + let safe_err = e.to_string().replace(&url, &display_url); + tracing::warn!("Arweave gateway connection failed: {safe_err}"); return Ok(VerifyResult { valid: false, anchor: serde_json::Value::Null, certificate: None, - errors: vec![format!("Arweave gateway connection failed: {e}")], + errors: vec![format!("Arweave gateway connection failed: {safe_err}")], }); } }; @@ -437,6 +457,12 @@ pub async fn verify_anchor( // identity fields must agree with what it recorded. let outer_repo = anchor.get("repo").and_then(|v| v.as_str()); let outer_owner = anchor.get("owner_did").and_then(|v| v.as_str()); + // Fail closed: when the outer identity fields are present, a lookup + // that cannot complete (repo missing or DB error) must not silently + // skip corroboration. Otherwise a forger could echo attacker-chosen + // identities next to a valid:true verdict simply because the node has + // no record — or the DB is down — to check them against. + let outer_identity_present = outer_repo.is_some() || outer_owner.is_some(); match db.get_repo_by_id(&c.repo_id).await { Ok(Some(record)) => { let expected_repo = format!( @@ -461,13 +487,27 @@ pub async fn verify_anchor( } } Ok(None) => { - tracing::warn!( - repo_id = %c.repo_id, - "cannot corroborate anchor repo/owner_did — repo_id not found in node database" - ); + if outer_identity_present { + errors.push(format!( + "anchor outer repo/owner_did present but repo_id {} not found in node database — outer identity cannot be corroborated", + c.repo_id + )); + } else { + tracing::warn!( + repo_id = %c.repo_id, + "cannot corroborate anchor repo/owner_did — repo_id not found in node database" + ); + } } Err(e) => { - tracing::warn!("repo lookup failed for {}: {e}", c.repo_id); + if outer_identity_present { + errors.push(format!( + "repo lookup failed for {} — outer repo/owner_did cannot be corroborated: {e}", + c.repo_id + )); + } else { + tracing::warn!("repo lookup failed for {}: {e}", c.repo_id); + } } } @@ -827,6 +867,7 @@ mod tests { /// functions); success returns `{"id": }`. async fn spawn_enforcing_bundler( kp: &Keypair, + expected_bundler_account: &'static str, expected_tags: &[(&str, &str)], validate: impl Fn(&serde_json::Value) -> bool + Send + Sync + Clone + 'static, tx_id: &'static str, @@ -840,45 +881,64 @@ mod tests { let addr = listener.local_addr().unwrap(); let router = axum::Router::new().route( "/v1/tx", - axum::routing::post(move |body: axum::body::Bytes| { - let vk = vk; - let expected = expected.clone(); - async move { - let parsed = match crate::ans104::verify_data_item(&vk, &body) { - Ok(p) => p, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - format!("unsigned/invalid item: {e}"), - ); + axum::routing::post( + move |headers: axum::http::HeaderMap, body: axum::body::Bytes| { + let vk = vk; + let expected = expected.clone(); + async move { + // The funded-account identity must be part of the request, + // not just the config: the item signature is authorship. + if !expected_bundler_account.is_empty() { + let got = headers + .get("x-bundler-address") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + if got != expected_bundler_account { + return ( + StatusCode::BAD_REQUEST, + format!( + "missing/wrong x-bundler-address: got {got:?}, want \ + {expected_bundler_account:?}" + ), + ); + } } - }; - for (name, value) in &expected { - if !parsed.tags.iter().any(|(tn, tv)| tn == name && tv == value) { - return ( - StatusCode::BAD_REQUEST, - format!("missing signed tag {name}:{value}"), - ); + let parsed = match crate::ans104::verify_data_item(&vk, &body) { + Ok(p) => p, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + format!("unsigned/invalid item: {e}"), + ); + } + }; + for (name, value) in &expected { + if !parsed.tags.iter().any(|(tn, tv)| tn == name && tv == value) { + return ( + StatusCode::BAD_REQUEST, + format!("missing signed tag {name}:{value}"), + ); + } } - } - let json: serde_json::Value = match serde_json::from_slice(&parsed.data) { - Ok(j) => j, - Err(e) => { + let json: serde_json::Value = match serde_json::from_slice(&parsed.data) { + Ok(j) => j, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + format!("item data is not JSON: {e}"), + ); + } + }; + if !validate(&json) { return ( StatusCode::BAD_REQUEST, - format!("item data is not JSON: {e}"), + "payload validation failed".to_string(), ); } - }; - if !validate(&json) { - return ( - StatusCode::BAD_REQUEST, - "payload validation failed".to_string(), - ); + (StatusCode::OK, format!(r#"{{"id":"{tx_id}"}}"#)) } - (StatusCode::OK, format!(r#"{{"id":"{tx_id}"}}"#)) - } - }), + }, + ), ); tokio::spawn(async move { axum::serve(listener, router).await.unwrap(); @@ -902,7 +962,7 @@ mod tests { node_did: "did:key:z6MknndwexV9...".into(), certificate: None, }; - let result = anchor_ref_update(&client, "", &anchor, &kp).await; + let result = anchor_ref_update(&client, "", "", &anchor, &kp).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), ""); } @@ -912,6 +972,7 @@ mod tests { let kp = Keypair::generate(); let server = spawn_enforcing_bundler( &kp, + "zBundlerAccount", &[ ("App-Name", "gitlawb"), ("Schema", "gitlawb/ref-update/v1"), @@ -936,7 +997,7 @@ mod tests { certificate: None, }; - let result = anchor_ref_update(&client, &server, &anchor, &kp).await; + let result = anchor_ref_update(&client, &server, "zBundlerAccount", &anchor, &kp).await; assert!(result.is_ok(), "anchor should succeed: {result:?}"); assert_eq!( result.unwrap(), @@ -944,6 +1005,43 @@ mod tests { ); } + /// The funded bundler account must ride on the upload request: the item + /// signature is authorship, not payment, so an upload that omits the + /// account must be refused — it would otherwise be billed to nobody. + #[tokio::test] + async fn test_anchor_ref_update_rejects_missing_bundler_account() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let anchor = RefAnchor { + repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), + owner_did: "did:key:z6Mk...".into(), + ref_name: "refs/heads/main".into(), + old_sha: "0".repeat(40), + new_sha: "a1b2c3d4".repeat(8), + cid: None, + timestamp: "2026-03-14T00:00:00Z".into(), + node_did: "did:key:z6Mknnd...".into(), + certificate: None, + }; + + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + &[("App-Name", "gitlawb"), ("Schema", "gitlawb/ref-update/v1")], + |_| true, + "NEVER_RETURNED", + ) + .await; + + let result = anchor_ref_update(&client, &server, "", &anchor, &kp).await; + let err = result.expect_err("missing bundler account must fail the upload"); + assert!( + err.to_string().contains("x-bundler-address"), + "error should name the missing account header: {err}" + ); + } + #[tokio::test] async fn test_anchor_body_carries_real_old_sha() { // The anchored body must serialize the real old→new transition the @@ -956,6 +1054,7 @@ mod tests { let kp = Keypair::generate(); let server = spawn_enforcing_bundler( &kp, + "zBundlerAccount", &[("App-Name", "gitlawb")], move |j| j["old_sha"] == real_old && j["new_sha"] == real_new, "TX_REAL_OLD_SHA", @@ -976,7 +1075,7 @@ mod tests { certificate: None, }; - let result = anchor_ref_update(&client, &server, &anchor, &kp).await; + let result = anchor_ref_update(&client, &server, "zBundlerAccount", &anchor, &kp).await; assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA"); } @@ -988,6 +1087,7 @@ mod tests { let impostor_kp = Keypair::generate(); let server = spawn_enforcing_bundler( &node_kp, + "zBundlerAccount", &[("App-Name", "gitlawb")], |_| true, "NEVER_RETURNED", @@ -1008,7 +1108,8 @@ mod tests { certificate: None, }; - let result = anchor_ref_update(&client, &server, &anchor, &impostor_kp).await; + let result = + anchor_ref_update(&client, &server, "zBundlerAccount", &anchor, &impostor_kp).await; assert!( result.is_err(), "upload signed by the wrong key must be denied by the bundler" @@ -1040,7 +1141,7 @@ mod tests { blobs: &blobs, }; assert_eq!( - anchor_encrypted_manifest(&client, "", &m, &kp) + anchor_encrypted_manifest(&client, "", "", &m, &kp) .await .unwrap(), "" @@ -1061,7 +1162,7 @@ mod tests { }; // Non-empty URL, but no blobs: still a no-op. assert_eq!( - anchor_encrypted_manifest(&client, "https://example.invalid", &m, &kp) + anchor_encrypted_manifest(&client, "https://example.invalid", "", &m, &kp) .await .unwrap(), "" @@ -1073,6 +1174,7 @@ mod tests { let kp = Keypair::generate(); let server = spawn_enforcing_bundler( &kp, + "zBundlerAccount", &[ ("App-Name", "gitlawb"), ("Schema", "gitlawb/encrypted-manifest/v1"), @@ -1094,7 +1196,7 @@ mod tests { timestamp: "2026-06-11T00:00:00Z", blobs: &blobs, }; - let r = anchor_encrypted_manifest(&client, &server, &m, &kp).await; + let r = anchor_encrypted_manifest(&client, &server, "zBundlerAccount", &m, &kp).await; assert_eq!(r.unwrap(), "MANIFESTTX123"); } @@ -1145,6 +1247,36 @@ mod tests { mock.assert_async().await; } + /// A gateway URL carrying a query token must never surface that token in + /// the public VerifyResult error text: reqwest embeds the request URL in + /// its connection error, so the error must be rebuilt from the masked URL. + #[tokio::test] + async fn test_verify_anchor_error_does_not_leak_gateway_query_credentials() { + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + + // Port 1 on loopback refuses connections deterministically. + let result = verify_anchor( + &client, + "http://127.0.0.1:1/?token=SECRET", + "txid", + &db, + "did:key:zNODE", + ) + .await; + + let r = result.expect("verify_anchor should return Ok for gateway connection errors"); + assert!(!r.valid); + let err_text = r.errors.join(" "); + assert!( + !err_text.contains("SECRET"), + "gateway query token leaked into VerifyResult: {err_text}" + ); + } + #[tokio::test] async fn test_verify_anchor_malformed_node_did() { let mut server = mockito::Server::new_async().await; @@ -1512,27 +1644,25 @@ mod tests { /// A true end-to-end accept: a cert signed by a real node keypair over a /// real 13-field payload, with a real RFC 9421 pusher proof, served through /// a mock gateway, must verify to `valid: true` with empty errors. - #[tokio::test] - async fn test_verify_anchor_accepts_authentic_13_field_certificate() { - let node_kp = gitlawb_core::identity::Keypair::generate(); - let node_did = node_kp.did().as_str().to_string(); - let pusher_kp = gitlawb_core::identity::Keypair::generate(); - let pusher_did = pusher_kp.did().as_str().to_string(); - - let repo_id = "repo-uuid"; - let ref_name = "refs/heads/main"; - let old_sha = "0".repeat(40); - let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; - let issued_at = "2026-07-22T00:00:00+00:00"; - let seq = 1i64; - let prev = "0".repeat(64); - - // Build a real RFC 9421 pusher proof over an arbitrary push body. + /// Build an authentic 13-field certificate signed by `node_kp` with a real + /// RFC 9421 pusher proof from `pusher_kp` — the exact shape a live node + /// issues. Shared by the accept and fail-closed corroboration tests. + #[allow(clippy::too_many_arguments)] + fn authentic_13_field_cert( + node_kp: &Keypair, + pusher_kp: &Keypair, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + node_did: &str, + issued_at: &str, + seq: i64, + prev: &str, + ) -> crate::db::RefCertificate { let request_path = "/repo-uuid.git/git-receive-pack"; let signed = - gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); - // The stored pusher_sig is the raw STANDARD base64 of the 64-byte - // signature, unwrapped from the `sig1=:...:` header form. + gitlawb_core::http_sig::sign_request(pusher_kp, "POST", request_path, b"push-body"); let pusher_sig = signed .signature .strip_prefix("sig1=:") @@ -1540,13 +1670,12 @@ mod tests { .unwrap() .to_string(); - // Sign the 13-field payload exactly as the node does. let payload = serde_json::json!({ "repo_id": repo_id, "ref": ref_name, "old": old_sha, "new": new_sha, - "pusher": pusher_did, + "pusher": pusher_kp.did().as_str().to_string(), "node": node_did, "ts": issued_at, "seq": seq, @@ -1558,25 +1687,77 @@ mod tests { }); let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); - let cert = crate::db::RefCertificate { + crate::db::RefCertificate { id: "cert-accept-1".to_string(), repo_id: repo_id.to_string(), ref_name: ref_name.to_string(), - old_sha: old_sha.clone(), + old_sha: old_sha.to_string(), new_sha: new_sha.to_string(), - pusher_did, - node_did: node_did.clone(), + pusher_did: pusher_kp.did().as_str().to_string(), + node_did: node_did.to_string(), signature, issued_at: issued_at.to_string(), seq, - prev, + prev: prev.to_string(), pusher_sig: Some(pusher_sig), signature_input: Some(signed.signature_input), content_digest: Some(signed.content_digest), request_path: Some(request_path.to_string()), - }; + } + } + + /// Run the current schema on a fresh `#[sqlx::test]` pool so DB-backed + /// anchor tests share one seeding path. + async fn migrated_db(pool: sqlx::PgPool) -> crate::db::Db { + let db = crate::db::Db::for_testing(pool); + db.run_migrations().await.expect("migrations should apply"); + db + } + + #[sqlx::test] + async fn test_verify_anchor_accepts_authentic_13_field_certificate(pool: sqlx::PgPool) { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + + let owner_did = "did:key:z6MkOwner"; + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let seq = 1i64; + let prev = "0".repeat(64); + let db = migrated_db(pool).await; + // Seed the repo so the outer identity corroboration actually runs + // against a real row instead of being skipped by a lazy pool. + db.create_repo(&crate::db::RepoRecord { + id: repo_id.to_string(), + name: "myrepo".into(), + owner_did: owner_did.to_string(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + disk_path: "/tmp/anchor-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + let cert = authentic_13_field_cert( + &node_kp, &pusher_kp, repo_id, ref_name, &old_sha, new_sha, &node_did, issued_at, seq, + &prev, + ); + + // The outer identity fields are present and must corroborate against + // the seeded repo row: expected_repo = normalize_owner_key(owner) / name. let anchor_json = serde_json::json!({ + "repo": format!("{}/myrepo", crate::db::normalize_owner_key(owner_did)), + "owner_did": owner_did, "repo_id": repo_id, "ref_name": ref_name, "old_sha": old_sha, @@ -1595,11 +1776,6 @@ mod tests { .await; let client = reqwest::Client::new(); - let pool = sqlx::postgres::PgPoolOptions::new() - .connect_lazy("postgres://localhost/gitlawb_test_placeholder") - .expect("lazy pool creation should not fail"); - let db = crate::db::Db::for_testing(pool); - let result = verify_anchor(&client, &server.url(), "accept-tx", &db, &node_did).await; let r = result.expect("verify_anchor should return Ok for a served anchor"); assert!( @@ -1615,6 +1791,79 @@ mod tests { _mock.assert_async().await; } + /// Fail closed: when the anchor carries outer `repo`/`owner_did` claims but + /// the node has no record of the repo, corroboration cannot run — and the + /// verdict must not rest on the certificate signature alone. + #[sqlx::test] + async fn test_verify_anchor_fails_closed_when_outer_identity_cannot_be_corroborated( + pool: sqlx::PgPool, + ) { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + + let owner_did = "did:key:zVictim"; + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + + let db = migrated_db(pool).await; + // Deliberately do NOT seed the repo row: the lookup must come up empty. + + let cert = authentic_13_field_cert( + &node_kp, + &pusher_kp, + repo_id, + ref_name, + &old_sha, + new_sha, + &node_did, + issued_at, + 1, + &"0".repeat(64), + ); + + // Forged outer identity fields, no way to corroborate them. + let anchor_json = serde_json::json!({ + "repo": "victim-owner/victim-repo", + "owner_did": owner_did, + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/uncorroborated-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = + verify_anchor(&client, &server.url(), "uncorroborated-tx", &db, &node_did).await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "uncorroborated outer identity must not verify as valid" + ); + assert!( + r.errors + .iter() + .any(|e| e.contains("cannot be corroborated")), + "expected the uncorroborated-identity error, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } + /// A tampered seq on an authentic legacy 7-field cert must fail: the /// 7-field signature does not cover seq/prev, so the node's stored row /// must be corroborated rather than accepting a blanket valid: true. diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index b8ac70f0..1fd44839 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -124,6 +124,21 @@ pub struct Config { )] pub bundler_url: String, + /// Funded bundler account (address/identity) that pays for anchoring. + /// The node signs ANS-104 data items with its own keypair, but that + /// signature is proof of authorship, NOT payment: the bundler only serves + /// items backed by a funded account. When `bundler_url` is set this must + /// name the funded account you created for the node (top up via the + /// bundler's devnet faucet for devnet hosts). `validate()` refuses to + /// start with a bundler URL but no funded account. + #[arg( + long, + env = "GITLAWB_BUNDLER_ACCOUNT", + default_value = "", + alias = "irys-account" + )] + pub bundler_account: String, + /// Arweave gateway URL for resolving arweave_tx_id to data items. /// Used by the verify endpoint. Default: https://arweave.net #[arg( @@ -623,6 +638,21 @@ impl Config { floor )); } + // Anchoring writes real, permanent transactions: the node's ANS-104 + // signature on each data item is authorship, not payment, and the + // bundler rejects items its funded-account ledger does not back. + // Refusing to start keeps an operator from silently losing every + // anchor to "Not enough balance" (see api/repos.rs anchor call sites, + // which degrade the push to a warning rather than fail it). + if !self.bundler_url.trim().is_empty() && self.bundler_account.trim().is_empty() { + return Err( + "GITLAWB_BUNDLER_URL is set but GITLAWB_BUNDLER_ACCOUNT is not: the data item \ + signature is not bundler payment. Create a funded account for this node (top up \ + via the bundler's faucet for devnet hosts) and set GITLAWB_BUNDLER_ACCOUNT to its \ + address/identity, or clear GITLAWB_BUNDLER_URL to disable anchoring." + .to_string(), + ); + } Ok(()) } } @@ -1014,6 +1044,40 @@ mod tests { ); } + /// Anchoring is paid, not free: the ANS-104 signature proves authorship, + /// and the bundler bills the funded account the upload names. A bundler URL + /// without a declared funded account must refuse to start, or every anchor + /// silently fails with "Not enough balance" behind a push-time warning. + #[test] + fn bundler_url_requires_a_funded_account() { + // Defaults (no bundler) validate. + Config::parse_from(["gitlawb-node"]) + .validate() + .expect("default config must validate"); + + // Bundler URL alone must be rejected. + let no_account = + Config::parse_from(["gitlawb-node", "--bundler-url", "https://devnet.irys.xyz"]); + let err = no_account + .validate() + .expect_err("bundler URL without a funded account must be rejected"); + assert!( + err.contains("GITLAWB_BUNDLER_ACCOUNT"), + "error must name the missing account: {err}" + ); + + // URL plus account validates. + Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + ]) + .validate() + .expect("bundler URL with a funded account must validate"); + } + /// #247: an explicit `--arweave-gateway` must not be overwritten by the /// bundler-URL inference. The inference keys on the value source, so only /// the clap default (no CLI flag, no env var) counts as "not explicit". diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 941d2a37..58e73e69 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -539,10 +539,7 @@ const MIGRATIONS: &[Migration] = &[ pusher_did TEXT NOT NULL, node_did TEXT NOT NULL, signature TEXT NOT NULL, - issued_at TEXT NOT NULL, - seq BIGINT NOT NULL DEFAULT 1, - prev TEXT NOT NULL DEFAULT '0000000000000000000000000000000000000000000000000000000000000000', - pusher_sig TEXT + issued_at TEXT NOT NULL )"#, "CREATE INDEX IF NOT EXISTS idx_ref_certs_repo ON ref_certificates(repo_id)", r#"CREATE TABLE IF NOT EXISTS peers ( @@ -654,16 +651,17 @@ const MIGRATIONS: &[Migration] = &[ "CREATE INDEX IF NOT EXISTS idx_agent_tasks_repo ON agent_tasks(repo_id)", // ── Arweave permanent anchors ──────────────────────────────────── r#"CREATE TABLE IF NOT EXISTS arweave_anchors ( - id TEXT NOT NULL PRIMARY KEY, - repo TEXT NOT NULL, - owner_did TEXT NOT NULL, - ref_name TEXT NOT NULL, - old_sha TEXT NOT NULL, - new_sha TEXT NOT NULL, - cid TEXT, - arweave_tx_id TEXT NOT NULL, - node_did TEXT NOT NULL, - anchored_at TEXT NOT NULL + id TEXT NOT NULL PRIMARY KEY, + repo TEXT NOT NULL, + owner_did TEXT NOT NULL, + ref_name TEXT NOT NULL, + old_sha TEXT NOT NULL, + new_sha TEXT NOT NULL, + cid TEXT, + irys_tx_id TEXT NOT NULL, + arweave_url TEXT NOT NULL, + node_did TEXT NOT NULL, + anchored_at TEXT NOT NULL )"#, "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_repo ON arweave_anchors(repo)", "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_new_sha ON arweave_anchors(new_sha)", @@ -7737,3 +7735,148 @@ mod peers_table_writer_guard { ); } } + +/// The released v1 migration is immutable: every deployment that already ran it +/// keeps the ORIGINAL column layout, and later migrations (v18+) do the column +/// adds and renames against that layout. This test replays that exact upgrade — +/// create the byte-identical released v1 schema, mark v1 applied, run the real +/// migration chain — and proves a certificate and an anchor written with the new +/// columns survive it. +#[cfg(test)] +mod upgrade_path_tests { + use super::{Db, RecordAnchorInputV2, RefCertificate, MIGRATIONS}; + use sqlx::{PgPool, Row}; + + #[sqlx::test] + async fn upgrading_released_v1_schema_lands_cert_and_anchor_columns(pool: PgPool) { + let v1 = &MIGRATIONS[0]; + assert_eq!(v1.version, 1, "test must target the released v1 migration"); + + // Bootstrap schema_migrations (the real migrate() creates it, but we + // replay v1 by hand to reproduce a deployed v1 database exactly). + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + )"#, + ) + .execute(&pool) + .await + .unwrap(); + + // Replay the released v1 schema, then record v1 as applied so the + // chain below picks up at v2 — exactly what a deployed node does. + for stmt in v1.stmts { + sqlx::query(stmt).execute(&pool).await.unwrap(); + } + sqlx::query( + "INSERT INTO schema_migrations (version, name, applied_at) VALUES (1, $1, now())", + ) + .bind(v1.name) + .execute(&pool) + .await + .unwrap(); + + // The released v1 layout must not yet carry the post-v1 columns; this + // assertion is what makes the test bite — it fails if v1 is ever edited + // to pre-add them, exactly the regression the immutability rule bans. + for (table, column) in [ + ("ref_certificates", "seq"), + ("ref_certificates", "pusher_sig"), + ("arweave_anchors", "arweave_tx_id"), + ("arweave_anchors", "cert_id"), + ] { + let exists: bool = sqlx::query( + "SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + ) AS present", + ) + .bind(table) + .bind(column) + .fetch_one(&pool) + .await + .unwrap() + .get("present"); + assert!(!exists, "released v1 must not contain {table}.{column}"); + } + + // Run the real migration chain v2..=v20 against the old layout. + let db = Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + // The post-v1 columns must now exist... + for (table, column) in [ + ("ref_certificates", "seq"), + ("ref_certificates", "prev"), + ("ref_certificates", "pusher_sig"), + ("ref_certificates", "signature_input"), + ("ref_certificates", "content_digest"), + ("ref_certificates", "request_path"), + ("arweave_anchors", "arweave_tx_id"), + ("arweave_anchors", "cert_id"), + ] { + let exists: bool = sqlx::query( + "SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + ) AS present", + ) + .bind(table) + .bind(column) + .fetch_one(&pool) + .await + .unwrap() + .get("present"); + assert!(exists, "upgraded schema must contain {table}.{column}"); + } + + // ...and a full certificate (chain + pusher-proof columns) plus an + // anchor written through the code paths must round-trip. + let cert = RefCertificate { + id: "cert-upgrade-1".to_string(), + repo_id: "repo-uuid".to_string(), + ref_name: "refs/heads/main".to_string(), + old_sha: "0".repeat(40), + new_sha: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(), + pusher_did: "did:key:zPusher".to_string(), + node_did: "did:key:zNode".to_string(), + signature: "sig".to_string(), + issued_at: "2026-07-22T00:00:00+00:00".to_string(), + seq: 7, + prev: "0".repeat(64), + pusher_sig: Some("sig1=:abc:".to_string()), + signature_input: Some(r#"("content-digest" "http://example.com/repo.git/git-receive-pack"; created=…; keyid="did:key:zPusher")"#.to_string()), + content_digest: Some("sha-256=:abc:".to_string()), + request_path: Some("/repo-uuid.git/git-receive-pack".to_string()), + }; + db.insert_ref_certificate(&cert).await.unwrap(); + let got = db + .get_cert_by_seq("repo-uuid", 7) + .await + .unwrap() + .expect("cert readable"); + assert_eq!(got.pusher_sig.as_deref(), Some("sig1=:abc:")); + + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/main", + old_sha: &"0".repeat(40), + new_sha: &"1".repeat(40), + cid: Some("bafyreib5..."), + arweave_tx_id: "upgrade-tx-id", + node_did: "did:key:zNODE", + cert_id: Some("cert-upgrade-1".to_string()), + }) + .await + .unwrap(); + let anchors = db + .list_arweave_anchors(Some("alice/myrepo"), 10) + .await + .unwrap(); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].arweave_tx_id, "upgrade-tx-id"); + } +} diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 96f1ae83..07b03b2e 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -95,7 +95,7 @@ async fn main() -> Result<()> { config.arweave_gateway = config.bundler_url.clone(); tracing::warn!( "GITLAWB_ARWEAVE_GATEWAY unset — inferred from bundler URL as {}", - config.arweave_gateway + crate::server::mask_credential_url(&config.arweave_gateway) ); } @@ -110,6 +110,15 @@ async fn main() -> Result<()> { .validate() .map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?; + if !config.bundler_url.is_empty() { + tracing::info!( + bundler_url = %crate::server::mask_credential_url(&config.bundler_url), + bundler_account = %config.bundler_account, + "arweave anchoring enabled; uploads billed to the funded bundler account \ + (the node's ANS-104 signature is authorship, not payment)" + ); + } + if !config.public_read { warn!( "GITLAWB_PUBLIC_READ=false is reserved; per-repository private-read enforcement is not wired in alpha" diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 49d0bcfb..8a86027c 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -595,28 +595,65 @@ pub(crate) async fn stats(State(state): State) -> Json String { - let scheme_end = match url.find("://") { + match reqwest::Url::parse(url) { + Ok(parsed) if !parsed.cannot_be_a_base() => { + let needs_masking = !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some(); + if !needs_masking { + return url.to_string(); + } + let had_empty_path = !url.ends_with('/'); + let mut clean = parsed; + let _ = clean.set_username(""); + let _ = clean.set_password(None); + clean.set_query(None); + clean.set_fragment(None); + let mut masked = clean.to_string(); + // The url crate serializes an empty path with a trailing '/'; + // drop it so a bare-origin config masks to the same bare origin. + if had_empty_path && masked.ends_with('/') { + masked.pop(); + } + masked + } + _ => mask_credential_url_fallback(url), + } +} + +fn mask_credential_url_fallback(url: &str) -> String { + // Strip any query/fragment up front — the string may carry credentials + // even without a parseable scheme. + let end = url.find(['?', '#']).unwrap_or(url.len()); + let without_query = &url[..end]; + let scheme_end = match without_query.find("://") { Some(pos) => pos + 3, None => 0, }; - let authority_end = url[scheme_end..] + let authority_end = without_query[scheme_end..] .find('/') .map(|p| scheme_end + p) - .unwrap_or(url.len()); - let authority = &url[scheme_end..authority_end]; + .unwrap_or(without_query.len()); + let authority = &without_query[scheme_end..authority_end]; if let Some(at) = authority.rfind('@') { format!( "{}{}{}", - &url[..scheme_end], + &without_query[..scheme_end], &authority[at + 1..], - &url[authority_end..] + &without_query[authority_end..] ) } else { - url.to_string() + without_query.to_string() } } @@ -683,6 +720,37 @@ mod tests { ); } + #[test] + fn drops_query_and_fragment_credentials() { + // Query tokens must not survive into public URLs, logs, or status + // responses — with or without userinfo and a path prefix. + assert_eq!( + mask_credential_url("https://gateway.example/data?token=SECRET"), + "https://gateway.example/data" + ); + assert_eq!( + mask_credential_url("https://gateway.example/data?token=SECRET#frag"), + "https://gateway.example/data" + ); + assert_eq!( + mask_credential_url("https://user:token@gateway.example/data?token=SECRET#frag"), + "https://gateway.example/data" + ); + assert_eq!( + mask_credential_url("https://u:p@host:9443/gateway?token=SECRET"), + "https://host:9443/gateway" + ); + assert_eq!( + mask_credential_url("https://host:9443/gateway#token=SECRET"), + "https://host:9443/gateway" + ); + // Scheme-less configs still get the query cut. + assert_eq!( + mask_credential_url("gateway.example/data?token=SECRET"), + "gateway.example/data" + ); + } + #[test] fn leaves_credential_free_urls_unchanged() { assert_eq!( From 7ef8e7c27bc502460cd50ea03e973361e011a881 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 14 Aug 2026 13:41:48 +0600 Subject: [PATCH 20/25] fix(node): ANS-104 deep-hash preimage, Irys payer token, tamper rejection, detached post-receive continuation - ans104: tags preimage is the spec nested [[name,value]] list, not Avro tag bytes; deep_hash is recursive; regenerate 0-tags vector and add an interop fixture signed by arbundles' deepHash + Node crypto. - tamper: b64url-decode the sig, flip a byte, re-encode, assert the specific signature error for both the 13-field and 7-field verify paths. - payer: require GITLAWB_BUNDLER_TOKEN alongside the account; send x-irys-paid-by and upload to /tx/{token}; make .env.example startable and gate it with a config test reading the shipped file. - urls: structural join_path helper preserving query and rejecting fragments on both bundler and gateway URLs; redact creds and truncate error bodies; mask the raw DB error at the fail-closed repo lookup. - continuation: move record_push, trust score, and per-ref certificate issuance into an owned post_receive_continuation spawned at the durability boundary, so a disconnect after the pack lands can no longer drop certs or the replication tail; add post_receive_continuation_survives_handler_abort and update the U5 ordering gate. - migrations: note v20's one-way drop and fix the stale v1 comment. --- .env.example | 15 +- crates/gitlawb-node/src/ans104.rs | 155 +++++-- crates/gitlawb-node/src/api/repos.rs | 282 +++++++++--- crates/gitlawb-node/src/arweave.rs | 543 +++++++++++++++-------- crates/gitlawb-node/src/config.rs | 109 ++++- crates/gitlawb-node/src/db/mod.rs | 12 +- crates/gitlawb-node/src/main.rs | 4 +- crates/gitlawb-node/tests/inv22_gates.rs | 60 +-- 8 files changed, 858 insertions(+), 322 deletions(-) diff --git a/.env.example b/.env.example index 91e9f019..84451e8e 100644 --- a/.env.example +++ b/.env.example @@ -49,9 +49,18 @@ GITLAWB_PINATA_UPLOAD_URL=https://uploads.pinata.cloud/v3/files # ── Arweave permanent anchoring (Bundler / Arweave gateway) ─────────────────── # Bundler URL for permanent anchoring. Leave empty to disable anchoring. # (Legacy name: GITLAWB_IRYS_URL) -# Default: Irys devnet (free, data deleted ~60 days). For production, use -# https://node2.irys.xyz and provide a funded wallet credential. -GITLAWB_BUNDLER_URL=https://devnet.irys.xyz +# Anchoring is PAID, and the node refuses to start when a bundler URL is set +# without BOTH GITLAWB_BUNDLER_ACCOUNT (a funded account) and +# GITLAWB_BUNDLER_TOKEN (the token that account holds): Irys bills uploads at +# /tx/{token} via the x-irys-paid-by header, so a URL with no funded account and +# token would silently fail every anchor. Default (empty) disables anchoring. +GITLAWB_BUNDLER_URL= +# To enable, uncomment the block below and fund the account via the bundler's +# devnet faucet (https://docs.irys.xyz/devnet/faucet), or provide a production +# funded wallet credential and https://node2.irys.xyz. +#GITLAWB_BUNDLER_URL=https://devnet.irys.xyz +#GITLAWB_BUNDLER_ACCOUNT= +#GITLAWB_BUNDLER_TOKEN=matic # Arweave gateway URL for resolving arweave_tx_id to data items. # Must match the network used by GITLAWB_BUNDLER_URL so anchors are verifiable. # Default: Irys devnet gateway; for production use https://arweave.net. diff --git a/crates/gitlawb-node/src/ans104.rs b/crates/gitlawb-node/src/ans104.rs index 01db20da..46fe019c 100644 --- a/crates/gitlawb-node/src/ans104.rs +++ b/crates/gitlawb-node/src/ans104.rs @@ -5,10 +5,11 @@ //! the upload, so the item provably originates from this node's keypair. The //! signature authenticates the item's authorship — it is NOT payment. The //! bundler charges each upload against a funded account and rejects items whose -//! account is unfunded. The node therefore carries a funded account in its -//! config (`GITLAWB_BUNDLER_ACCOUNT`) and sends it on every upload as -//! `x-bundler-address`; `Config::validate()` refuses to start with a bundler -//! URL but no funded account. +//! account is unfunded. The node therefore carries a funded account and payment +//! token in its config (`GITLAWB_BUNDLER_ACCOUNT`, `GITLAWB_BUNDLER_TOKEN`) and +//! sends them on every upload as the Irys `x-irys-paid-by` header to +//! `/tx/{token}`; `Config::validate()` refuses to start with a bundler URL but +//! no funded account. //! //! Binary layout (per the ANS-104 spec, ed25519 = signature type 2): //! @@ -25,9 +26,12 @@ //! ``` //! //! The signature covers `deepHash(["dataitem", "1", type, owner, target, -//! anchor, tags, data])` using the bundler deepHash (length-tagged SHA-384, -//! identical to `@irys/arbundles`), so a bundler, gateway, or the node itself -//! can re-derive it from the item's own fields and verify against the owner. +//! anchor, tags, data])` using the bundler deepHash (recursive length-tagged +//! SHA-384, identical to `@irys/arbundles`), so a bundler, gateway, or the +//! node itself can re-derive it from the item's own fields and verify against +//! the owner. Per ANS-104 the `tags` element of the preimage is a nested list +//! of `[tag.name, tag.value]` byte blobs — not the serialized tag stream — and +//! `deepHash` recurses into it exactly as a bundler does. use anyhow::Result; #[cfg(test)] @@ -76,14 +80,23 @@ pub fn build_signed_data_item( item.extend_from_slice(data); let signature_data = deep_hash(&[ - b"dataitem", - b"1", - SIGNATURE_TYPE_ED25519.to_string().as_bytes(), - &owner, - &[], - &[], - &serialized_tags, - data, + DeepHashChunk::Blob(b"dataitem"), + DeepHashChunk::Blob(b"1"), + DeepHashChunk::Blob(SIGNATURE_TYPE_ED25519.to_string().as_bytes()), + DeepHashChunk::Blob(&owner), + DeepHashChunk::Blob(&[]), + DeepHashChunk::Blob(&[]), + DeepHashChunk::List( + tags.iter() + .map(|(n, v)| { + DeepHashChunk::List(vec![ + DeepHashChunk::Blob(n.as_bytes()), + DeepHashChunk::Blob(v.as_bytes()), + ]) + }) + .collect(), + ), + DeepHashChunk::Blob(data), ]); let signature = keypair.sign(&signature_data).to_bytes(); item[2..2 + SIGNATURE_LEN].copy_from_slice(&signature); @@ -165,14 +178,14 @@ pub fn verify_data_item( } let signature_data = deep_hash(&[ - b"dataitem", - b"1", - signature_type.to_string().as_bytes(), - &owner, - raw_target, - raw_anchor, - raw_tags, - raw_data, + DeepHashChunk::Blob(b"dataitem"), + DeepHashChunk::Blob(b"1"), + DeepHashChunk::Blob(signature_type.to_string().as_bytes()), + DeepHashChunk::Blob(&owner), + DeepHashChunk::Blob(raw_target), + DeepHashChunk::Blob(raw_anchor), + DeepHashChunk::List(tags_preimage(&tags)), + DeepHashChunk::Blob(raw_data), ]); let sig = ed25519_dalek::Signature::from_bytes(&signature); verifying_key @@ -187,14 +200,25 @@ pub fn verify_data_item( }) } -/// deepHash of a flat list of byte blobs — the bundler's `deepHash` over the -/// data item's signature fields. `deepHash(list)` = SHA-384 chain seeded by -/// SHA-384("list"); each blob is hashed as SHA-384(SHA-384("blob") || -/// SHA-384(data)). -pub fn deep_hash(elems: &[&[u8]]) -> [u8; 48] { +/// One element of a `deepHash` list: a byte blob or a nested list (recursion is +/// required for the ANS-104 tags preimage element). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeepHashChunk<'a> { + Blob(&'a [u8]), + List(Vec>), +} + +/// The bundler's recursive `deepHash` over the data item's signature fields, +/// byte-for-byte identical to `@irys/arbundles` `deepHash` (decimal-ASCII +/// length tags, chained SHA-384 for lists, seeded by SHA-384("list"); blobs +/// hashed as SHA-384(SHA-384("blob") || SHA-384(data))). +pub fn deep_hash(elems: &[DeepHashChunk]) -> [u8; 48] { let mut acc = sha384(format!("list{}", elems.len()).as_bytes()); for elem in elems { - let chunk = deep_hash_blob(elem); + let chunk = match elem { + DeepHashChunk::Blob(data) => deep_hash_blob(data), + DeepHashChunk::List(children) => deep_hash(children), + }; let mut pair = [0u8; 96]; pair[..48].copy_from_slice(&acc); pair[48..].copy_from_slice(&chunk); @@ -210,6 +234,21 @@ fn deep_hash_blob(data: &[u8]) -> [u8; 48] { sha384(&tagged) } +/// ANS-104 tags preimage element: a nested list of `[name, value]` blobs. +/// Zero tags is an empty list, which `deepHash`s to SHA-384("list0") — a +/// different value than an empty blob, and the one a bundler recomputes. +#[cfg(test)] +fn tags_preimage(tags: &[(String, String)]) -> Vec> { + tags.iter() + .map(|(name, value)| { + DeepHashChunk::List(vec![ + DeepHashChunk::Blob(name.as_bytes()), + DeepHashChunk::Blob(value.as_bytes()), + ]) + }) + .collect() +} + fn sha384(data: &[u8]) -> [u8; 48] { let mut h = Sha384::new(); h.update(data); @@ -329,22 +368,62 @@ mod tests { use super::*; use gitlawb_core::identity::Keypair; - /// Independent reference vector, generated with a separate implementation - /// (Python/OpenSSL hashlib, not the code under test). Pins the deepHash - /// wire format against `@irys/arbundles` so an accidental divergence in - /// the length-tagging (e.g. reintroducing the old pairwise chaining) - /// turns this test red and every previously-signed anchor would no longer - /// verify. + /// Independent reference vector, generated with `@irys/arbundles` + /// `deepHash` (not the code under test) over the ANS-104 spec preimage. + /// Pins the deepHash wire format — decimal-ASCII length tags, recursive + /// list handling, chained SHA-384 — so an accidental divergence in the + /// length-tagging (e.g. reintroducing the old pairwise chaining) or in the + /// tags element turns this test red and every previously-signed anchor + /// would no longer verify. #[test] fn deep_hash_matches_independent_reference_vector() { let owner = [0x41u8; 32]; // Elements: "dataitem", "1", "2", owner, target, anchor, tags, data. - // 0 tags -> serialized tag bytes are empty; data = b"hi". - let hash = deep_hash(&[b"dataitem", b"1", b"2", &owner, &[], &[], &[], b"hi"]); - let expected = "98a0a3b931f9c5cc370e822ca06b6e9635f690f81979b70b6dfe92d0af3f601169b0d8dc72d518241e3caba7f9daad1d"; + // 0 tags -> tags element is an EMPTY LIST (deepHash([]) = SHA384("list0")), + // which differs from an empty blob and matches what a bundler recomputes. + let hash = deep_hash(&[ + DeepHashChunk::Blob(b"dataitem"), + DeepHashChunk::Blob(b"1"), + DeepHashChunk::Blob(b"2"), + DeepHashChunk::Blob(&owner), + DeepHashChunk::Blob(&[]), + DeepHashChunk::Blob(&[]), + DeepHashChunk::List(vec![]), + DeepHashChunk::Blob(b"hi"), + ]); + let expected = "a6d558f6f16e49b5224dc59740ca570f68d6d7c0f0f4045aa29f6305d47b3f5820aaff1792e93fa184f1ac238438fad8"; assert_eq!(hex::encode(hash), expected); } + /// Full-serialization interoperability fixture produced by an independent + /// implementation: `@irys/arbundles` `deepHash` over the spec preimage plus + /// Node's Ed25519 (`crypto.sign(null, ...)`), with NONEMPTY tags. Proves the + /// nested-list tags preimage and the binary layout interop with the real + /// bundler toolchain — a round trip through this module alone is not enough. + #[test] + fn verify_data_item_matches_independent_interop_fixture() { + let owner_hex = "192d13b846ce90f8b77461c47621cd3f5df04486dbe2d0e2cd5708e9b4c75d51"; + let item_hex = "0200002a34414b302c282969c75103927f5efe0b1f793605c47ba1e1057bf0ea70d580c2846305f96ef27d943a1d56e977d85e9e70fd20e65533dfa9a71a7c5e5705192d13b846ce90f8b77461c47621cd3f5df04486dbe2d0e2cd5708e9b4c75d5100000300000000000000420000000000000006104170702d4e616d650e6769746c617762085265706f18616c6963652f6d797265706f0c536368656d612a6769746c6177622f7265662d7570646174652f7631007b22736368656d61223a226769746c6177622f7265662d7570646174652f7631222c227265706f223a22616c6963652f6d797265706f227d"; + let owner: [u8; 32] = hex::decode(owner_hex).unwrap().try_into().unwrap(); + let item = hex::decode(item_hex).unwrap(); + let key = ed25519_dalek::VerifyingKey::from_bytes(&owner).unwrap(); + + let parsed = verify_data_item(&key, &item).unwrap(); + assert_eq!( + parsed.tags, + vec![ + ("App-Name".to_string(), "gitlawb".to_string()), + ("Repo".to_string(), "alice/myrepo".to_string()), + ("Schema".to_string(), "gitlawb/ref-update/v1".to_string()), + ] + ); + assert_eq!( + parsed.data, + br#"{"schema":"gitlawb/ref-update/v1","repo":"alice/myrepo"}"# + ); + assert_eq!(parsed.owner, owner); + } + #[test] fn serialize_tags_matches_reference_layout() { assert!(serialize_tags(&[]).unwrap().is_empty()); diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 93135856..f4b6713a 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -859,6 +859,7 @@ struct EncryptTaskCtx { repo_name: String, irys_url: String, bundler_account: String, + bundler_token: String, http_client: Arc, node_did: String, node_keypair: Arc, @@ -1250,6 +1251,7 @@ async fn pin_and_encrypt_objects( &ctx.http_client, &ctx.irys_url, &ctx.bundler_account, + &ctx.bundler_token, &manifest, &ctx.node_keypair, ) @@ -1996,13 +1998,18 @@ pub async fn git_receive_pack( // The tail is read-only on `disk_path` (walk plus plumbing) and takes neither the // write lease nor the advisory lock, so running it concurrently with the upload // below waits on nothing this handler still holds. Everything after (touch_repo, - // metrics, trust score, certificates, webhooks) stays in the cancellable handler. + // metrics, webhooks) stays in the cancellable handler. // - // Certificate issuance runs BEFORE the tail is spawned (below), so the tail always - // has the per-ref signed certificates in hand: the gossip event carries the real - // `cert_id`, and the Arweave anchor embeds the certificate itself. Issuance fails - // open (errors are logged and skipped), so a cert outage degrades to a cert-less - // announce rather than a dropped push. Each push owns its own tail, including its own + // The durable-success bookkeeping — record_push, trust score, and the per-ref + // signed certificates — also runs INSIDE the continuation, not here: it used to + // live in the cancellable handler between `receive_pack` returning Ok and the + // tail spawn, so a client/proxy disconnect during those DB awaits dropped a + // durable push with no certificates and no tail. Certificate issuance runs at + // the START of the continuation, so the tail always has the per-ref signed + // certificates in hand: the gossip event carries the real `cert_id`, and the + // Arweave anchor embeds the certificate itself. Issuance fails open (errors are + // logged and skipped), so a cert outage degrades to a cert-less announce rather + // than a dropped push. Each push owns its own tail, including its own // always-spawned announce, so per-push announcements are never coalesced away. // // ACCEPTED RESIDUAL, and it is the cost of this ordering: the tail also runs @@ -2019,66 +2026,32 @@ pub async fn git_receive_pack( // change to the client contract than the window it closes. let push_succeeded = receive_result.is_ok(); - // Record push event for trust score and issue a signed ref certificate. // The route is behind `require_signature`, so the verified pusher identity is // always present; use it directly rather than re-parsing the headers. let did = auth.0.as_str(); - // Collect certs keyed by ref_name so the anchoring loop below uses - // the correct per-update certificate rather than a repo-wide latest. - let mut ref_certs: std::collections::HashMap = - std::collections::HashMap::new(); - if receive_result.is_ok() { - // Use the first new commit hash we parsed, fall back to timestamp - let commit_hash = ref_updates - .first() - .map(|u| u.new_sha.clone()) - .unwrap_or_else(|| Utc::now().timestamp().to_string()); - - let _ = state.db.record_push(did, &record.id, &commit_hash, 0).await; - if let Ok(push_count) = state.db.get_push_count(did).await { - // 0.05 base (from registration) + 0.05 per push, capped at 1.0 - // 1 push → 0.10, 5 pushes → 0.30, 19 pushes → 1.0 - let new_score = (push_count as f64 * 0.05 + 0.05).min(1.0); - let _ = state.db.update_trust_score(did, new_score).await; - } - - // Issue a signed certificate for every ref this push advanced, each - // carrying that ref's real old→new transition. A multi-ref push must - // not collapse to a single cert covering only the first ref. - for update in &ref_updates { - match cert::issue_ref_certificate( - &state, - &record.id, - &update.ref_name, - &update.old_sha, - &update.new_sha, - did, - Some(pusher_sig.0.clone()), - Some(pusher_proof.signature_input.clone()), - Some(pusher_proof.content_digest.clone()), - Some(pusher_proof.request_path.clone()), - ) - .await - { - Ok(c) => { - tracing::info!(cert_id = %c.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate"); - ref_certs.insert(update.ref_name.clone(), c); - } - Err(e) => { - tracing::warn!(err = %e, ref_name = %update.ref_name, "failed to issue ref certificate") - } - } - } - } - if push_succeeded { - tokio::spawn(post_receive_replication_tail( + // Everything a landed push owes after git accepted the pack — record_push, + // trust score, per-ref signed certificates, and the replication tail — runs + // in this owned continuation. It used to live here in the cancellable + // handler between `receive_pack` returning Ok and the tail spawn, so a + // client/proxy disconnect during those DB awaits dropped a durable push + // with no certificates and no tail. Spawned above `guard.release()` for + // the same reason the tail was: `release` is itself cancellable, and the + // pack has already landed on disk by now. The continuation takes neither + // the write lease nor the advisory lock, so it waits on nothing this + // handler still holds. + tokio::spawn(post_receive_continuation( state.clone(), record.clone(), ref_updates.clone(), disk_path.clone(), - auth.0.to_string(), - ref_certs.clone(), + did.to_string(), + PusherAttestation { + sig: Some(pusher_sig.0.clone()), + signature_input: Some(pusher_proof.signature_input.clone()), + content_digest: Some(pusher_proof.content_digest.clone()), + request_path: Some(pusher_proof.request_path.clone()), + }, )); } @@ -2154,6 +2127,92 @@ pub async fn git_receive_pack( Ok(result) } +/// The pusher's RFC 9421 attestation, owned and detached with the continuation. +/// Flattened from the handler's `PusherSignature` / `PusherProof` extractors so +/// the continuation can issue per-ref certificates after the client is gone. +struct PusherAttestation { + sig: Option, + signature_input: Option, + content_digest: Option, + request_path: Option, +} + +/// The owned post-receive continuation (#224 review): everything a landed push +/// owes after git accepted the pack — the trust-score `record_push`, the +/// per-ref signed certificates, and the replication tail itself — runs in one +/// detached task. The handler spawns this immediately after `receive_pack` +/// returns Ok and before `guard.release()`, so a client/proxy disconnect after +/// the pack has landed can no longer cancel the bookkeeping or drop the tail +/// (previously they lived in the cancellable handler between receive and the +/// tail spawn). The continuation owns everything it reads and takes neither the +/// write lease nor the advisory lock. +/// +/// Certificate issuance runs at the START of the continuation, so the tail +/// always has the per-ref signed certificates in hand: the gossip event carries +/// the real `cert_id`, and the Arweave anchor embeds the certificate itself. +/// Issuance fails open (errors are logged and skipped), so a cert outage +/// degrades to a cert-less announce rather than a dropped push. +async fn post_receive_continuation( + state: AppState, + record: RepoRecord, + ref_updates: Vec, + disk_path: std::path::PathBuf, + did: String, + attestation: PusherAttestation, +) { + // Collect certs keyed by ref_name so the anchoring loop below uses + // the correct per-update certificate rather than a repo-wide latest. + let mut ref_certs: std::collections::HashMap = + std::collections::HashMap::new(); + + // Use the first new commit hash we parsed, fall back to timestamp + let commit_hash = ref_updates + .first() + .map(|u| u.new_sha.clone()) + .unwrap_or_else(|| Utc::now().timestamp().to_string()); + + let _ = state + .db + .record_push(&did, &record.id, &commit_hash, 0) + .await; + if let Ok(push_count) = state.db.get_push_count(&did).await { + // 0.05 base (from registration) + 0.05 per push, capped at 1.0 + // 1 push → 0.10, 5 pushes → 0.30, 19 pushes → 1.0 + let new_score = (push_count as f64 * 0.05 + 0.05).min(1.0); + let _ = state.db.update_trust_score(&did, new_score).await; + } + + // Issue a signed certificate for every ref this push advanced, each + // carrying that ref's real old→new transition. A multi-ref push must + // not collapse to a single cert covering only the first ref. + for update in &ref_updates { + match cert::issue_ref_certificate( + &state, + &record.id, + &update.ref_name, + &update.old_sha, + &update.new_sha, + &did, + attestation.sig.clone(), + attestation.signature_input.clone(), + attestation.content_digest.clone(), + attestation.request_path.clone(), + ) + .await + { + Ok(c) => { + tracing::info!(cert_id = %c.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate"); + ref_certs.insert(update.ref_name.clone(), c); + } + Err(e) => { + tracing::warn!(err = %e, ref_name = %update.ref_name, "failed to issue ref certificate") + } + } + } + + post_receive_replication_tail(state, record, ref_updates, disk_path, did, ref_certs).await; +} + /// The detached post-receive replication tail (#174 F2): everything a landed push /// still owes after its git response has been returned: the replication decision, /// the per-repo-coalesced pin/encrypt task, and this push's own Pinata + announce @@ -2323,6 +2382,7 @@ async fn post_receive_replication_tail( repo_name: record.name.clone(), irys_url: state.config.bundler_url.clone(), bundler_account: state.config.bundler_account.clone(), + bundler_token: state.config.bundler_token.clone(), http_client: std::sync::Arc::clone(&state.http_client), node_did: state.node_did.to_string(), node_keypair: std::sync::Arc::clone(&state.node_keypair), @@ -2380,6 +2440,7 @@ async fn post_receive_replication_tail( let ref_update_tx = state.ref_update_tx.clone(); let bundler_url = state.config.bundler_url.clone(); let bundler_account = state.config.bundler_account.clone(); + let bundler_token = state.config.bundler_token.clone(); let owner_did_for_arweave = record.owner_did.clone(); let self_public_url = state.config.public_url.clone(); let node_keypair = Arc::clone(&state.node_keypair); @@ -2562,6 +2623,7 @@ async fn post_receive_replication_tail( &http_client, &bundler_url, &bundler_account, + &bundler_token, &anchor, &node_keypair, ) @@ -2595,9 +2657,11 @@ async fn post_receive_replication_tail( tracing::warn!( repo=%repo_slug, bundler_account=%bundler_account, + bundler_token=%bundler_token, err=%e, "Arweave anchor failed — if the bundler reports 'Not enough \ - balance', fund GITLAWB_BUNDLER_ACCOUNT; an unfunded node \ + balance', fund GITLAWB_BUNDLER_ACCOUNT (for the token in \ + GITLAWB_BUNDLER_TOKEN); an unfunded node \ silently loses every anchor" ) } @@ -7117,6 +7181,7 @@ mod tests { repo_name: rec.name.clone(), irys_url: String::new(), bundler_account: String::new(), + bundler_token: String::new(), http_client: std::sync::Arc::clone(&state.http_client), node_did: state.node_did.to_string(), node_keypair: std::sync::Arc::clone(&state.node_keypair), @@ -9800,4 +9865,103 @@ mod tests { "and the unvetted push still maps no CID" ); } + + // ---- #224 review, P1: the continuation survives a disconnect after the pack lands ---- + + /// The owned post-receive continuation survives a client/proxy disconnect + /// after git accepted the pack. + /// + /// Before the fix, `record_push`, the trust-score update, and the per-ref + /// certificate issuance ran in the CANCELLABLE handler between `receive_pack` + /// returning Ok and the tail spawn; a disconnect during those DB awaits + /// dropped a durable push with no certificates and no tail. The fix spawns + /// `post_receive_continuation` (which owns that bookkeeping and then runs the + /// replication tail) before the handler does anything else cancellable. + /// + /// This test drives the fix's exact shape: a simulated handler spawns the + /// continuation, the simulated handler is aborted mid-flight (the disconnect), + /// and the continuation must still run to completion — the push row, the + /// trust score, the per-ref certificate, and the tail's withheld walk all + /// land. The tail's walk is asserted on the same git shim the F2a suite uses. + #[cfg(unix)] + #[sqlx::test] + async fn post_receive_continuation_survives_handler_abort(pool: sqlx::PgPool) { + let repo = tempfile::TempDir::new().unwrap(); + let bin = tempfile::TempDir::new().unwrap(); + u5_init_repo(repo.path()); + let c1 = u5_commit_file(repo.path(), "a.txt", "one\n"); + let log = bin.path().join("git.log"); + let git_bin = f2a_logging_git(bin.path(), &log); + let (state, rec) = f2a_state(pool, &git_bin, "z6abort", "c1", true).await; + // The trust-score update only mutates an existing agents row (never + // inserts); register the pusher so the update is observable. + state + .db + .register_agent(F2A_PUSHER, &["agent".to_string()]) + .await + .unwrap(); + + // Simulated handler: after `receive_pack` returned Ok it spawns the + // continuation, then it is still on the wire — the response has not been + // sent. The abort below is the disconnect. + let (sent, received) = tokio::sync::oneshot::channel(); + let handler_sim = tokio::spawn({ + let state = state.clone(); + let rec = rec.clone(); + let disk = repo.path().to_path_buf(); + let update = f2a_update("refs/heads/main", &c1); + async move { + let cont = tokio::spawn(post_receive_continuation( + state, + rec, + update, + disk, + F2A_PUSHER.to_string(), + PusherAttestation { + sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }, + )); + let _ = sent.send(cont); + std::future::pending::<()>().await + } + }); + let cont = received.await.expect("handler spawned the continuation"); + + // Give the continuation time to be mid-bookkeeping — the exact window the + // finding described — then sever the client. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + handler_sim.abort(); + let _ = handler_sim.await; + + // The detached continuation must still finish its whole job. + cont.await + .expect("the continuation must run to completion after the handler is aborted"); + + assert_eq!( + state.db.get_push_count(F2A_PUSHER).await.unwrap(), + 1, + "the push must still be recorded after the disconnect" + ); + assert!( + (state.db.get_trust_score(F2A_PUSHER).await.unwrap() - 0.10).abs() < 1e-9, + "the trust-score update (0.05 base + 0.05 per push) must still land" + ); + let certs = state.db.list_ref_certificates(&rec.id, 10).await.unwrap(); + assert_eq!( + certs.len(), + 1, + "the per-ref certificate must still be issued after the disconnect" + ); + assert_eq!(certs[0].ref_name, "refs/heads/main"); + assert_eq!(certs[0].new_sha, c1); + assert_eq!(certs[0].pusher_did, F2A_PUSHER); + assert!( + f2a_walks(&log) >= 1, + "the replication tail's withheld walk must still run after the disconnect; log:\n{}", + f2a_log(&log) + ); + } } diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 833c0a88..d4d5b155 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -11,10 +11,15 @@ //! the bundler only serves items backed by a funded account, and refuses //! under-funded uploads with "Not enough balance" — which the push path degrades //! to a warning, so an unfunded node silently loses every anchor. Funding is -//! therefore mandatory configuration, not optional: set `GITLAWB_BUNDLER_ACCOUNT` -//! to the funded account you created for this node (top up via the bundler's -//! devnet faucet on devnet hosts); `Config::validate()` refuses to start with a -//! bundler URL but no funded account. +//! therefore mandatory configuration, not optional. Irys bills each upload +//! against a payment token at `/tx/{token}` and reads the funded address from +//! the `x-irys-paid-by` header (see the `@irys/upload` js-sdk, +//! `UploadHeaders.PAID_BY`), so the node sends: +//! - `GITLAWB_BUNDLER_ACCOUNT` — the funded address/identity, as `x-irys-paid-by` +//! - `GITLAWB_BUNDLER_TOKEN` — the payment-token slug (e.g. "matic") +//! - `GITLAWB_BUNDLER_URL` — the node base URL; uploads go to `{url}/tx/{token}` +//! - `Config::validate()` refuses to start with a bundler URL but no funded +//! account or payment token. //! //! Set `GITLAWB_BUNDLER_URL` (deprecated name: `GITLAWB_IRYS_URL`) to override the default endpoint: //! - devnet (faucet-funded): https://devnet.irys.xyz @@ -27,7 +32,6 @@ //! The permanent Arweave URL is: / //! //! Anchors are stored in the `arweave_anchors` table for auditability. - use anyhow::Result; use base64::Engine as _; use futures::StreamExt; @@ -36,7 +40,6 @@ use serde_json::json; use sha2::Digest; use std::collections::HashMap; use std::str::FromStr; - /// Data describing a ref-update event to be anchored. #[derive(Debug, Clone)] pub struct RefAnchor { @@ -54,7 +57,6 @@ pub struct RefAnchor { /// serialized and embedded so a verifier can validate the chain. pub certificate: Option, } - /// Anchor a ref-update to Arweave via Irys. /// /// The payload is uploaded as a signed ANS-104 data item: `node_keypair` signs @@ -66,13 +68,13 @@ pub async fn anchor_ref_update( client: &reqwest::Client, bundler_url: &str, bundler_account: &str, + bundler_token: &str, anchor: &RefAnchor, node_keypair: &gitlawb_core::identity::Keypair, ) -> Result { if bundler_url.is_empty() { return Ok(String::new()); } - let mut payload = json!({ "schema": "gitlawb/ref-update/v1", "repo": anchor.repo, @@ -86,14 +88,11 @@ pub async fn anchor_ref_update( "node_did": anchor.node_did, "network": "alpha", }); - // Embed the signed certificate so verifiers can validate the chain. if let Some(cert) = &anchor.certificate { payload["certificate"] = serde_json::to_value(cert)?; } - let body = serde_json::to_vec(&payload)?; - let tags: Vec<(String, String)> = [ "App-Name:gitlawb".to_string(), "Schema:gitlawb/ref-update/v1".to_string(), @@ -108,50 +107,53 @@ pub async fn anchor_ref_update( (name.to_string(), value.to_string()) }) .collect(); - let data_item = crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body)?; - - // Irys upload endpoint - let url = format!("{}/v1/tx", bundler_url.trim_end_matches('/')); - + // Irys upload target: {bundler_url}/tx/{token}. Built structurally so a + // query on the base URL is preserved and a fragment is rejected outright. + let url = bundler_upload_url(bundler_url, bundler_token)?; + let display_url = crate::server::mask_credential_url(&url); let resp = client .post(&url) .header("Content-Type", "application/octet-stream") - .header("x-bundler-address", bundler_account) + .header("x-irys-paid-by", bundler_account) .body(data_item) .send() .await - .map_err(|e| anyhow::anyhow!("Bundler upload failed: {e}"))?; - + .map_err(|e| { + // reqwest embeds the request URL verbatim; swap in the masked form. + let safe_err = e.to_string().replace(&url, &display_url); + anyhow::anyhow!("Bundler upload failed: {safe_err}") + })?; if !resp.status().is_success() { let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); + let body = truncate_for_error(&resp.text().await.unwrap_or_default(), 512); return Err(anyhow::anyhow!("Bundler returned {status}: {body}")); } - let json: serde_json::Value = resp .json() .await .map_err(|e| anyhow::anyhow!("failed to parse Bundler response: {e}"))?; - // Bundler response: {"id": "", "timestamp": ..., "version": ...} let tx_id = json["id"] .as_str() - .ok_or_else(|| anyhow::anyhow!("no 'id' in Bundler response: {json}"))? + .ok_or_else(|| { + anyhow::anyhow!( + "no 'id' in Bundler response: {}", + truncate_for_error(&json.to_string(), 512) + ) + })? .to_string(); - tracing::info!( repo = %anchor.repo, ref_name = %anchor.ref_name, new_sha = %anchor.new_sha, tx_id = %tx_id, bundler_account = %bundler_account, + bundler_token = %bundler_token, "anchored ref update to Arweave via bundler" ); - Ok(tx_id) } - /// A per-push manifest of the blobs encrypted this push (Option B3). The /// `blobs` slice is `(oid, cid)` tuples. Anchored directly to Arweave as its JSON /// body so the discovery index survives total node loss. Recipient identities are @@ -163,7 +165,6 @@ pub struct EncryptedManifest<'a> { pub timestamp: &'a str, pub blobs: &'a [(String, String)], } - /// Anchor a per-push encrypted-blob manifest to Arweave via Irys. The manifest /// JSON body is the payload (not a CID pointer to IPFS), so the index is /// permanent and self-contained. Recipient identities are deliberately omitted: @@ -179,19 +180,18 @@ pub async fn anchor_encrypted_manifest( client: &reqwest::Client, bundler_url: &str, bundler_account: &str, + bundler_token: &str, manifest: &EncryptedManifest<'_>, node_keypair: &gitlawb_core::identity::Keypair, ) -> Result { if bundler_url.is_empty() || manifest.blobs.is_empty() { return Ok(String::new()); } - let blobs_json: Vec = manifest .blobs .iter() .map(|(oid, cid)| manifest_blob_json(oid, cid)) .collect(); - let payload = json!({ "schema": "gitlawb/encrypted-manifest/v1", "repo": manifest.repo, @@ -200,9 +200,7 @@ pub async fn anchor_encrypted_manifest( "timestamp": manifest.timestamp, "blobs": blobs_json, }); - let body = serde_json::to_vec(&payload)?; - let tags: Vec<(String, String)> = [ "App-Name:gitlawb".to_string(), "Schema:gitlawb/encrypted-manifest/v1".to_string(), @@ -216,59 +214,62 @@ pub async fn anchor_encrypted_manifest( (name.to_string(), value.to_string()) }) .collect(); - let data_item = crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body)?; - let url = format!("{}/v1/tx", bundler_url.trim_end_matches('/')); - + // Irys upload target: {bundler_url}/tx/{token}. Built structurally so a + // query on the base URL is preserved and a fragment is rejected outright. + let url = bundler_upload_url(bundler_url, bundler_token)?; + let display_url = crate::server::mask_credential_url(&url); let resp = client .post(&url) .header("Content-Type", "application/octet-stream") - .header("x-bundler-address", bundler_account) + .header("x-irys-paid-by", bundler_account) .body(data_item) .send() .await - .map_err(|e| anyhow::anyhow!("Bundler upload failed: {e}"))?; - + .map_err(|e| { + // reqwest embeds the request URL verbatim; swap in the masked form. + let safe_err = e.to_string().replace(&url, &display_url); + anyhow::anyhow!("Bundler upload failed: {safe_err}") + })?; if !resp.status().is_success() { let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); + let body = truncate_for_error(&resp.text().await.unwrap_or_default(), 512); return Err(anyhow::anyhow!("Bundler returned {status}: {body}")); } - let json: serde_json::Value = resp .json() .await .map_err(|e| anyhow::anyhow!("failed to parse Bundler response: {e}"))?; - let tx_id = json["id"] .as_str() - .ok_or_else(|| anyhow::anyhow!("no 'id' in Bundler response: {json}"))? + .ok_or_else(|| { + anyhow::anyhow!( + "no 'id' in Bundler response: {}", + truncate_for_error(&json.to_string(), 512) + ) + })? .to_string(); - tracing::info!( repo = %manifest.repo, tx_id = %tx_id, blobs = manifest.blobs.len(), bundler_account = %bundler_account, + bundler_token = %bundler_token, "anchored encrypted manifest to Arweave via bundler" ); - Ok(tx_id) } - /// Serialize one blob for the Arweave manifest. Recipient identities are /// intentionally absent so the permanent public anchor never records who can /// read a blob. fn manifest_blob_json(oid: &str, cid: &str) -> serde_json::Value { json!({ "oid": oid, "cid": cid }) } - /// Borrow `(name, value)` string slices from owned tag pairs for /// [`crate::ans104::build_signed_data_item`]. fn tag_refs(tags: &[(String, String)]) -> Vec<(&str, &str)> { tags.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect() } - /// Strip characters that are invalid in bundler/Arweave tag values. fn sanitize_tag(s: &str) -> String { s.chars() @@ -276,13 +277,58 @@ fn sanitize_tag(s: &str) -> String { .take(128) .collect() } - /// Arweave URL for a given transaction ID, resolved through a configurable gateway. #[allow(dead_code)] pub fn arweave_url(gateway: &str, tx_id: &str) -> String { format!("{}/{}", gateway.trim_end_matches('/'), tx_id) } - +/// Structurally join a base URL onto a path (`/tx/{token}` for uploads, a tx_id +/// for gateway reads), preserving the base's query string and rejecting +/// fragments. String concatenation would silently drop or garble a +/// query/fragment form and could smuggle credentials into the request target; +/// joining through `Url` keeps every part where it belongs. The returned string +/// is also the exact request target, so tests can assert it verbatim. +fn join_url_path(base: &str, segments: &[&str], what: &str) -> Result { + let mut url = reqwest::Url::parse(base).map_err(|e| anyhow::anyhow!("invalid {what}: {e}"))?; + if url.fragment().is_some() { + return Err(anyhow::anyhow!( + "{what} must not contain a URL fragment (a fragment is never sent to the \ + bundler/gateway and would silently change the request)" + )); + } + let query = url.query().map(str::to_string); + { + let mut segments_mut = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("{what} must be a hierarchical URL"))?; + segments_mut.pop_if_empty(); + for seg in segments { + segments_mut.push(seg); + } + } + if let Some(q) = query { + url.set_query(Some(&q)); + } + Ok(url.to_string()) +} +/// Irys upload request target: `{bundler_url}/tx/{token}`, structurally joined. +fn bundler_upload_url(bundler_url: &str, token: &str) -> Result { + join_url_path(bundler_url, &["tx", token], "bundler URL") +} +/// Gateway request target for a transaction ID: `{gateway_url}/{tx_id}`. +fn gateway_tx_url(gateway_url: &str, tx_id: &str) -> Result { + join_url_path(gateway_url, &[tx_id], "gateway URL") +} +/// Cap a value for error messages/logs so a hostile or misbehaving endpoint +/// cannot drive unbounded allocations or output through an error string. +fn truncate_for_error(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut out = s.chars().take(max).collect::(); + out.push_str("…(truncated)"); + out +} /// Result of verifying an Arweave anchor against the stored certificate chain. #[derive(Debug, Clone, Serialize)] pub struct VerifyResult { @@ -291,7 +337,6 @@ pub struct VerifyResult { pub certificate: Option, pub errors: Vec, } - /// Fetch an anchor from Arweave, extract the embedded certificate, and verify /// the full chain: certificate signature, prev hash linkage, and pusher signature. pub async fn verify_anchor( @@ -303,15 +348,23 @@ pub async fn verify_anchor( ) -> Result { // Fetch the data item from the Arweave gateway's data path. // Gateways serve data at /{tx_id}, not /v1/tx/{id} (which is the bundler API). - let url = format!("{}/{}", gateway_url.trim_end_matches('/'), tx_id); + // Built structurally: a query on the gateway config is preserved, and a + // fragment is rejected (it would never be sent to the gateway). + let url = match gateway_tx_url(gateway_url, tx_id) { + Ok(u) => u, + Err(e) => { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![e.to_string()], + }); + } + }; // Public-facing display form of the same URL: reqwest's connection error // embeds the request URL verbatim, so if the gateway config carries // credentials the error text would otherwise leak them into VerifyResult. - let display_url = format!( - "{}/{}", - crate::server::mask_credential_url(gateway_url).trim_end_matches('/'), - tx_id - ); + let display_url = crate::server::mask_credential_url(&url); let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => { @@ -359,7 +412,6 @@ pub async fn verify_anchor( } body_bytes.extend_from_slice(&data); } - // Parse the payload — could be JSON or raw bytes depending on gateway. // Non-JSON responses are handled as an invalid result rather than an error. let anchor: serde_json::Value = match serde_json::from_slice(&body_bytes) { @@ -374,14 +426,11 @@ pub async fn verify_anchor( } }; let cert_value = anchor.get("certificate"); - let cert: Option = match cert_value { Some(v) => serde_json::from_value(v.clone()).ok(), None => None, }; - let mut errors = Vec::new(); - if let Some(ref c) = cert { // 0a. Verify the certificate was issued by this node. if c.node_did != node_did { @@ -390,7 +439,6 @@ pub async fn verify_anchor( c.node_did, node_did )); } - // 0b. Cross-check the outer anchor fields against the embedded certificate. // A valid anchor must commit to the same identities and ref state. // The outer repo_id (UUID) is compared against the cert's repo_id (UUID) @@ -448,7 +496,6 @@ pub async fn verify_anchor( c.node_did )); } - // 0c. Corroborate outer repo slug and owner_did against the node's own // record for the certificate's repo_id. The certificate signs the // repo_id UUID but not the human-readable slug or owner DID, so a @@ -500,17 +547,18 @@ pub async fn verify_anchor( } } Err(e) => { + // The raw DB error never reaches the caller (it can embed + // connection details); it is logged server-side only, and the + // deny is stated without it, like the not-found branch above. + tracing::warn!("repo lookup failed for {}: {e}", c.repo_id); if outer_identity_present { errors.push(format!( - "repo lookup failed for {} — outer repo/owner_did cannot be corroborated: {e}", + "repo lookup failed for {} — outer repo/owner_did cannot be corroborated", c.repo_id )); - } else { - tracing::warn!("repo lookup failed for {}: {e}", c.repo_id); } } } - // 1. Verify node signature on the certificate payload. // Certificates produced after this PR use a 13-field payload // that includes seq, prev, and proof fields. Pre-PR certificates @@ -521,7 +569,6 @@ pub async fn verify_anchor( && c.signature_input.is_none() && c.content_digest.is_none() && c.request_path.is_none(); - // Resolve node DID to public key let node_did = match gitlawb_core::did::Did::from_str(&c.node_did) { Ok(did) => did, @@ -547,7 +594,6 @@ pub async fn verify_anchor( }); } }; - let sig_array: [u8; 64] = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&c.signature) { Ok(bytes) => match bytes.as_slice().try_into() { @@ -572,7 +618,6 @@ pub async fn verify_anchor( }); } }; - // Try 13-field payload first. let payload_13 = serde_json::json!({ "repo_id": c.repo_id, @@ -592,7 +637,6 @@ pub async fn verify_anchor( let payload_bytes_13 = serde_json::to_vec(&payload_13)?; let sig_valid_13 = gitlawb_core::identity::verify(&verifying_key, &payload_bytes_13, &sig_array); - let mut legacy_7_field_verified = false; if proof_fields_null && sig_valid_13.is_err() { // Fall back to 7-field payload for pre-PR certificates. @@ -615,7 +659,6 @@ pub async fn verify_anchor( } else if let Err(e) = sig_valid_13 { errors.push(format!("certificate signature verification failed: {e}")); } - // 1b. Corroborate chain position for legacy certificates. // The 7-field fallback covers only repo_id, ref, old, new, pusher, // node, ts. seq and prev are NOT covered on that path, so a tampered @@ -665,7 +708,6 @@ pub async fn verify_anchor( } } } - // 2. Verify prev hash linkage against the predecessor at seq - 1. // The prev hash covers the 7-field payload (repo_id, ref, old, new, // pusher, node, ts) — seq, prev, and proof fields are excluded so @@ -721,7 +763,6 @@ pub async fn verify_anchor( } } } - // 3. Verify the pusher authorization proof (RFC 9421 HTTP Signature). // The context fields (signature_input, content_digest, request_path) // are bound into the node signing payload, so a certificate whose @@ -754,12 +795,10 @@ pub async fn verify_anchor( request_values.insert("@path".to_string(), request_path.clone()); request_values .insert("content-digest".to_string(), content_digest.clone()); - let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input); let components_ref: Vec<&str> = http_sig.components.iter().map(String::as_str).collect(); - match gitlawb_core::http_sig::build_signing_string( &components_ref, sig_params_value, @@ -844,7 +883,6 @@ pub async fn verify_anchor( } else { errors.push("no embedded certificate found in anchor".to_string()); } - Ok(VerifyResult { valid: errors.is_empty(), anchor, @@ -852,22 +890,24 @@ pub async fn verify_anchor( errors, }) } - #[cfg(test)] mod tests { use super::*; use axum::http::StatusCode; use gitlawb_core::identity::Keypair; - /// Spin up an in-process bundler that *enforces* the signed data item /// contract: it parses the posted bytes as an ANS-104 item, verifies the /// Ed25519 signature against `kp`, checks that every `expected_tag` is /// present inside the item, and requires the embedded JSON payload to pass - /// `validate`. Any failure returns 400 (surfacing as `Err` from the anchor - /// functions); success returns `{"id": }`. + /// `validate`. It also asserts the Irys wire contract verbatim: the request + /// target must equal `expected_request_target` (i.e. `/tx/{token}`, possibly + /// with a path prefix or query) and the `x-irys-paid-by` header must carry + /// `expected_bundler_account`. Any failure returns 400 (surfacing as `Err` + /// from the anchor functions); success returns `{"id": }`. async fn spawn_enforcing_bundler( kp: &Keypair, expected_bundler_account: &'static str, + expected_request_target: &'static str, expected_tags: &[(&str, &str)], validate: impl Fn(&serde_json::Value) -> bool + Send + Sync + Clone + 'static, tx_id: &'static str, @@ -879,25 +919,47 @@ mod tests { .collect(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); + // Serve the exact path the client must request (path portion of the + // expected request target), so prefixed or query-carrying bases are + // exercised structurally rather than special-cased. + let route_path = expected_request_target + .split('?') + .next() + .unwrap_or(expected_request_target); let router = axum::Router::new().route( - "/v1/tx", + route_path, axum::routing::post( - move |headers: axum::http::HeaderMap, body: axum::body::Bytes| { + move |uri: axum::http::Uri, + headers: axum::http::HeaderMap, + body: axum::body::Bytes| { let vk = vk; let expected = expected.clone(); async move { + // The request target is the Irys contract: /tx/{token} + // with the base's query preserved. Assert it verbatim so + // the structural URL join cannot regress. + let target = uri.path_and_query().map(|q| q.as_str()).unwrap_or(""); + if target != expected_request_target { + return ( + StatusCode::BAD_REQUEST, + format!( + "wrong request target: got {target:?}, want \ + {expected_request_target:?}" + ), + ); + } // The funded-account identity must be part of the request, // not just the config: the item signature is authorship. if !expected_bundler_account.is_empty() { let got = headers - .get("x-bundler-address") + .get("x-irys-paid-by") .and_then(|v| v.to_str().ok()) .unwrap_or_default(); if got != expected_bundler_account { return ( StatusCode::BAD_REQUEST, format!( - "missing/wrong x-bundler-address: got {got:?}, want \ + "missing/wrong x-irys-paid-by: got {got:?}, want \ {expected_bundler_account:?}" ), ); @@ -945,7 +1007,6 @@ mod tests { }); format!("http://{addr}") } - #[tokio::test] async fn test_anchor_noop_when_url_empty() { let kp = Keypair::generate(); @@ -962,17 +1023,17 @@ mod tests { node_did: "did:key:z6MknndwexV9...".into(), certificate: None, }; - let result = anchor_ref_update(&client, "", "", &anchor, &kp).await; + let result = anchor_ref_update(&client, "", "", "", &anchor, &kp).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), ""); } - #[tokio::test] async fn test_anchor_success() { let kp = Keypair::generate(); let server = spawn_enforcing_bundler( &kp, "zBundlerAccount", + "/tx/matic", &[ ("App-Name", "gitlawb"), ("Schema", "gitlawb/ref-update/v1"), @@ -982,7 +1043,6 @@ mod tests { "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk", ) .await; - let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), @@ -996,15 +1056,14 @@ mod tests { node_did: "did:key:z6Mknnd...".into(), certificate: None, }; - - let result = anchor_ref_update(&client, &server, "zBundlerAccount", &anchor, &kp).await; + let result = + anchor_ref_update(&client, &server, "zBundlerAccount", "matic", &anchor, &kp).await; assert!(result.is_ok(), "anchor should succeed: {result:?}"); assert_eq!( result.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk" ); } - /// The funded bundler account must ride on the upload request: the item /// signature is authorship, not payment, so an upload that omits the /// account must be refused — it would otherwise be billed to nobody. @@ -1024,24 +1083,22 @@ mod tests { node_did: "did:key:z6Mknnd...".into(), certificate: None, }; - let server = spawn_enforcing_bundler( &kp, "zBundlerAccount", + "/tx/matic", &[("App-Name", "gitlawb"), ("Schema", "gitlawb/ref-update/v1")], |_| true, "NEVER_RETURNED", ) .await; - - let result = anchor_ref_update(&client, &server, "", &anchor, &kp).await; + let result = anchor_ref_update(&client, &server, "", "matic", &anchor, &kp).await; let err = result.expect_err("missing bundler account must fail the upload"); assert!( - err.to_string().contains("x-bundler-address"), + err.to_string().contains("x-irys-paid-by"), "error should name the missing account header: {err}" ); } - #[tokio::test] async fn test_anchor_body_carries_real_old_sha() { // The anchored body must serialize the real old→new transition the @@ -1055,12 +1112,12 @@ mod tests { let server = spawn_enforcing_bundler( &kp, "zBundlerAccount", + "/tx/matic", &[("App-Name", "gitlawb")], move |j| j["old_sha"] == real_old && j["new_sha"] == real_new, "TX_REAL_OLD_SHA", ) .await; - let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), @@ -1074,11 +1131,10 @@ mod tests { node_did: "did:key:z6Mknnd...".into(), certificate: None, }; - - let result = anchor_ref_update(&client, &server, "zBundlerAccount", &anchor, &kp).await; + let result = + anchor_ref_update(&client, &server, "zBundlerAccount", "matic", &anchor, &kp).await; assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA"); } - #[tokio::test] async fn test_anchor_rejected_when_signed_by_other_key() { // The bundler enforces the node's public key; an item signed by a @@ -1088,12 +1144,12 @@ mod tests { let server = spawn_enforcing_bundler( &node_kp, "zBundlerAccount", + "/tx/matic", &[("App-Name", "gitlawb")], |_| true, "NEVER_RETURNED", ) .await; - let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), @@ -1107,15 +1163,20 @@ mod tests { node_did: "did:key:z6Mknnd...".into(), certificate: None, }; - - let result = - anchor_ref_update(&client, &server, "zBundlerAccount", &anchor, &impostor_kp).await; + let result = anchor_ref_update( + &client, + &server, + "zBundlerAccount", + "matic", + &anchor, + &impostor_kp, + ) + .await; assert!( result.is_err(), "upload signed by the wrong key must be denied by the bundler" ); } - #[test] fn test_arweave_url() { let url = arweave_url( @@ -1127,7 +1188,6 @@ mod tests { "https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk" ); } - #[tokio::test] async fn test_manifest_anchor_noop_when_url_empty() { let client = reqwest::Client::new(); @@ -1141,13 +1201,12 @@ mod tests { blobs: &blobs, }; assert_eq!( - anchor_encrypted_manifest(&client, "", "", &m, &kp) + anchor_encrypted_manifest(&client, "", "", "", &m, &kp) .await .unwrap(), "" ); } - #[tokio::test] async fn test_manifest_anchor_noop_when_no_blobs() { let client = reqwest::Client::new(); @@ -1162,19 +1221,19 @@ mod tests { }; // Non-empty URL, but no blobs: still a no-op. assert_eq!( - anchor_encrypted_manifest(&client, "https://example.invalid", "", &m, &kp) + anchor_encrypted_manifest(&client, "https://example.invalid", "", "", &m, &kp) .await .unwrap(), "" ); } - #[tokio::test] async fn test_manifest_anchor_success() { let kp = Keypair::generate(); let server = spawn_enforcing_bundler( &kp, "zBundlerAccount", + "/tx/matic", &[ ("App-Name", "gitlawb"), ("Schema", "gitlawb/encrypted-manifest/v1"), @@ -1186,7 +1245,6 @@ mod tests { "MANIFESTTX123", ) .await; - let client = reqwest::Client::new(); let blobs = vec![("oid1".to_string(), "cid1".to_string())]; let m = EncryptedManifest { @@ -1196,10 +1254,170 @@ mod tests { timestamp: "2026-06-11T00:00:00Z", blobs: &blobs, }; - let r = anchor_encrypted_manifest(&client, &server, "zBundlerAccount", &m, &kp).await; + let r = + anchor_encrypted_manifest(&client, &server, "zBundlerAccount", "matic", &m, &kp).await; assert_eq!(r.unwrap(), "MANIFESTTX123"); } - + /// A minimal ref-update anchor for the URL-join tests. + fn test_anchor(repo: &str, new_sha: &str) -> RefAnchor { + RefAnchor { + repo: repo.into(), + repo_id: "repo-uuid".into(), + owner_did: "did:key:z6Mk...".into(), + ref_name: "refs/heads/main".into(), + old_sha: "0".repeat(40), + new_sha: new_sha.into(), + cid: None, + timestamp: "2026-03-14T00:00:00Z".into(), + node_did: "did:key:z6Mknnd...".into(), + certificate: None, + } + } + /// The upload target must survive a path-prefixed bundler base: joining + /// `{url}/prefix` must produce `/prefix/tx/matic`, never a dropped prefix. + #[tokio::test] + async fn test_anchor_preserves_bundler_path_prefix() { + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/prefix/tx/matic", + &[("App-Name", "gitlawb")], + |_| true, + "PREFIXED_TX", + ) + .await; + let client = reqwest::Client::new(); + let base = format!("{server}/prefix"); + let result = anchor_ref_update( + &client, + &base, + "zBundlerAccount", + "matic", + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .await; + assert_eq!(result.unwrap(), "PREFIXED_TX"); + } + /// A query on the bundler base must ride along on the upload request target + /// (`/tx/matic?token=secret`) rather than being dropped by string concat. + #[tokio::test] + async fn test_anchor_preserves_bundler_query() { + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/tx/matic?token=secret", + &[("App-Name", "gitlawb")], + |_| true, + "QUERY_TX", + ) + .await; + let client = reqwest::Client::new(); + let base = format!("{server}?token=secret"); + let result = anchor_ref_update( + &client, + &base, + "zBundlerAccount", + "matic", + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .await; + assert_eq!(result.unwrap(), "QUERY_TX"); + } + /// A fragment in the bundler URL must be rejected outright for both upload + /// paths: it is never sent to the bundler, so sending it silently would + /// change the request target in a way the operator cannot see. + #[tokio::test] + async fn test_anchor_rejects_fragment_in_bundler_url() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let bad = "https://example.invalid/#fragment"; + let anchor = test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ); + let err = anchor_ref_update(&client, bad, "acct", "matic", &anchor, &kp) + .await + .expect_err("a fragment in the bundler URL must fail the upload"); + assert!( + err.to_string().contains("fragment"), + "error should name the fragment: {err}" + ); + let blobs = vec![("oid1".to_string(), "cid1".to_string())]; + let m = EncryptedManifest { + repo: "alice/r", + owner_did: "did:key:zO", + node_did: "did:key:zN", + timestamp: "2026-06-11T00:00:00Z", + blobs: &blobs, + }; + let err = anchor_encrypted_manifest(&client, bad, "acct", "matic", &m, &kp) + .await + .expect_err("a fragment in the bundler URL must fail the manifest upload"); + assert!( + err.to_string().contains("fragment"), + "error should name the fragment: {err}" + ); + } + /// The gateway read must preserve a query on the gateway config (structural + /// join), so the mock only answers a request whose target carries it. + #[tokio::test] + async fn test_verify_anchor_preserves_gateway_query() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/some-tx-id?token=secret") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid":false}"#) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let gateway = format!("{}?token=secret", server.url()); + let r = verify_anchor(&client, &gateway, "some-tx-id", &db, "did:key:zNODE") + .await + .expect("verify_anchor should return Ok"); + assert!(!r.valid, "non-certificate JSON should be invalid"); + mock.assert_async().await; + } + /// A fragment in the gateway URL must be rejected without ever issuing an + /// HTTP request: a fragment is never sent to the gateway, so a config that + /// carries one is a configuration error, surfaced as an invalid result. + #[tokio::test] + async fn test_verify_anchor_rejects_fragment_in_gateway_url() { + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let r = verify_anchor( + &client, + "https://gateway.example/#fragment", + "some-tx-id", + &db, + "did:key:zNODE", + ) + .await + .expect("verify_anchor should return Ok"); + assert!(!r.valid, "fragment in gateway URL must be invalid"); + assert!( + r.errors.iter().any(|e| e.contains("fragment")), + "errors should name the fragment: {:?}", + r.errors + ); + } #[test] fn manifest_blob_json_omits_recipients() { let v = manifest_blob_json("oid1", "cidA"); @@ -1210,13 +1428,11 @@ mod tests { "Arweave manifest must not anchor recipient identities" ); } - #[test] fn test_sanitize_tag() { assert_eq!(sanitize_tag("alice/myrepo"), "alice/myrepo"); assert_eq!(sanitize_tag("hello world!"), "helloworld"); } - #[tokio::test] async fn test_verify_anchor_uses_correct_gateway_url() { let mut server = mockito::Server::new_async().await; @@ -1227,7 +1443,6 @@ mod tests { .with_body(r#"{"valid":false}"#) .create_async() .await; - let client = reqwest::Client::new(); let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://localhost/gitlawb_test_placeholder") @@ -1241,12 +1456,10 @@ mod tests { "did:key:zNODE", ) .await; - let r = result.expect("verify_anchor should return Ok for gateway errors"); assert!(!r.valid, "non-certificate JSON should be invalid"); mock.assert_async().await; } - /// A gateway URL carrying a query token must never surface that token in /// the public VerifyResult error text: reqwest embeds the request URL in /// its connection error, so the error must be rebuilt from the masked URL. @@ -1257,7 +1470,6 @@ mod tests { .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - // Port 1 on loopback refuses connections deterministically. let result = verify_anchor( &client, @@ -1267,7 +1479,6 @@ mod tests { "did:key:zNODE", ) .await; - let r = result.expect("verify_anchor should return Ok for gateway connection errors"); assert!(!r.valid); let err_text = r.errors.join(" "); @@ -1276,11 +1487,9 @@ mod tests { "gateway query token leaked into VerifyResult: {err_text}" ); } - #[tokio::test] async fn test_verify_anchor_malformed_node_did() { let mut server = mockito::Server::new_async().await; - let bad_cert_json = serde_json::json!({ "certificate": { "id": "cert-1", @@ -1301,7 +1510,6 @@ mod tests { "new_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "node_did": "malformed-node-did", }); - let _mock = server .mock("GET", "/test-tx") .with_status(200) @@ -1309,13 +1517,11 @@ mod tests { .with_body(serde_json::to_string(&bad_cert_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - // Verify as "malformed-node-did" itself so the issuer check passes and // the DID-parse guard is what must fire. This pins the `invalid node // DID` error push: with the anchor claiming the node IS the malformed @@ -1327,7 +1533,6 @@ mod tests { "Expected Ok response, got Err: {:?}", result ); - let verify_result = result.unwrap(); assert!(!verify_result.valid, "VerifyResult should be invalid"); assert!( @@ -1339,7 +1544,6 @@ mod tests { verify_result.errors ); } - /// Pins the issuer guard (`c.node_did != node_did`): a cert that is fully /// authentic — real node signature over the real 13-field payload, real /// pusher proof — but names a DIFFERENT node as its issuer must fail with @@ -1354,7 +1558,6 @@ mod tests { let other_did = other_kp.did().as_str().to_string(); let pusher_kp = gitlawb_core::identity::Keypair::generate(); let pusher_did = pusher_kp.did().as_str().to_string(); - let repo_id = "repo-uuid"; let ref_name = "refs/heads/main"; let old_sha = "0".repeat(40); @@ -1362,7 +1565,6 @@ mod tests { let issued_at = "2026-07-22T00:00:00+00:00"; let seq = 1i64; let prev = "0".repeat(64); - let request_path = "/repo-uuid.git/git-receive-pack"; let signed = gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); @@ -1372,7 +1574,6 @@ mod tests { .and_then(|s| s.strip_suffix(':')) .unwrap() .to_string(); - // Signed by `other_kp`, which the payload names as node_did — so the // cert is internally self-consistent and its signature verifies. let payload = serde_json::json!({ @@ -1391,7 +1592,6 @@ mod tests { "request_path": request_path, }); let signature = other_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); - let cert = crate::db::RefCertificate { id: "cert-other-node".to_string(), repo_id: repo_id.to_string(), @@ -1409,7 +1609,6 @@ mod tests { content_digest: Some(signed.content_digest), request_path: Some(request_path.to_string()), }; - let anchor_json = serde_json::json!({ "repo_id": repo_id, "ref_name": ref_name, @@ -1418,7 +1617,6 @@ mod tests { "node_did": other_did, "certificate": cert, }); - let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/other-node-tx") @@ -1427,13 +1625,11 @@ mod tests { .with_body(serde_json::to_string(&anchor_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - let result = verify_anchor(&client, &server.url(), "other-node-tx", &db, &node_did).await; let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); assert!( @@ -1450,7 +1646,6 @@ mod tests { ); _mock.assert_async().await; } - /// Pins the 13-field signature-failure error push: an authentic cert whose /// node signature was tampered must fail with the 13-field signature error. /// If the push were removed, no other guard would catch it (the proof @@ -1462,7 +1657,6 @@ mod tests { let node_did = node_kp.did().as_str().to_string(); let pusher_kp = gitlawb_core::identity::Keypair::generate(); let pusher_did = pusher_kp.did().as_str().to_string(); - let repo_id = "repo-uuid"; let ref_name = "refs/heads/main"; let old_sha = "0".repeat(40); @@ -1470,7 +1664,6 @@ mod tests { let issued_at = "2026-07-22T00:00:00+00:00"; let seq = 1i64; let prev = "0".repeat(64); - let request_path = "/repo-uuid.git/git-receive-pack"; let signed = gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); @@ -1480,7 +1673,6 @@ mod tests { .and_then(|s| s.strip_suffix(':')) .unwrap() .to_string(); - let payload = serde_json::json!({ "repo_id": repo_id, "ref": ref_name, @@ -1497,9 +1689,19 @@ mod tests { "request_path": request_path, }); let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); - // Tamper: flip one byte in the node signature. - let tampered_signature = format!("A{}", &signature[1..]); - + // Tamper: decode the b64url signature, flip one byte (guaranteed to + // change the value — unlike prefix replacement, which is a 1-in-64 + // no-op), and re-encode. + let tampered_signature = { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let mut bytes = URL_SAFE_NO_PAD + .decode(&signature) + .expect("signature should decode"); + bytes[0] ^= 0x01; + let tampered = URL_SAFE_NO_PAD.encode(&bytes); + assert_ne!(tampered, signature, "tamper must change the signature"); + tampered + }; let cert = crate::db::RefCertificate { id: "cert-tampered-13".to_string(), repo_id: repo_id.to_string(), @@ -1517,7 +1719,6 @@ mod tests { content_digest: Some(signed.content_digest), request_path: Some(request_path.to_string()), }; - let anchor_json = serde_json::json!({ "repo_id": repo_id, "ref_name": ref_name, @@ -1526,7 +1727,6 @@ mod tests { "node_did": node_did, "certificate": cert, }); - let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/tampered-13-tx") @@ -1535,13 +1735,11 @@ mod tests { .with_body(serde_json::to_string(&anchor_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - let result = verify_anchor(&client, &server.url(), "tampered-13-tx", &db, &node_did).await; let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); assert!( @@ -1558,7 +1756,6 @@ mod tests { ); _mock.assert_async().await; } - /// Pins the 7-field signature-failure error push: a legacy cert (proof /// fields NULL) whose node signature was tampered must fail with the /// 7-field signature error. @@ -1566,13 +1763,11 @@ mod tests { async fn test_verify_anchor_rejects_tampered_7_field_signature() { let node_kp = gitlawb_core::identity::Keypair::generate(); let node_did = node_kp.did().as_str().to_string(); - let repo_id = "repo-uuid"; let ref_name = "refs/heads/main"; let old_sha = "0".repeat(40); let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; let issued_at = "2026-07-22T00:00:00+00:00"; - let payload = serde_json::json!({ "repo_id": repo_id, "ref": ref_name, @@ -1583,8 +1778,19 @@ mod tests { "ts": issued_at, }); let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); - let tampered_signature = format!("A{}", &signature[1..]); - + // Tamper: decode the b64url signature, flip one byte (guaranteed to + // change the value — unlike prefix replacement, which is a 1-in-64 + // no-op), and re-encode. + let tampered_signature = { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let mut bytes = URL_SAFE_NO_PAD + .decode(&signature) + .expect("signature should decode"); + bytes[0] ^= 0x01; + let tampered = URL_SAFE_NO_PAD.encode(&bytes); + assert_ne!(tampered, signature, "tamper must change the signature"); + tampered + }; let cert = crate::db::RefCertificate { id: "cert-tampered-7".to_string(), repo_id: repo_id.to_string(), @@ -1602,7 +1808,6 @@ mod tests { content_digest: None, request_path: None, }; - let anchor_json = serde_json::json!({ "repo_id": repo_id, "ref_name": ref_name, @@ -1611,7 +1816,6 @@ mod tests { "node_did": node_did, "certificate": cert, }); - let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/tampered-7-tx") @@ -1620,13 +1824,11 @@ mod tests { .with_body(serde_json::to_string(&anchor_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - let result = verify_anchor(&client, &server.url(), "tampered-7-tx", &db, &node_did).await; let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); assert!( @@ -1640,7 +1842,6 @@ mod tests { ); _mock.assert_async().await; } - /// A true end-to-end accept: a cert signed by a real node keypair over a /// real 13-field payload, with a real RFC 9421 pusher proof, served through /// a mock gateway, must verify to `valid: true` with empty errors. @@ -1669,7 +1870,6 @@ mod tests { .and_then(|s| s.strip_suffix(':')) .unwrap() .to_string(); - let payload = serde_json::json!({ "repo_id": repo_id, "ref": ref_name, @@ -1686,7 +1886,6 @@ mod tests { "request_path": request_path, }); let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); - crate::db::RefCertificate { id: "cert-accept-1".to_string(), repo_id: repo_id.to_string(), @@ -1705,7 +1904,6 @@ mod tests { request_path: Some(request_path.to_string()), } } - /// Run the current schema on a fresh `#[sqlx::test]` pool so DB-backed /// anchor tests share one seeding path. async fn migrated_db(pool: sqlx::PgPool) -> crate::db::Db { @@ -1713,13 +1911,11 @@ mod tests { db.run_migrations().await.expect("migrations should apply"); db } - #[sqlx::test] async fn test_verify_anchor_accepts_authentic_13_field_certificate(pool: sqlx::PgPool) { let node_kp = gitlawb_core::identity::Keypair::generate(); let node_did = node_kp.did().as_str().to_string(); let pusher_kp = gitlawb_core::identity::Keypair::generate(); - let owner_did = "did:key:z6MkOwner"; let repo_id = "repo-uuid"; let ref_name = "refs/heads/main"; @@ -1728,7 +1924,6 @@ mod tests { let issued_at = "2026-07-22T00:00:00+00:00"; let seq = 1i64; let prev = "0".repeat(64); - let db = migrated_db(pool).await; // Seed the repo so the outer identity corroboration actually runs // against a real row instead of being skipped by a lazy pool. @@ -1747,12 +1942,10 @@ mod tests { }) .await .unwrap(); - let cert = authentic_13_field_cert( &node_kp, &pusher_kp, repo_id, ref_name, &old_sha, new_sha, &node_did, issued_at, seq, &prev, ); - // The outer identity fields are present and must corroborate against // the seeded repo row: expected_repo = normalize_owner_key(owner) / name. let anchor_json = serde_json::json!({ @@ -1765,7 +1958,6 @@ mod tests { "node_did": node_did, "certificate": cert, }); - let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/accept-tx") @@ -1774,7 +1966,6 @@ mod tests { .with_body(serde_json::to_string(&anchor_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); let result = verify_anchor(&client, &server.url(), "accept-tx", &db, &node_did).await; let r = result.expect("verify_anchor should return Ok for a served anchor"); @@ -1790,7 +1981,6 @@ mod tests { ); _mock.assert_async().await; } - /// Fail closed: when the anchor carries outer `repo`/`owner_did` claims but /// the node has no record of the repo, corroboration cannot run — and the /// verdict must not rest on the certificate signature alone. @@ -1801,17 +1991,14 @@ mod tests { let node_kp = gitlawb_core::identity::Keypair::generate(); let node_did = node_kp.did().as_str().to_string(); let pusher_kp = gitlawb_core::identity::Keypair::generate(); - let owner_did = "did:key:zVictim"; let repo_id = "repo-uuid"; let ref_name = "refs/heads/main"; let old_sha = "0".repeat(40); let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; let issued_at = "2026-07-22T00:00:00+00:00"; - let db = migrated_db(pool).await; // Deliberately do NOT seed the repo row: the lookup must come up empty. - let cert = authentic_13_field_cert( &node_kp, &pusher_kp, @@ -1824,7 +2011,6 @@ mod tests { 1, &"0".repeat(64), ); - // Forged outer identity fields, no way to corroborate them. let anchor_json = serde_json::json!({ "repo": "victim-owner/victim-repo", @@ -1836,7 +2022,6 @@ mod tests { "node_did": node_did, "certificate": cert, }); - let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/uncorroborated-tx") @@ -1845,7 +2030,6 @@ mod tests { .with_body(serde_json::to_string(&anchor_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); let result = verify_anchor(&client, &server.url(), "uncorroborated-tx", &db, &node_did).await; @@ -1863,7 +2047,6 @@ mod tests { ); _mock.assert_async().await; } - /// A tampered seq on an authentic legacy 7-field cert must fail: the /// 7-field signature does not cover seq/prev, so the node's stored row /// must be corroborated rather than accepting a blanket valid: true. @@ -1871,13 +2054,11 @@ mod tests { async fn test_verify_anchor_legacy_seq_tamper_fails_closed() { let node_kp = gitlawb_core::identity::Keypair::generate(); let node_did = node_kp.did().as_str().to_string(); - let repo_id = "repo-uuid"; let ref_name = "refs/heads/main"; let old_sha = "0".repeat(40); let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; let issued_at = "2026-07-22T00:00:00+00:00"; - // Sign the 7-field payload exactly as pre-PR nodes did. let payload_7 = serde_json::json!({ "repo_id": repo_id, @@ -1889,7 +2070,6 @@ mod tests { "ts": issued_at, }); let signature = node_kp.sign_b64(&serde_json::to_vec(&payload_7).unwrap()); - let cert = crate::db::RefCertificate { id: "cert-legacy-tamper".to_string(), repo_id: repo_id.to_string(), @@ -1907,7 +2087,6 @@ mod tests { content_digest: None, request_path: None, }; - let anchor_json = serde_json::json!({ "repo_id": repo_id, "ref_name": ref_name, @@ -1916,7 +2095,6 @@ mod tests { "node_did": node_did, "certificate": cert, }); - let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/legacy-tamper-tx") @@ -1925,13 +2103,11 @@ mod tests { .with_body(serde_json::to_string(&anchor_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://localhost/gitlawb_test_placeholder") .expect("lazy pool creation should not fail"); let db = crate::db::Db::for_testing(pool); - // The cert is not present in the (lazy) node database — no stored row // matches its signed (repo_id, ref_name, old_sha, new_sha, ts), so the // legacy corroboration must fail closed instead of returning valid. @@ -1952,7 +2128,6 @@ mod tests { ); _mock.assert_async().await; } - /// The legacy corroboration must key on the fields the 7-field signature /// actually covers — never on `id`, which appears in no signed payload. /// A forged cert that copies `id`/`seq`/`prev` from a stored row at seq 7 @@ -1967,7 +2142,6 @@ mod tests { let node_did = node_kp.did().as_str().to_string(); let db = crate::db::Db::for_testing(pool.clone()); db.run_migrations().await.expect("migrations should apply"); - // Build a full stored chain seq 1..7 for the repo so every chain check // the forged cert must survive (prev-linkage against seq-1, predecessor // lookups) has a real row to pass against. Each cert's `prev` is the @@ -2017,7 +2191,6 @@ mod tests { } } let stored_seq_7 = stored_at_seq_7.expect("seq-7 cert was inserted"); - // The forged anchor: signed tuple says the transition (repo, ref, // forged_old, forged_new, forged_ts) — a DIFFERENT, never-recorded // transition — but id/seq/prev are copied verbatim from the seq-7 @@ -2038,7 +2211,6 @@ mod tests { "ts": forged_ts, }); let forged_signature = forged_kp.sign_b64(&serde_json::to_vec(&forged_payload).unwrap()); - let forged_cert = crate::db::RefCertificate { id: stored_seq_7.id.clone(), repo_id: repo_id.to_string(), @@ -2056,7 +2228,6 @@ mod tests { content_digest: None, request_path: None, }; - let anchor_json = serde_json::json!({ "repo_id": repo_id, "ref_name": ref_name, @@ -2065,7 +2236,6 @@ mod tests { "node_did": forged_did, "certificate": forged_cert, }); - let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/forged-borrowed-position-tx") @@ -2074,7 +2244,6 @@ mod tests { .with_body(serde_json::to_string(&anchor_json).unwrap()) .create_async() .await; - let client = reqwest::Client::new(); // Verify as the forger's own node: node_did, the issuer check, the // outer-field cross-check, the signature, and the chain-position diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 1fd44839..53a764ce 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -139,6 +139,19 @@ pub struct Config { )] pub bundler_account: String, + /// Irys payment-token slug billed for uploads (e.g. "matic", "ethereum", + /// "solana", "usdc" — see the Irys devnet faucet). Irys serves uploads at + /// `/tx/{token}` and reads the funded address from the `x-irys-paid-by` + /// header, so when `bundler_url` is set this must name the token the + /// funded account holds. `validate()` refuses to start without it. + #[arg( + long, + env = "GITLAWB_BUNDLER_TOKEN", + default_value = "", + alias = "irys-token" + )] + pub bundler_token: String, + /// Arweave gateway URL for resolving arweave_tx_id to data items. /// Used by the verify endpoint. Default: https://arweave.net #[arg( @@ -648,11 +661,23 @@ impl Config { return Err( "GITLAWB_BUNDLER_URL is set but GITLAWB_BUNDLER_ACCOUNT is not: the data item \ signature is not bundler payment. Create a funded account for this node (top up \ - via the bundler's faucet for devnet hosts) and set GITLAWB_BUNDLER_ACCOUNT to its \ + via the bundler's devnet faucet for devnet hosts) and set GITLAWB_BUNDLER_ACCOUNT to its \ address/identity, or clear GITLAWB_BUNDLER_URL to disable anchoring." .to_string(), ); } + // Irys uploads are billed against a payment token at /tx/{token}; the + // header the node sends is pointless if the operator has not said which + // token the funded account holds. + if !self.bundler_url.trim().is_empty() && self.bundler_token.trim().is_empty() { + return Err( + "GITLAWB_BUNDLER_URL is set but GITLAWB_BUNDLER_TOKEN is not: Irys bills uploads \ + against a payment token at /tx/{token} and reads x-irys-paid-by for the funded \ + address. Set GITLAWB_BUNDLER_TOKEN to the token the funded account holds (e.g. \ + 'matic' on the Irys devnet), or clear GITLAWB_BUNDLER_URL to disable anchoring." + .to_string(), + ); + } Ok(()) } } @@ -1046,8 +1071,9 @@ mod tests { /// Anchoring is paid, not free: the ANS-104 signature proves authorship, /// and the bundler bills the funded account the upload names. A bundler URL - /// without a declared funded account must refuse to start, or every anchor - /// silently fails with "Not enough balance" behind a push-time warning. + /// without a declared funded account and payment token must refuse to start, + /// or every anchor silently fails with "Not enough balance" behind a + /// push-time warning. #[test] fn bundler_url_requires_a_funded_account() { // Defaults (no bundler) validate. @@ -1066,16 +1092,89 @@ mod tests { "error must name the missing account: {err}" ); - // URL plus account validates. + // Account without a payment token must still be rejected: Irys bills + // at /tx/{token}, so the header alone cannot be charged. + let no_token = Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + ]); + let err = no_token + .validate() + .expect_err("bundler URL with an account but no token must be rejected"); + assert!( + err.contains("GITLAWB_BUNDLER_TOKEN"), + "error must name the missing token: {err}" + ); + + // URL plus account plus token validates. Config::parse_from([ "gitlawb-node", "--bundler-url", "https://devnet.irys.xyz", "--bundler-account", "zBundlerAccount", + "--bundler-token", + "matic", ]) .validate() - .expect("bundler URL with a funded account must validate"); + .expect("bundler URL with a funded account and token must validate"); + } + + /// The shipped `.env.example` must stay startable. Anchoring is paid, and + /// `validate()` refuses a bundler URL without both a funded account and a + /// payment token, so the example must never ship a non-empty + /// `GITLAWB_BUNDLER_URL` that the file itself does not also back with a + /// `GITLAWB_BUNDLER_ACCOUNT` and `GITLAWB_BUNDLER_TOKEN`. The app has no + /// dotenv loader, so this test keys on the file's active (non-commented) + /// lines the way a user `source`-ing the example would. + #[test] + fn env_example_bundler_block_is_startable() { + let example_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.env.example"); + let contents = std::fs::read_to_string(&example_path).unwrap_or_else(|e| { + panic!("cannot read shipped .env.example at {example_path:?}: {e}") + }); + + let active = |key: &str| -> String { + contents + .lines() + .map(str::trim) + .find(|l| l.starts_with(key) && !l.starts_with('#')) + .map(|l| l[key.len()..].trim().to_string()) + .unwrap_or_default() + }; + + let url = active("GITLAWB_BUNDLER_URL="); + let account = active("GITLAWB_BUNDLER_ACCOUNT="); + let token = active("GITLAWB_BUNDLER_TOKEN="); + if !url.is_empty() { + assert!( + !account.is_empty(), + ".env.example sets GITLAWB_BUNDLER_URL but no active GITLAWB_BUNDLER_ACCOUNT" + ); + assert!( + !token.is_empty(), + ".env.example sets GITLAWB_BUNDLER_URL but no active GITLAWB_BUNDLER_TOKEN" + ); + } + + // Whatever the example ships, it must be a shape `validate()` accepts, so a + // user who exports the example as-is can start the node. + let args = [ + "gitlawb-node", + "--bundler-url", + &url, + "--bundler-account", + &account, + "--bundler-token", + &token, + ]; + Config::parse_from(args) + .validate() + .unwrap_or_else(|e| panic!("the shipped .env.example must be startable: {e}")); } /// #247: an explicit `--arweave-gateway` must not be overwritten by the diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 58e73e69..9e62d3ac 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -473,7 +473,9 @@ impl Db { // is added by migration v18 as ALTER TABLE; signature_input, content_digest, // and request_path are added by v19. New installs reach v18/v19 via sequential // migration; existing installs with the columns already present are no-ops via -// IF NOT EXISTS. +// IF NOT EXISTS. v20 drops the superseded (repo_id, ref_name) unique index that +// v1 bundled; that drop is one-way and rollback-unsupported (see the +// migration's own comment). // // Each migration runs in a single transaction, so statements that Postgres // forbids inside a transaction (notably `CREATE INDEX CONCURRENTLY`) cannot be @@ -973,6 +975,14 @@ const MIGRATIONS: &[Migration] = &[ Migration { version: 20, name: "drop_ref_certs_repo_ref_unique", + // ONE-WAY, ROLLBACK-UNSUPPORTED: this drops the unique index that v1 + // bundled. Rolling back to v19 would require re-creating + // `idx_ref_certs_repo_ref`, which a release built at v20+ cannot do + // (the migration that created it has been superseded). Operators must + // treat v20 as terminal: there is no supported downgrade past it. The + // drop itself is the point of the migration — the old index would + // reject the second cert insert for a ref, which the append-only cert + // chain (v19) requires. stmts: &[ // Remove the superseded (repo_id, ref_name) unique index. v19 makes // the cert chain append-only, which requires multiple rows per diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 07b03b2e..ac60997f 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -114,8 +114,10 @@ async fn main() -> Result<()> { tracing::info!( bundler_url = %crate::server::mask_credential_url(&config.bundler_url), bundler_account = %config.bundler_account, + bundler_token = %config.bundler_token, "arweave anchoring enabled; uploads billed to the funded bundler account \ - (the node's ANS-104 signature is authorship, not payment)" + at /tx/{{token}} via x-irys-paid-by (the node's ANS-104 signature is \ + authorship, not payment)" ); } diff --git a/crates/gitlawb-node/tests/inv22_gates.rs b/crates/gitlawb-node/tests/inv22_gates.rs index 32e23dd0..9a4d012e 100644 --- a/crates/gitlawb-node/tests/inv22_gates.rs +++ b/crates/gitlawb-node/tests/inv22_gates.rs @@ -414,29 +414,33 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() { ); } -/// #174 U5: the post-receive replication tail is spawned at the DURABILITY BOUNDARY, -/// which is the moment receive-pack returns success, not the end of the handler and -/// not after `guard.release()`. +/// #174 U5, #224 review: the post-receive work is detached at the DURABILITY +/// BOUNDARY, which is the moment receive-pack returns success, not the end of the +/// handler and not after `guard.release()`. As of #224 the handler spawns the +/// owned `post_receive_continuation` (record_push, trust score, certificates, +/// and the replication tail) at that boundary; everything below the spawn stays +/// in the cancellable request future, so anything the continuation is spawned +/// after is a window where a client disconnect drops that work while the pack +/// is already durable on disk. `guard.release()` is such a window: on success +/// it awaits the Tigris upload and then the advisory unlock. /// -/// The tail owes this push its pins, recovery copy, and announcements. Everything -/// below the spawn stays in the cancellable request future, so anything the tail is -/// spawned after is a window where a client disconnect drops that work while the pack -/// is already durable on disk. `guard.release()` is such a window: on success it -/// awaits the Tigris upload and then the advisory unlock. +/// The lower bound matters just as much as the upper one: `release` runs on +/// failure too, so an ungated spawn would fire for a push git rejected, pinning +/// and announcing a half-applied repo. Above `release` the `?` on +/// `receive_result` can no longer be what gates it, so the success check is +/// explicit and this gate binds it: the spawn must sit inside +/// `if push_succeeded`, and `release` must consume the same flag so the two +/// cannot drift apart. /// -/// The lower bound matters just as much as the upper one: `release` runs on failure -/// too, so an ungated spawn would fire for a push git rejected, pinning and announcing -/// a half-applied repo. Above `release` the `?` on `receive_result` can no longer be -/// what gates it, so the success check is explicit and this gate binds it: the spawn -/// must sit inside `if push_succeeded`, and `release` must consume the same flag so -/// the two cannot drift apart. +/// This is an ordering check rather than a cancellation-race test on purpose: +/// it is the companion to +/// `receive_pack_tail_survives_a_disconnect_during_release`, which drives the +/// actual disconnect through a parked `release`, and to +/// `post_receive_continuation_survives_handler_abort` (in `api/repos.rs`), +/// which drives the disconnect through the bookkeeping the continuation now +/// owns. Same instrument the F3 gate above uses. /// -/// This is an ordering check rather than a cancellation-race test on purpose: it is -/// the companion to `receive_pack_tail_survives_a_disconnect_during_release`, which -/// drives the actual disconnect through a parked `release`. Same instrument the F3 -/// gate above uses. -/// -/// MUTATION (RED): move the `tokio::spawn(post_receive_replication_tail` call below +/// MUTATION (RED): move the `tokio::spawn(post_receive_continuation` call below /// `guard.release(` and the ordering assertion fails; take it out of the /// `if push_succeeded` block and the failed-push assertion fails. #[test] @@ -457,8 +461,8 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() { .find("if push_succeeded {") .expect("U5 gate missing: the tail spawn must be gated on the push having succeeded"); let spawn = production - .find("tokio::spawn(post_receive_replication_tail(") - .expect("U5 gate missing: the replication tail must be spawned by git_receive_pack"); + .find("tokio::spawn(post_receive_continuation(") + .expect("U5 gate missing: the post-receive continuation must be spawned by git_receive_pack"); let release = production .find("guard.release(push_succeeded)") .expect("U5 gate stale: release must consume the same success flag as the tail gate"); @@ -471,19 +475,19 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() { assert!( success_flag < gate_open && gate_open < spawn, - "U5 gate bypassed: the tail must be spawned inside `if push_succeeded`, or a \ - rejected push spawns a tail that pins and announces a half-applied repo" + "U5 gate bypassed: the continuation must be spawned inside `if push_succeeded`, \ + or a rejected push spawns a tail that pins and announces a half-applied repo" ); // Still inside that block: no `}` may close it between the gate and the spawn. assert!( !production[gate_open + "if push_succeeded {".len()..spawn].contains('}'), - "U5 gate bypassed: the tail spawn left the `if push_succeeded` block, so a \ + "U5 gate bypassed: the continuation spawn left the `if push_succeeded` block, so a \ rejected push now spawns a tail" ); assert!( spawn < release && spawn < touch && spawn < webhook, - "U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \ - and the webhook fan-out, so a disconnect in any of those windows cannot drop \ - this push's pins, recovery copy, and announcements" + "U5 gate bypassed: the continuation must be spawned BEFORE guard.release, \ + touch_repo and the webhook fan-out, so a disconnect in any of those windows \ + cannot drop this push's pins, recovery copy, and announcements" ); } From 4b2aeb4ba692f683c541c333ba751c18c7af4be7 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 14 Aug 2026 13:43:41 +0600 Subject: [PATCH 21/25] style(node): rustfmt the U5 ordering gate --- crates/gitlawb-node/tests/inv22_gates.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/tests/inv22_gates.rs b/crates/gitlawb-node/tests/inv22_gates.rs index 9a4d012e..fa600686 100644 --- a/crates/gitlawb-node/tests/inv22_gates.rs +++ b/crates/gitlawb-node/tests/inv22_gates.rs @@ -462,7 +462,9 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() { .expect("U5 gate missing: the tail spawn must be gated on the push having succeeded"); let spawn = production .find("tokio::spawn(post_receive_continuation(") - .expect("U5 gate missing: the post-receive continuation must be spawned by git_receive_pack"); + .expect( + "U5 gate missing: the post-receive continuation must be spawned by git_receive_pack", + ); let release = production .find("guard.release(push_succeeded)") .expect("U5 gate stale: release must consume the same success flag as the tail gate"); From 6bf13d1e1b095b90ecd1c950c9a70be7b1072dd4 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sat, 15 Aug 2026 13:56:20 +0600 Subject: [PATCH 22/25] fix(node): durable post-receive jobs, restart recovery, ANS-104 flat tags, funded-account bundler docs Post-receive work becomes durable: git_receive_pack persists a post_receive_jobs row (id, pusher DID, repo, ref updates, RFC 9421 attestation) BEFORE acking the push, so a crash between the pack landing and the bookkeeping (record_push, trust score, certs, replication tail) is recoverable instead of dropping a durable push with no record. Startup drains rows a previous process left processing/failed and replays them; every effect is idempotent so a replay is safe: - push_events is keyed on the job id (ON CONFLICT (id) DO NOTHING) so a replay never double-counts the push - certificate ids are deterministic per (job, ref) and insert_ref_certificate_tx is idempotent, so a replay cannot mint a second certificate - the Arweave anchor upload is gated on an existence check for the exact transition, so a replay cannot write a second permanent artifact Also lands the reviewed ANS-104 flat-tags preimage (arbundles getSignatureData semantics, empty-tag reference vector), legacy GITLAWB_IRYS_URL adoption gated on the funded account/token pair, the centralized redaction boundary for bundler credentials in errors, the immutable-v1 migration note, and the README GITLAWB_BUNDLER_ACCOUNT/GITLAWB_BUNDLER_TOKEN rows. Refs #224 --- README.md | 2 + crates/gitlawb-node/src/ans104.rs | 147 +++---- crates/gitlawb-node/src/api/repos.rs | 453 +++++++++++++++++----- crates/gitlawb-node/src/arweave.rs | 301 +++++++++++++- crates/gitlawb-node/src/cert.rs | 18 +- crates/gitlawb-node/src/config.rs | 60 +++ crates/gitlawb-node/src/db/mod.rs | 278 ++++++++++++- crates/gitlawb-node/src/git/repo_store.rs | 8 +- crates/gitlawb-node/src/main.rs | 57 ++- crates/gitlawb-node/tests/inv22_gates.rs | 87 +++-- 10 files changed, 1144 insertions(+), 267 deletions(-) diff --git a/README.md b/README.md index 65891640..0adbe812 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,8 @@ Important node settings: | `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. | | `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. | | `GITLAWB_BUNDLER_URL` | Bundler URL for Arweave permanent anchoring (e.g., https://devnet.irys.xyz). Leave empty to disable. (Legacy name: `GITLAWB_IRYS_URL`). | +| `GITLAWB_BUNDLER_ACCOUNT` | Irys bundler account (public address) for Arweave permanent anchoring. Must be set together with `GITLAWB_BUNDLER_TOKEN` for anchoring to enable. | +| `GITLAWB_BUNDLER_TOKEN` | Irys bundler token (API key) for Arweave permanent anchoring. Sent as the `x-irys-paid-by` header with the account. Must be set together with `GITLAWB_BUNDLER_ACCOUNT` for anchoring to enable. | | `GITLAWB_ARWEAVE_GATEWAY` | Arweave gateway URL for resolving anchors (defaults to `https://arweave.net`). | | `GITLAWB_ARWEAVE_RATE_LIMIT` | Per-client-IP rate limit for the verify endpoint, requests per hour (defaults to 120; `0` disables). | diff --git a/crates/gitlawb-node/src/ans104.rs b/crates/gitlawb-node/src/ans104.rs index 46fe019c..4d2299fc 100644 --- a/crates/gitlawb-node/src/ans104.rs +++ b/crates/gitlawb-node/src/ans104.rs @@ -27,11 +27,13 @@ //! //! The signature covers `deepHash(["dataitem", "1", type, owner, target, //! anchor, tags, data])` using the bundler deepHash (recursive length-tagged -//! SHA-384, identical to `@irys/arbundles`), so a bundler, gateway, or the -//! node itself can re-derive it from the item's own fields and verify against -//! the owner. Per ANS-104 the `tags` element of the preimage is a nested list -//! of `[tag.name, tag.value]` byte blobs — not the serialized tag stream — and -//! `deepHash` recurses into it exactly as a bundler does. +//! SHA-384, identical to the published `arbundles` package), so a bundler, +//! gateway, or the node itself can re-derive it from the item's own fields and +//! verify against the owner. The `tags` element is the FLAT serialized tag +//! stream (`item.rawTags` in `arbundles`' `getSignatureData`) — NOT a nested +//! list. The nested `[[name, value], ...]` form is what Arweave layer-one +//! transactions use; data items deep-hash the serialized tag blob. Zero tags is +//! an empty blob. use anyhow::Result; #[cfg(test)] @@ -80,23 +82,14 @@ pub fn build_signed_data_item( item.extend_from_slice(data); let signature_data = deep_hash(&[ - DeepHashChunk::Blob(b"dataitem"), - DeepHashChunk::Blob(b"1"), - DeepHashChunk::Blob(SIGNATURE_TYPE_ED25519.to_string().as_bytes()), - DeepHashChunk::Blob(&owner), - DeepHashChunk::Blob(&[]), - DeepHashChunk::Blob(&[]), - DeepHashChunk::List( - tags.iter() - .map(|(n, v)| { - DeepHashChunk::List(vec![ - DeepHashChunk::Blob(n.as_bytes()), - DeepHashChunk::Blob(v.as_bytes()), - ]) - }) - .collect(), - ), - DeepHashChunk::Blob(data), + b"dataitem", + b"1", + SIGNATURE_TYPE_ED25519.to_string().as_bytes(), + &owner, + &[], + &[], + &serialized_tags, + data, ]); let signature = keypair.sign(&signature_data).to_bytes(); item[2..2 + SIGNATURE_LEN].copy_from_slice(&signature); @@ -178,14 +171,14 @@ pub fn verify_data_item( } let signature_data = deep_hash(&[ - DeepHashChunk::Blob(b"dataitem"), - DeepHashChunk::Blob(b"1"), - DeepHashChunk::Blob(signature_type.to_string().as_bytes()), - DeepHashChunk::Blob(&owner), - DeepHashChunk::Blob(raw_target), - DeepHashChunk::Blob(raw_anchor), - DeepHashChunk::List(tags_preimage(&tags)), - DeepHashChunk::Blob(raw_data), + b"dataitem", + b"1", + signature_type.to_string().as_bytes(), + &owner, + raw_target, + raw_anchor, + raw_tags, + raw_data, ]); let sig = ed25519_dalek::Signature::from_bytes(&signature); verifying_key @@ -200,25 +193,18 @@ pub fn verify_data_item( }) } -/// One element of a `deepHash` list: a byte blob or a nested list (recursion is -/// required for the ANS-104 tags preimage element). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DeepHashChunk<'a> { - Blob(&'a [u8]), - List(Vec>), -} - -/// The bundler's recursive `deepHash` over the data item's signature fields, -/// byte-for-byte identical to `@irys/arbundles` `deepHash` (decimal-ASCII -/// length tags, chained SHA-384 for lists, seeded by SHA-384("list"); blobs -/// hashed as SHA-384(SHA-384("blob") || SHA-384(data))). -pub fn deep_hash(elems: &[DeepHashChunk]) -> [u8; 48] { +/// The bundler's `deepHash` over the data item's signature fields, +/// byte-for-byte identical to the published `arbundles` `deepHash` for the +/// all-blob preimage a data item uses: seeded by SHA-384("list") over the +/// element count, then each element chained as SHA-384(acc || blob-chunk) +/// where a blob-chunk is SHA-384(SHA-384("blob") || SHA-384(data)). The +/// bundler also recurses for nested list elements, but a data item's signature +/// fields are all blobs (tags included — see the module docs), so no nesting +/// is needed here. +pub fn deep_hash(elems: &[&[u8]]) -> [u8; 48] { let mut acc = sha384(format!("list{}", elems.len()).as_bytes()); for elem in elems { - let chunk = match elem { - DeepHashChunk::Blob(data) => deep_hash_blob(data), - DeepHashChunk::List(children) => deep_hash(children), - }; + let chunk = deep_hash_blob(elem); let mut pair = [0u8; 96]; pair[..48].copy_from_slice(&acc); pair[48..].copy_from_slice(&chunk); @@ -234,28 +220,15 @@ fn deep_hash_blob(data: &[u8]) -> [u8; 48] { sha384(&tagged) } -/// ANS-104 tags preimage element: a nested list of `[name, value]` blobs. -/// Zero tags is an empty list, which `deepHash`s to SHA-384("list0") — a -/// different value than an empty blob, and the one a bundler recomputes. -#[cfg(test)] -fn tags_preimage(tags: &[(String, String)]) -> Vec> { - tags.iter() - .map(|(name, value)| { - DeepHashChunk::List(vec![ - DeepHashChunk::Blob(name.as_bytes()), - DeepHashChunk::Blob(value.as_bytes()), - ]) - }) - .collect() -} - fn sha384(data: &[u8]) -> [u8; 48] { let mut h = Sha384::new(); h.update(data); h.finalize().into() } -/// Avro-style tag encoding matching `@irys/arbundles` `serializeTags`. +/// Avro-style tag encoding matching the published `arbundles` `serializeTags`. +/// The serialized stream is the `tags` preimage element (`item.rawTags`), so a +/// bundler recomputes the signature from the exact bytes the item carries. /// /// For `n > 0` tags: zigzag-varint(n), then for each tag the zigzag-varint /// length + UTF-8 bytes of name and value, then a terminating zigzag-varint(0). @@ -368,42 +341,36 @@ mod tests { use super::*; use gitlawb_core::identity::Keypair; - /// Independent reference vector, generated with `@irys/arbundles` - /// `deepHash` (not the code under test) over the ANS-104 spec preimage. - /// Pins the deepHash wire format — decimal-ASCII length tags, recursive - /// list handling, chained SHA-384 — so an accidental divergence in the - /// length-tagging (e.g. reintroducing the old pairwise chaining) or in the - /// tags element turns this test red and every previously-signed anchor - /// would no longer verify. + /// Independent reference vector, generated with the published `arbundles` + /// package's `deepHash` (not the code under test) over the ANS-104 spec + /// preimage. Pins the deepHash wire format — decimal-ASCII length tags, + /// recursive list handling, chained SHA-384 — so an accidental divergence + /// in the length-tagging (e.g. reintroducing the old pairwise chaining) or + /// in the tags element turns this test red and every previously-signed + /// anchor would no longer verify. #[test] fn deep_hash_matches_independent_reference_vector() { let owner = [0x41u8; 32]; // Elements: "dataitem", "1", "2", owner, target, anchor, tags, data. - // 0 tags -> tags element is an EMPTY LIST (deepHash([]) = SHA384("list0")), - // which differs from an empty blob and matches what a bundler recomputes. - let hash = deep_hash(&[ - DeepHashChunk::Blob(b"dataitem"), - DeepHashChunk::Blob(b"1"), - DeepHashChunk::Blob(b"2"), - DeepHashChunk::Blob(&owner), - DeepHashChunk::Blob(&[]), - DeepHashChunk::Blob(&[]), - DeepHashChunk::List(vec![]), - DeepHashChunk::Blob(b"hi"), - ]); - let expected = "a6d558f6f16e49b5224dc59740ca570f68d6d7c0f0f4045aa29f6305d47b3f5820aaff1792e93fa184f1ac238438fad8"; + // 0 tags -> tags element is an EMPTY BLOB (deepHash([]) = SHA384("list0") + // would be a different value): data items hash the flat serialized tag + // stream, and an empty tag stream is zero bytes. + let hash = deep_hash(&[b"dataitem", b"1", b"2", &owner, &[], &[], &[], b"hi"]); + let expected = "98a0a3b931f9c5cc370e822ca06b6e9635f690f81979b70b6dfe92d0af3f601169b0d8dc72d518241e3caba7f9daad1d"; assert_eq!(hex::encode(hash), expected); } - /// Full-serialization interoperability fixture produced by an independent - /// implementation: `@irys/arbundles` `deepHash` over the spec preimage plus - /// Node's Ed25519 (`crypto.sign(null, ...)`), with NONEMPTY tags. Proves the - /// nested-list tags preimage and the binary layout interop with the real - /// bundler toolchain — a round trip through this module alone is not enough. + /// Full-serialization interoperability fixture produced by the published + /// `arbundles` package: `createData` + `sign` (its own `getSignatureData` + /// deepHash over the flat `item.rawTags`, plus its Ed25519 signer) with + /// NONEMPTY tags. Proves the flat-tags preimage and the binary layout + /// interop with the real bundler toolchain — a round trip through this + /// module alone is not enough, and the node's own signer must produce + /// items a bundler (and this verifier) accepts. #[test] fn verify_data_item_matches_independent_interop_fixture() { - let owner_hex = "192d13b846ce90f8b77461c47621cd3f5df04486dbe2d0e2cd5708e9b4c75d51"; - let item_hex = "0200002a34414b302c282969c75103927f5efe0b1f793605c47ba1e1057bf0ea70d580c2846305f96ef27d943a1d56e977d85e9e70fd20e65533dfa9a71a7c5e5705192d13b846ce90f8b77461c47621cd3f5df04486dbe2d0e2cd5708e9b4c75d5100000300000000000000420000000000000006104170702d4e616d650e6769746c617762085265706f18616c6963652f6d797265706f0c536368656d612a6769746c6177622f7265662d7570646174652f7631007b22736368656d61223a226769746c6177622f7265662d7570646174652f7631222c227265706f223a22616c6963652f6d797265706f227d"; + let owner_hex = "d520b4cc5001a7ce12d1aaad57d6fd8e4b1c7b9926f699e6f778fb69f7e6f98b"; + let item_hex = "0200611e031059cf0395a990a1cd59e7c73f877cd36a065795630f9d1858a111d34e9db705dd01b6e2dbf0f5bbe9d6f8d5111d420512f60b80b7dfa7448a83c22e0bd520b4cc5001a7ce12d1aaad57d6fd8e4b1c7b9926f699e6f778fb69f7e6f98b00000300000000000000420000000000000006104170702d4e616d650e6769746c617762085265706f18616c6963652f6d797265706f0c536368656d612a6769746c6177622f7265662d7570646174652f7631007b22736368656d61223a226769746c6177622f7265662d7570646174652f7631222c227265706f223a22616c6963652f6d797265706f227d"; let owner: [u8; 32] = hex::decode(owner_hex).unwrap().try_into().unwrap(); let item = hex::decode(item_hex).unwrap(); let key = ed25519_dalek::VerifyingKey::from_bytes(&owner).unwrap(); diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index f4b6713a..5a860a3e 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -2032,27 +2032,62 @@ pub async fn git_receive_pack( if push_succeeded { // Everything a landed push owes after git accepted the pack — record_push, // trust score, per-ref signed certificates, and the replication tail — runs - // in this owned continuation. It used to live here in the cancellable - // handler between `receive_pack` returning Ok and the tail spawn, so a - // client/proxy disconnect during those DB awaits dropped a durable push - // with no certificates and no tail. Spawned above `guard.release()` for - // the same reason the tail was: `release` is itself cancellable, and the - // pack has already landed on disk by now. The continuation takes neither - // the write lease nor the advisory lock, so it waits on nothing this - // handler still holds. - tokio::spawn(post_receive_continuation( - state.clone(), - record.clone(), - ref_updates.clone(), - disk_path.clone(), - did.to_string(), - PusherAttestation { + // inside a DURABLE POST-RECEIVE JOB, not a bare spawned task. The job is + // persisted BEFORE the response is returned: Tokio cancels spawned tasks on + // restart/shutdown, so a continuation spawned only in memory left the window + // open where a crash between the pack landing and the bookkeeping reaching + // record_push/cert dropped a durable push with no cert, accounting, anchor, + // or replication and no recovery record. Once the job row is durable the + // effects can be replayed idempotently after a crash (see + // `process_post_receive_job` and the startup drain in main). + // + // The enqueue itself is NOT skippable: if it fails, the pack is on disk but + // its post-receive work has no recovery record, so acknowledging the push + // would be a lie. Refuse the 200 (the client/operator can investigate) and + // release the lock exactly like the error path below does. The ordinary + // failure here is a DB write failing while other paths still work — rare, + // and returning 500 is the honest outcome; retrying the push will not + // re-derive the ref updates (git sees them as already applied), so the + // operator must treat a 500 as "the push landed but was not recorded". + let job = crate::db::PostReceiveJob { + id: Uuid::new_v4().to_string(), + pusher_did: did.to_string(), + owner_did: record.owner_did.clone(), + repo_name: record.name.clone(), + repo_id: record.id.clone(), + ref_updates: ref_updates + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation { sig: Some(pusher_sig.0.clone()), signature_input: Some(pusher_proof.signature_input.clone()), content_digest: Some(pusher_proof.content_digest.clone()), request_path: Some(pusher_proof.request_path.clone()), }, - )); + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + if let Err(e) = state.db.enqueue_post_receive_job(&job).await { + tracing::error!( + repo = %name, + err = %e, + "failed to persist post-receive job — the pack landed but its \ + bookkeeping has no recovery record; refusing the push success" + ); + guard.release(push_succeeded).await; + drop(lease); + return Err(AppError::Internal(anyhow::anyhow!( + "push landed but the node could not record its post-receive work" + ))); + } + tokio::spawn(process_post_receive_job(state.clone(), job)); } // Always release the advisory lock — even on error — to prevent stale locks @@ -2127,43 +2162,113 @@ pub async fn git_receive_pack( Ok(result) } -/// The pusher's RFC 9421 attestation, owned and detached with the continuation. -/// Flattened from the handler's `PusherSignature` / `PusherProof` extractors so -/// the continuation can issue per-ref certificates after the client is gone. -struct PusherAttestation { - sig: Option, - signature_input: Option, - content_digest: Option, - request_path: Option, +/// Deterministic certificate id for a (job, ref) pair (#224): the same job +/// replayed after a restart must mint the SAME id so `insert_ref_certificate_tx` +///'s `ON CONFLICT (id) DO NOTHING` turns the replay into a no-op instead of a +/// second certificate for the same transition. Any collision-resistant hash of +/// the job id + ref is sufficient; sha256 hex is used (the column is TEXT, not +/// a UUID type). +fn deterministic_cert_id(job_id: &str, ref_name: &str) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(b"post-receive/"); + h.update(job_id.as_bytes()); + h.update(b"/"); + h.update(ref_name.as_bytes()); + hex::encode(h.finalize()) } -/// The owned post-receive continuation (#224 review): everything a landed push -/// owes after git accepted the pack — the trust-score `record_push`, the -/// per-ref signed certificates, and the replication tail itself — runs in one -/// detached task. The handler spawns this immediately after `receive_pack` -/// returns Ok and before `guard.release()`, so a client/proxy disconnect after -/// the pack has landed can no longer cancel the bookkeeping or drop the tail -/// (previously they lived in the cancellable handler between receive and the -/// tail spawn). The continuation owns everything it reads and takes neither the -/// write lease nor the advisory lock. +/// Process a durable post-receive job (#224). Everything a landed push owes +/// after git accepted the pack — the trust-score `record_push`, the per-ref +/// signed certificates, and the replication tail — runs here, driven from the +/// `post_receive_jobs` row rather than from the request handler. The handler +/// enqueued the job BEFORE acking the push, so a crash or restart between the +/// pack landing and these effects is recovered by the startup drain in main, +/// which resets stale rows to `pending` and re-runs this function. Every effect +/// is idempotent, so a replay is safe: /// -/// Certificate issuance runs at the START of the continuation, so the tail -/// always has the per-ref signed certificates in hand: the gossip event carries -/// the real `cert_id`, and the Arweave anchor embeds the certificate itself. -/// Issuance fails open (errors are logged and skipped), so a cert outage -/// degrades to a cert-less announce rather than a dropped push. -async fn post_receive_continuation( - state: AppState, - record: RepoRecord, - ref_updates: Vec, - disk_path: std::path::PathBuf, - did: String, - attestation: PusherAttestation, -) { - // Collect certs keyed by ref_name so the anchoring loop below uses - // the correct per-update certificate rather than a repo-wide latest. - let mut ref_certs: std::collections::HashMap = - std::collections::HashMap::new(); +/// - `record_push_job` keys the `push_events` row on the job id with +/// `ON CONFLICT (id) DO NOTHING`, so a replay never double-counts the push. +/// - certificate ids are deterministic per (job, ref) (above), and +/// `insert_ref_certificate_tx` skips ids that already exist. +/// - the Arweave anchor upload is skipped when this exact transition already +/// has a recorded anchor (`arweave_anchor_exists`), so a replay does not mint +/// a second permanent on-chain artifact for the same transition. +/// - the replication tail re-announces, which is the same per-push per-ref +/// work the original run did — a replay is no worse than the original. +/// +/// The job's DB status marks progress (`processing` → `done`/`failed`); a +/// restart is the retry policy, matching the durable-queue pattern used +/// elsewhere. This task is spawned by the handler on success and by the startup +/// drain for every row a previous process left pending. +pub(crate) async fn process_post_receive_job(state: AppState, job: crate::db::PostReceiveJob) { + if let Err(e) = state + .db + .update_post_receive_job(&job.id, "processing", None) + .await + { + tracing::error!(job_id = %job.id, err = %e, "failed to mark post-receive job processing"); + } + + match run_post_receive_job(&state, &job).await { + Ok(()) => { + if let Err(e) = state + .db + .update_post_receive_job(&job.id, "done", None) + .await + { + tracing::error!(job_id = %job.id, err = %e, "failed to mark post-receive job done"); + } + } + Err(e) => { + tracing::error!(job_id = %job.id, err = %e, "post-receive job failed"); + if let Err(mark_err) = state + .db + .update_post_receive_job(&job.id, "failed", Some(&e.to_string())) + .await + { + tracing::error!( + job_id = %job.id, + err = %mark_err, + "failed to mark post-receive job failed" + ); + } + } + } +} + +/// The durable job's body, factored out of `process_post_receive_job` so the +/// status transitions above stay visible next to the work they bookend. +async fn run_post_receive_job( + state: &AppState, + job: &crate::db::PostReceiveJob, +) -> anyhow::Result<()> { + // The RepoRecord is re-read from the DB rather than captured: the durable + // job may run after a restart, when the handler's in-memory record is gone. + let record = state + .db + .get_repo_by_id(&job.repo_id) + .await? + .ok_or_else(|| anyhow::anyhow!("repo {} vanished for post-receive job", job.repo_id))?; + // The local copy the original push wrote is exactly what a replay should + // read. `local_path` never touches Tigris or the network (unlike + // `acquire_fresh`, which would re-download), and the job's repo was written + // locally by that push moments earlier. + let (_, disk_path) = state + .repo_store + .local_path(&job.owner_did, &job.repo_name)?; + + let ref_updates: Vec = job + .ref_updates + .iter() + .map(|u| RefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(); + + let did = &job.pusher_did; // Use the first new commit hash we parsed, fall back to timestamp let commit_hash = ref_updates @@ -2171,32 +2276,39 @@ async fn post_receive_continuation( .map(|u| u.new_sha.clone()) .unwrap_or_else(|| Utc::now().timestamp().to_string()); - let _ = state + // Idempotent accounting: the push_events row is keyed on the job id, so a + // startup replay of this job is a no-op rather than a double-counted push + // that would inflate the pusher's trust score. + state .db - .record_push(&did, &record.id, &commit_hash, 0) - .await; - if let Ok(push_count) = state.db.get_push_count(&did).await { + .record_push_job(&job.id, did, &record.id, &commit_hash, 0) + .await?; + if let Ok(push_count) = state.db.get_push_count(did).await { // 0.05 base (from registration) + 0.05 per push, capped at 1.0 // 1 push → 0.10, 5 pushes → 0.30, 19 pushes → 1.0 let new_score = (push_count as f64 * 0.05 + 0.05).min(1.0); - let _ = state.db.update_trust_score(&did, new_score).await; + let _ = state.db.update_trust_score(did, new_score).await; } // Issue a signed certificate for every ref this push advanced, each // carrying that ref's real old→new transition. A multi-ref push must // not collapse to a single cert covering only the first ref. + let mut ref_certs: std::collections::HashMap = + std::collections::HashMap::new(); for update in &ref_updates { + let cert_id = deterministic_cert_id(&job.id, &update.ref_name); match cert::issue_ref_certificate( - &state, + state, &record.id, &update.ref_name, &update.old_sha, &update.new_sha, - &did, - attestation.sig.clone(), - attestation.signature_input.clone(), - attestation.content_digest.clone(), - attestation.request_path.clone(), + did, + &cert_id, + job.attestation.sig.clone(), + job.attestation.signature_input.clone(), + job.attestation.content_digest.clone(), + job.attestation.request_path.clone(), ) .await { @@ -2210,7 +2322,47 @@ async fn post_receive_continuation( } } - post_receive_replication_tail(state, record, ref_updates, disk_path, did, ref_certs).await; + post_receive_replication_tail( + state.clone(), + record, + ref_updates, + disk_path, + did.to_string(), + ref_certs, + ) + .await; + Ok(()) +} + +/// Startup recovery (#224): replay post-receive jobs a previous process left +/// mid-flight. Called once from main right after the AppState is built, before +/// the HTTP listener serves traffic. +/// +/// Rows a previous process left `processing` or `failed` are reset to +/// `pending` — a fresh process has no in-flight jobs, so resetting is safe — +/// and every pending row is spawned through the same processor the handler +/// uses. Each effect is idempotent (`record_push_job` keys on the job id, +/// certificate ids are deterministic per (job, ref), the Arweave anchor is +/// gated on an existence check), so a replay completes exactly the work the +/// original run owed without double-counting or double-issuing. A drain that +/// errors out is logged; the unprocessed rows stay `pending` and are retried on +/// the next restart (the job table IS the retry policy). +pub(crate) async fn drain_post_receive_jobs(state: AppState) -> anyhow::Result { + state.db.reset_stale_post_receive_jobs().await?; + let pending = state.db.list_pending_post_receive_jobs().await?; + let count = pending.len(); + for job in pending { + tracing::info!( + job_id = %job.id, + repo_id = %job.repo_id, + "replaying post-receive job left by the previous process" + ); + tokio::spawn(process_post_receive_job(state.clone(), job)); + } + if count > 0 { + tracing::info!(jobs = count, "startup post-receive job drain scheduled"); + } + Ok(count) } /// The detached post-receive replication tail (#174 F2): everything a landed push @@ -2607,6 +2759,30 @@ async fn post_receive_replication_tail( continue; } let cert_id = cert.as_ref().map(|c| c.id.clone()); + // #224: a startup replay of this push's job would otherwise + // re-run this loop and upload a SECOND permanent artifact + // for the same transition. The anchor existence check makes + // the upload idempotent: the exact (repo, ref, old→new) + // transition already anchored skips the upload entirely + // (the recorded anchor, cert, and tx_id from the original + // run stand). Narrow TOCTOU window between check and upload + // is accepted: the node's own anchor is single-flight per + // transition in practice, and the duplicate would at worst + // be an extra on-chain artifact, not data corruption. + if db_clone + .arweave_anchor_exists(&repo_slug, ref_name, old_sha, new_sha) + .await + .unwrap_or(false) + { + tracing::debug!( + repo = %repo_slug, + ref_name, + old_sha, + new_sha, + "skipping arweave anchor — transition already anchored" + ); + continue; + } let anchor = crate::arweave::RefAnchor { repo: repo_slug.clone(), repo_id: record.id.clone(), @@ -9866,33 +10042,39 @@ mod tests { ); } - // ---- #224 review, P1: the continuation survives a disconnect after the pack lands ---- + // ---- #224 review, P1: the durable post-receive job survives a crash ---- - /// The owned post-receive continuation survives a client/proxy disconnect - /// after git accepted the pack. + /// A post-receive job enqueued by the handler survives the handler being + /// aborted (a client/proxy disconnect — or, harder, a process crash) between + /// the pack landing and the job's bookkeeping running. /// /// Before the fix, `record_push`, the trust-score update, and the per-ref /// certificate issuance ran in the CANCELLABLE handler between `receive_pack` /// returning Ok and the tail spawn; a disconnect during those DB awaits - /// dropped a durable push with no certificates and no tail. The fix spawns - /// `post_receive_continuation` (which owns that bookkeeping and then runs the - /// replication tail) before the handler does anything else cancellable. + /// dropped a durable push with no certificates and no tail. The fix makes the + /// job DURABLE: the handler persists the job row BEFORE acking the push, and + /// the startup drain replays rows a previous process left pending. /// - /// This test drives the fix's exact shape: a simulated handler spawns the - /// continuation, the simulated handler is aborted mid-flight (the disconnect), - /// and the continuation must still run to completion — the push row, the - /// trust score, the per-ref certificate, and the tail's withheld walk all - /// land. The tail's walk is asserted on the same git shim the F2a suite uses. + /// This test drives the hardest shape of that fix: the simulated handler + /// enqueues the job, then is aborted BEFORE it even spawns the processor — + /// the crash-between-enqueue-and-spawn window. The startup drain + /// (`reset_stale_post_receive_jobs` + replay each pending row) must recover + /// it completely: the push row, the trust score, the per-ref certificate, and + /// the tail's withheld walk all land. Running the drain a second time must + /// not double-count the push (idempotent replay). #[cfg(unix)] #[sqlx::test] - async fn post_receive_continuation_survives_handler_abort(pool: sqlx::PgPool) { - let repo = tempfile::TempDir::new().unwrap(); + async fn post_receive_job_survives_handler_abort(pool: sqlx::PgPool) { let bin = tempfile::TempDir::new().unwrap(); - u5_init_repo(repo.path()); - let c1 = u5_commit_file(repo.path(), "a.txt", "one\n"); let log = bin.path().join("git.log"); let git_bin = f2a_logging_git(bin.path(), &log); - let (state, rec) = f2a_state(pool, &git_bin, "z6abort", "c1", true).await; + let (mut state, rec) = f2a_state(pool.clone(), &git_bin, "z6abort", "c1", true).await; + // Point the store at a per-run temp dir: the shared `for_testing` /tmp + // layout persists between test runs, and a stale repo dir makes the + // fixture's `git commit` a no-op ("nothing to commit"). + let repos_dir = tempfile::TempDir::new().unwrap(); + state.repo_store = + crate::git::repo_store::RepoStore::for_testing(repos_dir.path().to_path_buf(), pool); // The trust-score update only mutates an existing agents row (never // inserts); register the pusher so the update is observable. state @@ -9901,49 +10083,81 @@ mod tests { .await .unwrap(); - // Simulated handler: after `receive_pack` returned Ok it spawns the - // continuation, then it is still on the wire — the response has not been - // sent. The abort below is the disconnect. + // The durable job re-locates the repo via repo_store.local_path, so the + // repo must exist exactly where the store will look for it. + let (_, repo_path) = state + .repo_store + .local_path(&rec.owner_did, &rec.name) + .unwrap(); + std::fs::create_dir_all(&repo_path).unwrap(); + u5_init_repo(&repo_path); + let c1 = u5_commit_file(&repo_path, "a.txt", "one\n"); + + let update = f2a_update("refs/heads/main", &c1); + let job = crate::db::PostReceiveJob { + id: uuid::Uuid::new_v4().to_string(), + pusher_did: F2A_PUSHER.to_string(), + owner_did: rec.owner_did.clone(), + repo_name: rec.name.clone(), + repo_id: rec.id.clone(), + ref_updates: update + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation::default(), + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + + // Simulated handler: after `receive_pack` returned Ok it persists the + // job (the durability boundary) — then, BEFORE spawning the processor, + // it is aborted: the crash-between-enqueue-and-spawn window. The startup + // drain is the only thing that can recover this job. let (sent, received) = tokio::sync::oneshot::channel(); + let job_for_handler = job.clone(); let handler_sim = tokio::spawn({ let state = state.clone(); - let rec = rec.clone(); - let disk = repo.path().to_path_buf(); - let update = f2a_update("refs/heads/main", &c1); async move { - let cont = tokio::spawn(post_receive_continuation( - state, - rec, - update, - disk, - F2A_PUSHER.to_string(), - PusherAttestation { - sig: None, - signature_input: None, - content_digest: None, - request_path: None, - }, - )); - let _ = sent.send(cont); + state + .db + .enqueue_post_receive_job(&job_for_handler) + .await + .unwrap(); + let _ = sent.send(()); std::future::pending::<()>().await } }); - let cont = received.await.expect("handler spawned the continuation"); + received.await.expect("handler enqueued the job"); - // Give the continuation time to be mid-bookkeeping — the exact window the - // finding described — then sever the client. + // Sever the client: the handler never spawns the processor. tokio::time::sleep(std::time::Duration::from_millis(50)).await; handler_sim.abort(); let _ = handler_sim.await; - // The detached continuation must still finish its whole job. - cont.await - .expect("the continuation must run to completion after the handler is aborted"); + assert_eq!( + state.db.get_push_count(F2A_PUSHER).await.unwrap(), + 0, + "nothing has run yet — the job is pending and unprocessed" + ); + + // Startup drain: reset stale rows, then replay every pending row. + state.db.reset_stale_post_receive_jobs().await.unwrap(); + let pending = state.db.list_pending_post_receive_jobs().await.unwrap(); + assert_eq!(pending.len(), 1, "the enqueued job must be drained"); + for job in pending { + process_post_receive_job(state.clone(), job).await; + } assert_eq!( state.db.get_push_count(F2A_PUSHER).await.unwrap(), 1, - "the push must still be recorded after the disconnect" + "the push must still be recorded after the crash" ); assert!( (state.db.get_trust_score(F2A_PUSHER).await.unwrap() - 0.10).abs() < 1e-9, @@ -9953,15 +10167,40 @@ mod tests { assert_eq!( certs.len(), 1, - "the per-ref certificate must still be issued after the disconnect" + "the per-ref certificate must still be issued after the crash" ); assert_eq!(certs[0].ref_name, "refs/heads/main"); assert_eq!(certs[0].new_sha, c1); assert_eq!(certs[0].pusher_did, F2A_PUSHER); assert!( f2a_walks(&log) >= 1, - "the replication tail's withheld walk must still run after the disconnect; log:\n{}", + "the replication tail's withheld walk must still run after the crash; log:\n{}", f2a_log(&log) ); + + // Idempotent replay: the job is `done`, so a second drain finds nothing + // pending, and even a forced re-run of the processor does not double-count + // the push (push_events is keyed on the job id with ON CONFLICT DO NOTHING). + let pending = state.db.list_pending_post_receive_jobs().await.unwrap(); + assert!( + pending.is_empty(), + "a processed job must not be drained a second time" + ); + process_post_receive_job(state.clone(), job.clone()).await; + assert_eq!( + state.db.get_push_count(F2A_PUSHER).await.unwrap(), + 1, + "replaying the job must not double-count the push" + ); + assert_eq!( + state + .db + .list_ref_certificates(&rec.id, 10) + .await + .unwrap() + .len(), + 1, + "replaying the job must not mint a second certificate" + ); } } diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index d4d5b155..2b7d8a23 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -119,15 +119,18 @@ pub async fn anchor_ref_update( .body(data_item) .send() .await - .map_err(|e| { - // reqwest embeds the request URL verbatim; swap in the masked form. - let safe_err = e.to_string().replace(&url, &display_url); - anyhow::anyhow!("Bundler upload failed: {safe_err}") - })?; + .map_err(|e| remote_send_error("Bundler upload failed", &e, &url, &display_url))?; if !resp.status().is_success() { let status = resp.status(); - let body = truncate_for_error(&resp.text().await.unwrap_or_default(), 512); - return Err(anyhow::anyhow!("Bundler returned {status}: {body}")); + let body = resp.text().await.unwrap_or_default(); + return Err(remote_response_error( + "Bundler upload", + &status, + &body, + &url, + &display_url, + &[bundler_account, bundler_token], + )); } let json: serde_json::Value = resp .json() @@ -226,15 +229,18 @@ pub async fn anchor_encrypted_manifest( .body(data_item) .send() .await - .map_err(|e| { - // reqwest embeds the request URL verbatim; swap in the masked form. - let safe_err = e.to_string().replace(&url, &display_url); - anyhow::anyhow!("Bundler upload failed: {safe_err}") - })?; + .map_err(|e| remote_send_error("Bundler upload failed", &e, &url, &display_url))?; if !resp.status().is_success() { let status = resp.status(); - let body = truncate_for_error(&resp.text().await.unwrap_or_default(), 512); - return Err(anyhow::anyhow!("Bundler returned {status}: {body}")); + let body = resp.text().await.unwrap_or_default(); + return Err(remote_response_error( + "Bundler manifest upload", + &status, + &body, + &url, + &display_url, + &[bundler_account, bundler_token], + )); } let json: serde_json::Value = resp .json() @@ -329,6 +335,57 @@ fn truncate_for_error(s: &str, max: usize) -> String { out.push_str("…(truncated)"); out } +/// Central redaction boundary for every error that comes from a remote +/// endpoint the node talked to. reqwest embeds the request URL verbatim in its +/// error text, and a remote server can reflect anything the node sent — the +/// funded-account identity (`x-irys-paid-by`), the payment token riding in the +/// URL path, and any credentials in the base URL — back through an error or a +/// response body. Routing every such error through this module guarantees a raw +/// URL or a credential-bearing remote body never reaches a log (`err = %e`) or +/// a caller. +/// +/// `detail` is any string that may contain the raw URL or the secrets; the raw +/// URL is swapped for `display_url` (its credential-masked form) and each +/// non-empty secret is replaced with ``. +fn redact_remote_detail(detail: &str, url: &str, display_url: &str, secrets: &[&str]) -> String { + let mut out = detail.replace(url, display_url); + for secret in secrets { + if !secret.is_empty() { + out = out.replace(secret, ""); + } + } + out +} +/// Build the error for a remote request that failed before a response body was +/// available (connection refused, TLS failure, dropped stream). The reqwest +/// error text may embed the raw request URL, so it is masked and any secrets +/// scrubbed before the error is constructed. +fn remote_send_error( + prefix: &str, + err: &reqwest::Error, + url: &str, + display_url: &str, +) -> anyhow::Error { + let detail = redact_remote_detail(&err.to_string(), url, display_url, &[]); + anyhow::anyhow!("{prefix}: {detail}") +} +/// Build the error for a non-success response whose body the remote may have +/// populated by reflecting the request (including credential-bearing pieces). +/// The body is truncated, its raw URL swapped for the masked form, and the +/// secrets the node actually sent scrubbed — so a hostile bundler/gateway +/// cannot echo the operator's funded-account identity or payment token into +/// logs or an error surfaced to a caller. +fn remote_response_error( + prefix: &str, + status: &reqwest::StatusCode, + body: &str, + url: &str, + display_url: &str, + secrets: &[&str], +) -> anyhow::Error { + let body = truncate_for_error(&redact_remote_detail(body, url, display_url, secrets), 512); + anyhow::anyhow!("{prefix} returned {status}: {body}") +} /// Result of verifying an Arweave anchor against the stored certificate chain. #[derive(Debug, Clone, Serialize)] pub struct VerifyResult { @@ -368,13 +425,15 @@ pub async fn verify_anchor( let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => { - let safe_err = e.to_string().replace(&url, &display_url); - tracing::warn!("Arweave gateway connection failed: {safe_err}"); + let safe_err = + remote_send_error("Arweave gateway connection failed", &e, &url, &display_url) + .to_string(); + tracing::warn!("{safe_err}"); return Ok(VerifyResult { valid: false, anchor: serde_json::Value::Null, certificate: None, - errors: vec![format!("Arweave gateway connection failed: {safe_err}")], + errors: vec![safe_err], }); } }; @@ -394,11 +453,18 @@ pub async fn verify_anchor( let data = match chunk { Ok(d) => d, Err(e) => { + // Mid-stream transport errors carry the same risk as connection + // errors: reqwest can embed the raw request URL in the error + // text, so it is masked through the same boundary as above. + let safe_err = + remote_send_error("failed to read response body", &e, &url, &display_url) + .to_string(); + tracing::warn!("{safe_err}"); return Ok(VerifyResult { valid: false, anchor: serde_json::Value::Null, certificate: None, - errors: vec![format!("failed to read response body: {e}")], + errors: vec![safe_err], }); } }; @@ -2272,4 +2338,203 @@ mod tests { ); _mock.assert_async().await; } + /// A bundler that returns 500 with a body reflecting the request back — the + /// scenario a hostile or buggy endpoint uses to leak the credential-bearing + /// pieces (the `x-irys-paid-by` funded account and the payment token riding + /// in the path) through the error path. The error path must redact them. + async fn spawn_echoing_error_bundler() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = axum::Router::new().route( + "/tx/matic", + axum::routing::post( + move |uri: axum::http::Uri, headers: axum::http::HeaderMap| async move { + let paid_by = headers + .get("x-irys-paid-by") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + let target = uri.path_and_query().map(|q| q.as_str()).unwrap_or(""); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!(r#"{{"error":"rejected for {paid_by} at {target}"}}"#), + ) + }, + ), + ); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") + } + /// A non-success bundler response must not let the remote reflect the + /// credential-bearing request back into the error text: the funded-account + /// identity and the payment token are sent by the node, so a bundler that + /// echoes them (hostile or buggy) must be defeated by the redaction + /// boundary, not surfaced verbatim in logs or a caller-visible error. + #[tokio::test] + async fn test_anchor_ref_update_redacts_credentials_in_error_body() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let server = spawn_echoing_error_bundler().await; + let account = "zSecretFundedAccount"; + let result = anchor_ref_update( + &client, + &server, + account, + "matic", + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .await; + let err = result.expect_err("a 500 bundler response must fail the upload"); + let text = err.to_string(); + assert!( + text.contains("500"), + "error should carry the status: {text}" + ); + assert!( + !text.contains(account), + "funded account echoed by the bundler must be redacted: {text}" + ); + assert!( + !text.contains("matic"), + "payment token echoed by the bundler must be redacted: {text}" + ); + assert!( + text.contains(""), + "expected a redaction marker: {text}" + ); + } + /// The manifest upload path shares the same redaction boundary: a 500 body + /// that echoes the funded account and token must not reach the error text. + #[tokio::test] + async fn test_manifest_anchor_redacts_credentials_in_error_body() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let server = spawn_echoing_error_bundler().await; + let account = "zSecretFundedAccount"; + let blobs = vec![("oid1".to_string(), "cid1".to_string())]; + let m = EncryptedManifest { + repo: "alice/r", + owner_did: "did:key:zO", + node_did: "did:key:zN", + timestamp: "2026-06-11T00:00:00Z", + blobs: &blobs, + }; + let err = anchor_encrypted_manifest(&client, &server, account, "matic", &m, &kp) + .await + .expect_err("a 500 bundler response must fail the manifest upload"); + let text = err.to_string(); + assert!( + !text.contains(account), + "funded account must be redacted: {text}" + ); + assert!( + !text.contains("matic"), + "payment token must be redacted: {text}" + ); + assert!( + text.contains(""), + "expected a redaction marker: {text}" + ); + } + /// A gateway that announces a body it never delivers (headers promise + /// Content-Length, connection dropped mid-body) surfaces a mid-stream error. + /// That error must be rebuilt through the redaction boundary so a + /// credential-bearing gateway URL never leaks into the public VerifyResult. + #[tokio::test] + async fn test_verify_anchor_interrupted_stream_error_is_masked() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let _ = socket.read(&mut buf).await; + let _ = socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + content-length: 1000\r\n\r\n{\"certificate\":", + ) + .await; + // Drop the connection mid-body: the promised length is never + // delivered, forcing a stream error on the client. + drop(socket); + }); + } + }); + let gateway = format!("http://{addr}/?token=SECRET"); + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let r = verify_anchor(&client, &gateway, "txid", &db, "did:key:zNODE") + .await + .expect("verify_anchor should return Ok for a stream error"); + assert!(!r.valid); + let err_text = r.errors.join(" "); + assert!( + err_text.contains("failed to read response body"), + "expected a masked stream error, got: {err_text}" + ); + assert!( + !err_text.contains("SECRET"), + "gateway query token leaked through the stream error: {err_text}" + ); + } + /// The redaction helpers must scrub a raw URL (userinfo, token in the path) + /// and every secret the node sent out of an error string, and the scrub + /// must apply to remote bodies that reflect the request. + #[test] + fn redaction_helpers_scrub_urls_and_secrets() { + let url = "https://user:pw@example.invalid/tx/matic"; + let display = "https://***@example.invalid/tx/matic"; + let body = format!(r#"{{"error":"rejected for zFundedAccount at {url}"}}"#); + let err = remote_response_error( + "Bundler upload", + &StatusCode::INTERNAL_SERVER_ERROR, + &body, + url, + display, + &["zFundedAccount", "matic"], + ); + let text = err.to_string(); + assert!( + text.contains("Bundler upload returned 500"), + "error should carry prefix and status: {text}" + ); + assert!( + !text.contains("zFundedAccount"), + "funded account leaked: {text}" + ); + assert!(!text.contains("matic"), "payment token leaked: {text}"); + assert!(!text.contains("user:pw"), "URL userinfo leaked: {text}"); + assert!( + !text.contains("example.invalid/tx/matic"), + "raw URL leaked: {text}" + ); + assert!( + text.contains(""), + "expected a redaction marker: {text}" + ); + + // A reqwest-style detail that embeds the raw URL is masked through the + // same boundary (used for connection and mid-stream errors). + let detail = format!("error sending request for url ({url})"); + let detail = redact_remote_detail(&detail, url, display, &["matic"]); + assert!(!detail.contains("user:pw"), "URL userinfo leaked: {detail}"); + assert!(!detail.contains("matic"), "payment token leaked: {detail}"); + assert!( + detail.contains(""), + "expected a redaction marker: {detail}" + ); + } } diff --git a/crates/gitlawb-node/src/cert.rs b/crates/gitlawb-node/src/cert.rs index e8d2b79b..1d4e8933 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -3,7 +3,6 @@ use std::ops::DerefMut; use anyhow::Result; use chrono::Utc; use sha2::{Digest, Sha256}; -use uuid::Uuid; use crate::db::RefCertificate; use crate::state::AppState; @@ -57,7 +56,10 @@ fn prev_hash(c: &RefCertificate) -> Result { Ok(hex::encode(Sha256::digest(&prev_bytes))) } -/// Attempt a single cert-issuance within an active transaction. +/// Attempt a single cert-issuance within an active transaction. `cert_id` is +/// the certificate id: a deterministic per-(job, ref) value on the durable +/// post-receive job path so a startup replay is a no-op (see +/// [`Db::insert_ref_certificate_tx`]'s `ON CONFLICT (id) DO NOTHING`). #[allow(clippy::too_many_arguments)] async fn issue_once( state: &AppState, @@ -66,6 +68,7 @@ async fn issue_once( old_sha: &str, new_sha: &str, pusher_did: &str, + cert_id: &str, pusher_sig: &Option, signature_input: &Option, content_digest: &Option, @@ -102,7 +105,7 @@ async fn issue_once( let signature = state.node_keypair.sign_b64(&payload_bytes); let cert = RefCertificate { - id: Uuid::new_v4().to_string(), + id: cert_id.to_string(), repo_id: repo_id.to_string(), ref_name: ref_name.to_string(), old_sha: old_sha.to_string(), @@ -124,6 +127,12 @@ async fn issue_once( /// Issue a signed ref-update certificate for a successful push. /// +/// `cert_id` is the certificate id to use. The durable post-receive job path +/// passes a deterministic per-(job, ref) value so a startup replay re-issues +/// the SAME id and `insert_ref_certificate_tx`'s `ON CONFLICT (id) DO NOTHING` +/// makes it a no-op — a replayed push must not mint a second certificate for +/// the same transition. +/// /// Acquires a per-repo advisory lock to atomically allocate the chain /// sequence number within a single database transaction, preventing race /// conditions with concurrent pushes to the same repository. @@ -135,6 +144,7 @@ pub async fn issue_ref_certificate( old_sha: &str, new_sha: &str, pusher_did: &str, + cert_id: &str, pusher_sig: Option, signature_input: Option, content_digest: Option, @@ -156,6 +166,7 @@ pub async fn issue_ref_certificate( old_sha, new_sha, pusher_did, + cert_id, &pusher_sig, &signature_input, &content_digest, @@ -187,6 +198,7 @@ pub async fn issue_ref_certificate( old_sha, new_sha, pusher_did, + cert_id, &pusher_sig, &signature_input, &content_digest, diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 53a764ce..9f7c3f65 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -680,6 +680,31 @@ impl Config { } Ok(()) } + + /// Decide whether to adopt a legacy `GITLAWB_IRYS_URL` as the bundler URL. + /// + /// A bare URL no longer enables paid anchoring — uploads are billed to a + /// funded account via `x-irys-paid-by` at `/tx/{token}`, and `validate()` + /// refuses to start with a URL but no funded account/token. Adopting the + /// legacy value unconditionally would therefore break every deployment that + /// only ever set the URL. The legacy value is honored only when the operator + /// has opted into the new funded-account pair; otherwise `None` is returned + /// (anchoring stays disabled and the node starts, with a warning at the call + /// site). + pub fn legacy_bundler_url_fallback( + legacy_url: &str, + bundler_account: &str, + bundler_token: &str, + ) -> Option { + if legacy_url.is_empty() { + return None; + } + if !bundler_account.trim().is_empty() && !bundler_token.trim().is_empty() { + Some(legacy_url.to_string()) + } else { + None + } + } } #[cfg(test)] @@ -703,6 +728,41 @@ mod tests { ); } + #[test] + fn legacy_irys_url_is_adopted_only_with_funded_account_pair() { + // Full opt-in: legacy URL + the new funded-account pair -> adopted. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "0xabc", "matic"), + Some("https://devnet.irys.xyz".to_string()) + ); + // Legacy URL alone no longer enables anchoring: validate() would refuse + // to start, so the fallback stays disabled and the node boots. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "", ""), + None + ); + // Partial opt-in (account but no token, or vice versa) is also refused: + // both halves of the funded-account pair are required. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "0xabc", ""), + None + ); + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "", "matic"), + None + ); + // Whitespace-only account/token are not an opt-in. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", " ", " "), + None + ); + // Empty legacy value: nothing to adopt. + assert_eq!( + Config::legacy_bundler_url_fallback("", "0xabc", "matic"), + None + ); + } + /// #174 (RED-before/GREEN-after): the upper bound is what keeps every duration /// derived from this knob in range — the lease steal bound's `* 2 + 60` on the write /// path, and the `Instant::now() + Duration::from_secs(..)` deadlines in diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 9e62d3ac..f2f2a427 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -152,6 +152,57 @@ pub struct RefCertificate { pub request_path: Option, } +/// One ref transition a durable post-receive job owes, in a serde-friendly form +/// so it can be persisted in the `post_receive_jobs` JSONB column and replayed +/// after a crash. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobRefUpdate { + pub old_sha: String, + pub new_sha: String, + pub ref_name: String, +} + +/// The pusher's RFC 9421 attestation, persisted with the post-receive job so +/// per-ref certificates can be issued during a replay with the same proof the +/// original push carried. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PostReceiveAttestation { + pub sig: Option, + pub signature_input: Option, + pub content_digest: Option, + pub request_path: Option, +} + +/// A durable post-receive job (#224 review): everything a landed push owes +/// after git accepted the pack — trust-score `record_push`, per-ref signed +/// certificates, and the replication tail — with its inputs persisted BEFORE +/// the push is acknowledged. Tokio cancels spawned tasks on restart/shutdown, +/// so a push whose continuation task died before reaching the bookkeeping left +/// a durable ref update with no certificate, accounting, anchor, or replication +/// and no way to recover it. Persisting the job first makes that interval +/// recoverable: startup resets stale rows to `pending` and replays them, and +/// each effect is idempotent (`record_push` keys on the job id, certificates on +/// a deterministic per-(job, ref) id, the Arweave anchor on an existence +/// check), so a replay never double-counts, double-issues, or double-anchors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostReceiveJob { + pub id: String, + /// The DID that pushed — the signer of the RFC 9421 attestation, and the + /// subject of the trust-score `record_push`. Persisted because a startup + /// replay runs long after the handler that knew the caller is gone. + pub pusher_did: String, + pub owner_did: String, + pub repo_name: String, + pub repo_id: String, + pub ref_updates: Vec, + pub attestation: PostReceiveAttestation, + /// pending | processing | done | failed + pub status: String, + pub enqueued_at: String, + pub attempts: i64, + pub error: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PeerRecord { pub did: String, @@ -467,15 +518,17 @@ impl Db { // appended to v1. Operators can read `schema_migrations` to confirm a node // is at the expected version. // -// NOTE: the v1 migration includes columns (seq, prev, pusher_sig on -// ref_certificates) that were historically added by later migrations. These -// were bundled into v1 for development convenience. cert_id on arweave_anchors -// is added by migration v18 as ALTER TABLE; signature_input, content_digest, -// and request_path are added by v19. New installs reach v18/v19 via sequential -// migration; existing installs with the columns already present are no-ops via -// IF NOT EXISTS. v20 drops the superseded (repo_id, ref_name) unique index that -// v1 bundled; that drop is one-way and rollback-unsupported (see the -// migration's own comment). +// NOTE: the released v1 schema has NO cert-chain columns: `ref_certificates` +// carries only (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, +// signature, issued_at). The chain fields seq, prev, and pusher_sig are added +// by migration v18 (alongside `arweave_anchors.cert_id` and the +// `irys_tx_id` → `arweave_tx_id` rename); the proof columns +// signature_input, content_digest, and request_path are added by v19. +// New installs reach v18/v19 via sequential migration; existing installs with +// the columns already present are no-ops via IF NOT EXISTS. v20 drops the +// superseded (repo_id, ref_name) unique index that v1 bundled; that drop is +// one-way and rollback-unsupported (see the migration's own comment). +// v21 adds the durable post-receive job table. // // Each migration runs in a single transaction, so statements that Postgres // forbids inside a transaction (notably `CREATE INDEX CONCURRENTLY`) cannot be @@ -994,6 +1047,33 @@ const MIGRATIONS: &[Migration] = &[ "DROP INDEX IF EXISTS idx_ref_certs_repo_ref", ], }, + // Durable post-receive jobs (#224 review). Numbered 21: versions 12–16 are + // claimed by other in-flight branches, and 17 is main's prior max (18–20 + // are this same branch's earlier migrations; #173 renumbers before merge). + // The runner keys the applied set on the integer alone, so gaps are + // harmless. + Migration { + version: 21, + name: "durable_post_receive_jobs", + stmts: &[ + r#"CREATE TABLE IF NOT EXISTS post_receive_jobs ( + id TEXT NOT NULL PRIMARY KEY, + pusher_did TEXT NOT NULL, + owner_did TEXT NOT NULL, + repo_name TEXT NOT NULL, + repo_id TEXT NOT NULL, + ref_updates JSONB NOT NULL, + attestation JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + enqueued_at TEXT NOT NULL, + attempted_at TEXT, + processed_at TEXT, + error TEXT + )"#, + "CREATE INDEX IF NOT EXISTS idx_post_receive_jobs_status ON post_receive_jobs(status, enqueued_at)", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -1575,8 +1655,13 @@ impl Db { Ok(()) } - pub async fn record_push( + /// Idempotent `record_push` for the durable post-receive job path (#224): + /// the push event's `id` is the job id, so a replay of the same job is a + /// no-op (`ON CONFLICT (id) DO NOTHING`) instead of double-counting the + /// push — which would inflate the pusher's trust score. + pub async fn record_push_job( &self, + job_id: &str, agent_did: &str, repo_id: &str, commit_hash: &str, @@ -1584,9 +1669,10 @@ impl Db { ) -> Result<()> { sqlx::query( "INSERT INTO push_events (id, agent_did, repo_id, commit_hash, object_count, pushed_at) - VALUES ($1, $2, $3, $4, $5, $6)", + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO NOTHING", ) - .bind(Uuid::new_v4().to_string()) + .bind(job_id) .bind(agent_did) .bind(repo_id) .bind(commit_hash) @@ -1841,6 +1927,128 @@ impl Db { } } +// ── Durable post-receive jobs ───────────────────────────────────────────────── + +impl Db { + /// Persist a post-receive job BEFORE the push is acknowledged (#224): a + /// push whose detached continuation task is cancelled by a restart before + /// reaching record_push/cert/anchor would otherwise leave a durable ref + /// update with no bookkeeping and no recovery record. `ON CONFLICT (id) DO + /// NOTHING` makes a retried enqueue a no-op. + pub async fn enqueue_post_receive_job(&self, job: &PostReceiveJob) -> Result<()> { + sqlx::query( + "INSERT INTO post_receive_jobs + (id, pusher_did, owner_did, repo_name, repo_id, ref_updates, attestation, status, attempts, enqueued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 0, $8) + ON CONFLICT (id) DO NOTHING", + ) + .bind(&job.id) + .bind(&job.pusher_did) + .bind(&job.owner_did) + .bind(&job.repo_name) + .bind(&job.repo_id) + .bind(serde_json::to_value(&job.ref_updates)?) + .bind(serde_json::to_value(&job.attestation)?) + .bind(&job.enqueued_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Advance a job's status. `done` stamps `processed_at`; `processing` + /// stamps `attempted_at` and increments `attempts`. `failed` records the + /// error so operators can see why a job never completed. + pub async fn update_post_receive_job( + &self, + id: &str, + status: &str, + error: Option<&str>, + ) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let result = + match status { + "done" => sqlx::query( + "UPDATE post_receive_jobs SET status = 'done', processed_at = $1, error = NULL + WHERE id = $2", + ) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await?, + "failed" => { + sqlx::query( + "UPDATE post_receive_jobs SET status = 'failed', error = $1 + WHERE id = $2", + ) + .bind(error) + .bind(id) + .execute(&self.pool) + .await? + } + _ => { + sqlx::query( + "UPDATE post_receive_jobs SET status = $1, attempted_at = $2, + attempts = attempts + 1, error = NULL + WHERE id = $3", + ) + .bind(status) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await? + } + }; + if result.rows_affected() == 0 { + tracing::warn!(job_id = %id, status, "post-receive job not found for status update"); + } + Ok(()) + } + + /// Startup recovery (#224): every job that a previous process left + /// mid-flight (`processing`) or failed is reset to `pending` so the startup + /// drain replays it. A fresh process has no in-flight jobs, so resetting is + /// safe; a job that keeps failing stays `failed` between drains and its + /// error is preserved for operators until the next restart resets it. + pub async fn reset_stale_post_receive_jobs(&self) -> Result<()> { + sqlx::query( + "UPDATE post_receive_jobs SET status = 'pending', error = NULL + WHERE status IN ('processing', 'failed')", + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Pending jobs in enqueue order, for the startup drain. + pub async fn list_pending_post_receive_jobs(&self) -> Result> { + let rows = sqlx::query( + "SELECT id, pusher_did, owner_did, repo_name, repo_id, ref_updates, attestation, status, + attempts, enqueued_at, error + FROM post_receive_jobs + WHERE status = 'pending' + ORDER BY enqueued_at ASC, id ASC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| PostReceiveJob { + id: r.get("id"), + pusher_did: r.get("pusher_did"), + owner_did: r.get("owner_did"), + repo_name: r.get("repo_name"), + repo_id: r.get("repo_id"), + ref_updates: serde_json::from_value(r.get("ref_updates")).unwrap_or_default(), + attestation: serde_json::from_value(r.get("attestation")).unwrap_or_default(), + status: r.get("status"), + enqueued_at: r.get("enqueued_at"), + attempts: r.get::("attempts") as i64, + error: r.get("error"), + }) + .collect()) + } +} + // ── Pull Requests ───────────────────────────────────────────────────────────── impl Db { @@ -2181,10 +2389,17 @@ impl Db { cert: &RefCertificate, conn: &mut sqlx::postgres::PgConnection, ) -> Result { + // Idempotent insert (#224): a durable post-receive job re-issues its + // certificates with a deterministic per-(job, ref) id during a replay, + // so a re-run must not duplicate the row. `ON CONFLICT (id) DO NOTHING` + // returns no row for the already-inserted case; the existing row is + // then read back so the caller gets the certificate that actually + // landed (which, for a deterministic id, is the same one it computed). let row = sqlx::query( "INSERT INTO ref_certificates (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (id) DO NOTHING RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path", ) .bind(&cert.id) @@ -2202,9 +2417,19 @@ impl Db { .bind(&cert.signature_input) .bind(&cert.content_digest) .bind(&cert.request_path) + .fetch_optional(&mut *conn) + .await?; + if let Some(row) = row { + return Ok(row_to_cert(row)); + } + let existing = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE id = $1", + ) + .bind(&cert.id) .fetch_one(&mut *conn) .await?; - Ok(row_to_cert(row)) + Ok(row_to_cert(existing)) } pub async fn list_ref_certificates( @@ -3249,6 +3474,33 @@ impl Db { Ok(()) } + /// Whether this exact ref transition (same repo slug, ref, old→new SHAs) + /// already has a recorded Arweave anchor. The durable post-receive job + /// checks this BEFORE uploading, so a startup replay of an already-anchored + /// job skips the upload instead of writing a second permanent on-chain + /// artifact for the same transition (#224). + pub async fn arweave_anchor_exists( + &self, + repo: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + ) -> Result { + let row = sqlx::query( + "SELECT EXISTS( + SELECT 1 FROM arweave_anchors + WHERE repo = $1 AND ref_name = $2 AND old_sha = $3 AND new_sha = $4 + ) AS present", + ) + .bind(repo) + .bind(ref_name) + .bind(old_sha) + .bind(new_sha) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("present")) + } + pub async fn list_arweave_anchors( &self, repo: Option<&str>, diff --git a/crates/gitlawb-node/src/git/repo_store.rs b/crates/gitlawb-node/src/git/repo_store.rs index 2aef6ff0..77c74795 100644 --- a/crates/gitlawb-node/src/git/repo_store.rs +++ b/crates/gitlawb-node/src/git/repo_store.rs @@ -328,7 +328,13 @@ impl RepoStore { /// (or the prefix/root from `repos_dir`); any `ParentDir`/`CurDir` /// segment is rejected. This is the CodeQL-recognised barrier /// pattern for `rust/path-injection`. - fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> { + /// + /// Derive the local path for a repo without touching Tigris or the network. + /// Used by the durable post-receive job to re-locate a repo during a + /// startup replay, where the local copy written by the original push is + /// exactly what should be read. See also [`acquire`] / [`acquire_fresh`], + /// which download from Tigris first. + pub fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> { validate_path_components(owner_did, repo_name)?; let owner_slug = owner_did.replace([':', '/'], "_"); diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index ac60997f..c055b79f 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -72,12 +72,36 @@ async fn main() -> Result<()> { let mut config = Config::parse(); - // Fallback to legacy GITLAWB_IRYS_URL for backward compatibility during rename + // Fallback to legacy GITLAWB_IRYS_URL for backward compatibility during rename. + // A bare URL no longer enables paid anchoring: uploads are billed to a funded + // account via x-irys-paid-by at /tx/{token}, which this release introduced, + // so validate() below refuses to start with a URL but no account/token. + // Config::legacy_bundler_url_fallback therefore honors the legacy value only + // when the operator has opted into the new funded-account pair; otherwise we + // warn that the legacy URL alone leaves anchoring disabled and start anyway. if config.bundler_url.is_empty() { if let Ok(legacy) = std::env::var("GITLAWB_IRYS_URL") { - if !legacy.is_empty() { - config.bundler_url = legacy; - tracing::warn!("GITLAWB_IRYS_URL is deprecated, use GITLAWB_BUNDLER_URL instead"); + match Config::legacy_bundler_url_fallback( + &legacy, + &config.bundler_account, + &config.bundler_token, + ) { + Some(url) => { + config.bundler_url = url; + tracing::warn!( + "GITLAWB_IRYS_URL is deprecated, use GITLAWB_BUNDLER_URL instead" + ); + } + None if !legacy.is_empty() => { + tracing::warn!( + "GITLAWB_IRYS_URL is set but GITLAWB_BUNDLER_ACCOUNT and \ + GITLAWB_BUNDLER_TOKEN are not: a bundler URL alone no longer \ + enables anchoring (uploads are billed to a funded account via \ + x-irys-paid-by). Set the funded-account pair to enable it, or \ + use GITLAWB_BUNDLER_URL. Starting with anchoring disabled." + ); + } + None => {} } } } @@ -516,6 +540,31 @@ async fn main() -> Result<()> { tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled"); } + // #224: replay durable post-receive jobs a previous process left mid-flight. + // The receive-pack handler persists each push's job BEFORE acknowledging it, + // so a crash between the pack landing and the job's bookkeeping (record_push, + // certificates, anchor, replication) is recovered here on the next start — + // and the drained jobs are spawned before traffic is served, so no push can + // be acknowledged against a queue this process has not yet replayed. Each + // effect is idempotent, so a replay completes exactly the work owed without + // double-counting or double-issuing. + { + let drain_state = state.clone(); + match crate::api::repos::drain_post_receive_jobs(drain_state).await { + Ok(0) => {} + Ok(count) => { + info!("startup post-receive job drain found {count} job(s) to replay") + } + Err(e) => { + tracing::error!( + err = %e, + "startup post-receive job drain failed; unprocessed jobs stay queued \ + and are retried on the next restart" + ); + } + } + } + // Periodic peer-count poll for the metrics gauge. If p2p is disabled // we still set the gauge to 0 so dashboards don't show "no data". { diff --git a/crates/gitlawb-node/tests/inv22_gates.rs b/crates/gitlawb-node/tests/inv22_gates.rs index fa600686..0834e8af 100644 --- a/crates/gitlawb-node/tests/inv22_gates.rs +++ b/crates/gitlawb-node/tests/inv22_gates.rs @@ -416,33 +416,38 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() { /// #174 U5, #224 review: the post-receive work is detached at the DURABILITY /// BOUNDARY, which is the moment receive-pack returns success, not the end of the -/// handler and not after `guard.release()`. As of #224 the handler spawns the -/// owned `post_receive_continuation` (record_push, trust score, certificates, -/// and the replication tail) at that boundary; everything below the spawn stays -/// in the cancellable request future, so anything the continuation is spawned -/// after is a window where a client disconnect drops that work while the pack -/// is already durable on disk. `guard.release()` is such a window: on success -/// it awaits the Tigris upload and then the advisory unlock. +/// handler and not after `guard.release()`. Since #224 the handler persists the +/// push's post-receive JOB (`enqueue_post_receive_job` — record_push, trust +/// score, certificates, and the replication tail all run inside the job) at +/// that boundary and spawns `process_post_receive_job` to run it; everything +/// below the enqueue stays in the cancellable request future, so anything the +/// enqueue is after is a window where a client disconnect drops that work while +/// the pack is already durable on disk. `guard.release()` is such a window: on +/// success it awaits the Tigris upload and then the advisory unlock. /// /// The lower bound matters just as much as the upper one: `release` runs on -/// failure too, so an ungated spawn would fire for a push git rejected, pinning -/// and announcing a half-applied repo. Above `release` the `?` on +/// failure too, so an ungated enqueue would fire for a push git rejected, +/// pinning and announcing a half-applied repo. Above `release` the `?` on /// `receive_result` can no longer be what gates it, so the success check is -/// explicit and this gate binds it: the spawn must sit inside -/// `if push_succeeded`, and `release` must consume the same flag so the two -/// cannot drift apart. +/// explicit and this gate binds it: the enqueue AND the processor spawn must +/// sit inside `if push_succeeded`, the enqueue must come before the spawn (the +/// durable row is the job's recovery record, so the processor must never run +/// against an unpersisted job), and `release` must consume the same flag so the +/// gate and the release cannot drift apart. /// /// This is an ordering check rather than a cancellation-race test on purpose: /// it is the companion to /// `receive_pack_tail_survives_a_disconnect_during_release`, which drives the /// actual disconnect through a parked `release`, and to -/// `post_receive_continuation_survives_handler_abort` (in `api/repos.rs`), -/// which drives the disconnect through the bookkeeping the continuation now -/// owns. Same instrument the F3 gate above uses. +/// `post_receive_job_survives_handler_abort` (in `api/repos.rs`), which drives +/// the disconnect (and a crash-before-spawn) through the durable job the +/// handler persisted. Same instrument the F3 gate above uses. /// -/// MUTATION (RED): move the `tokio::spawn(post_receive_continuation` call below +/// MUTATION (RED): move the `enqueue_post_receive_job` call below /// `guard.release(` and the ordering assertion fails; take it out of the -/// `if push_succeeded` block and the failed-push assertion fails. +/// `if push_succeeded` block and the failed-push assertion fails; move the +/// `process_post_receive_job` spawn above the enqueue and the durability +/// assertion fails. #[test] fn inv22_replication_tail_spawns_at_the_durability_boundary() { let repos = src("api/repos.rs"); @@ -460,13 +465,20 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() { let gate_open = production .find("if push_succeeded {") .expect("U5 gate missing: the tail spawn must be gated on the push having succeeded"); - let spawn = production - .find("tokio::spawn(post_receive_continuation(") + let enqueue = production + .find("state.db.enqueue_post_receive_job(&job)") .expect( - "U5 gate missing: the post-receive continuation must be spawned by git_receive_pack", + "U5 gate missing: the post-receive job must be persisted by git_receive_pack \ + before the push is acknowledged", ); + let spawn = production + .find("tokio::spawn(process_post_receive_job(state.clone(), job));") + .expect("U5 gate missing: the post-receive job must be spawned by git_receive_pack"); + // The success-path `release` is the LAST one in the handler (the enqueue + // error branch has its own, earlier); `rfind` picks it so the ordering + // assertions bind the normal success path. let release = production - .find("guard.release(push_succeeded)") + .rfind("guard.release(push_succeeded)") .expect("U5 gate stale: release must consume the same success flag as the tail gate"); let touch = production .find("state.db.touch_repo(") @@ -476,20 +488,33 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() { .expect("U5 gate stale: git_receive_pack no longer fires push webhooks"); assert!( - success_flag < gate_open && gate_open < spawn, - "U5 gate bypassed: the continuation must be spawned inside `if push_succeeded`, \ - or a rejected push spawns a tail that pins and announces a half-applied repo" + success_flag < gate_open && gate_open < enqueue && enqueue < spawn, + "U5 gate bypassed: the post-receive job must be enqueued (the durability \ + boundary) then spawned inside `if push_succeeded`, or a rejected push spawns \ + a job that pins and announces a half-applied repo — or the processor runs \ + against a job that has no recovery record yet" ); - // Still inside that block: no `}` may close it between the gate and the spawn. + // Still inside that block: between the `if push_succeeded {` and the enqueue + // the brace balance must never go negative — the block's opening `{` is + // matched by the struct literals' own braces (job construction), but a `}` + // that closed the `if` block before the enqueue would unbalance it. The + // enqueue's own `if let Err` error branch closes a brace after it, which is + // fine — the spawn is asserted after the enqueue separately. + let prefix = &production[gate_open + "if push_succeeded {".len()..enqueue]; + let depth = prefix.chars().fold(1i64, |depth, c| match c { + '{' => depth + 1, + '}' => depth - 1, + _ => depth, + }); assert!( - !production[gate_open + "if push_succeeded {".len()..spawn].contains('}'), - "U5 gate bypassed: the continuation spawn left the `if push_succeeded` block, so a \ - rejected push now spawns a tail" + depth >= 1, + "U5 gate bypassed: the enqueue left the `if push_succeeded` block, so a \ + rejected push now enqueues a job" ); assert!( spawn < release && spawn < touch && spawn < webhook, - "U5 gate bypassed: the continuation must be spawned BEFORE guard.release, \ - touch_repo and the webhook fan-out, so a disconnect in any of those windows \ - cannot drop this push's pins, recovery copy, and announcements" + "U5 gate bypassed: the post-receive job must be enqueued and spawned BEFORE \ + guard.release, touch_repo and the webhook fan-out, so a disconnect in any of \ + those windows cannot drop this push's pins, recovery copy, and announcements" ); } From edd40361ff66642b792ae6bff5283b465d7c88e1 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 16 Aug 2026 23:16:36 +0600 Subject: [PATCH 23/25] fix(node): make the Arweave anchor a durable job unit and require an explicit gateway Round-4 reviewer findings on #224: - Move Arweave anchoring out of the spawned replication tail and into the awaited post-receive job body: the tail now reports (announce, cid_map) over a oneshot channel, and anchor_ref_updates runs after the replication tail returns. A failed upload, an unpersistable row, or an unanswerable existence check now fails the job instead of leaking into the tail, so the startup drain retries the whole unit. The per-ref existence check keeps retries and job replays from paying for a second on-chain artifact. - Drop the implicit gateway default (arweave.net) and refuse to start with a bundler configured but no explicit GITLAWB_ARWEAVE_GATEWAY: the old behavior silently paired the gateway to the bundler URL, which broke /verify for production deployments (devnet transactions are not resolvable via arweave.net). Enforced in Config::validate() next to the existing ACCOUNT/TOKEN checks. - .env.example: split the commented devnet/production bundler blocks and document that anchoring needs URL + funded ACCOUNT + TOKEN + a gateway on the same network. - README: GITLAWB_BUNDLER_ACCOUNT is the funded payer (x-irys-paid-by), GITLAWB_BUNDLER_TOKEN is the payment-token slug billed at /tx/{token} (not an API key), GITLAWB_ARWEAVE_GATEWAY has no default. - anchors list: limit 0 now falls back to the default page size instead of returning nothing. - Tests: P4 unit (upload OK but row insert blocked -> job body errors, retry re-uploads exactly once, replay never re-calls the bundler), P5 unit (unanswerable existence check -> fail closed, no upload), and an end-to-end job test (bundler 500 -> job stays failed, drain retries, replay after the row exists never re-uploads). --- .env.example | 25 +- README.md | 8 +- crates/gitlawb-node/src/api/arweave.rs | 71 ++- crates/gitlawb-node/src/api/repos.rs | 800 +++++++++++++++++++++---- crates/gitlawb-node/src/config.rs | 186 ++++-- crates/gitlawb-node/src/db/mod.rs | 18 +- crates/gitlawb-node/src/main.rs | 24 +- 7 files changed, 909 insertions(+), 223 deletions(-) diff --git a/.env.example b/.env.example index 84451e8e..04e11c84 100644 --- a/.env.example +++ b/.env.example @@ -55,16 +55,25 @@ GITLAWB_PINATA_UPLOAD_URL=https://uploads.pinata.cloud/v3/files # /tx/{token} via the x-irys-paid-by header, so a URL with no funded account and # token would silently fail every anchor. Default (empty) disables anchoring. GITLAWB_BUNDLER_URL= -# To enable, uncomment the block below and fund the account via the bundler's -# devnet faucet (https://docs.irys.xyz/devnet/faucet), or provide a production -# funded wallet credential and https://node2.irys.xyz. +# To enable, uncomment the devnet block below and fund the account via the +# bundler's devnet faucet (https://docs.irys.xyz/devnet/faucet), or use the +# production block with a funded wallet and https://node2.irys.xyz. +# Anchoring is PAID and needs the funded-account pair AND an explicit +# GITLAWB_ARWEAVE_GATEWAY for the SAME network: the node refuses to start with +# a bundler URL but no gateway, because an anchor is only resolvable through +# the gateway of the network that recorded it. +# +# Devnet: #GITLAWB_BUNDLER_URL=https://devnet.irys.xyz -#GITLAWB_BUNDLER_ACCOUNT= +#GITLAWB_BUNDLER_ACCOUNT= #GITLAWB_BUNDLER_TOKEN=matic -# Arweave gateway URL for resolving arweave_tx_id to data items. -# Must match the network used by GITLAWB_BUNDLER_URL so anchors are verifiable. -# Default: Irys devnet gateway; for production use https://arweave.net. -GITLAWB_ARWEAVE_GATEWAY=https://devnet.irys.xyz +#GITLAWB_ARWEAVE_GATEWAY=https://devnet.irys.xyz +# +# Production (mainnet Irys + Arweave): +#GITLAWB_BUNDLER_URL=https://node2.irys.xyz +#GITLAWB_BUNDLER_ACCOUNT= +#GITLAWB_BUNDLER_TOKEN=ethereum +#GITLAWB_ARWEAVE_GATEWAY=https://arweave.net # Per-client-IP rate limit for the unauthenticated /api/v1/arweave/verify/:tx_id # endpoint, in requests per hour. 0 disables. Default 120. GITLAWB_ARWEAVE_RATE_LIMIT=120 diff --git a/README.md b/README.md index 0adbe812..efddd011 100644 --- a/README.md +++ b/README.md @@ -358,10 +358,10 @@ Important node settings: | `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. | | `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. | | `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. | -| `GITLAWB_BUNDLER_URL` | Bundler URL for Arweave permanent anchoring (e.g., https://devnet.irys.xyz). Leave empty to disable. (Legacy name: `GITLAWB_IRYS_URL`). | -| `GITLAWB_BUNDLER_ACCOUNT` | Irys bundler account (public address) for Arweave permanent anchoring. Must be set together with `GITLAWB_BUNDLER_TOKEN` for anchoring to enable. | -| `GITLAWB_BUNDLER_TOKEN` | Irys bundler token (API key) for Arweave permanent anchoring. Sent as the `x-irys-paid-by` header with the account. Must be set together with `GITLAWB_BUNDLER_ACCOUNT` for anchoring to enable. | -| `GITLAWB_ARWEAVE_GATEWAY` | Arweave gateway URL for resolving anchors (defaults to `https://arweave.net`). | +| `GITLAWB_BUNDLER_URL` | Bundler URL for Arweave permanent anchoring (e.g., https://devnet.irys.xyz for devnet, https://node2.irys.xyz for mainnet Irys). Leave empty to disable. (Legacy name: `GITLAWB_IRYS_URL`). | +| `GITLAWB_BUNDLER_ACCOUNT` | Funded bundler account (public address/identity) that pays for uploads. The node's ANS-104 signature proves authorship, not payment — Irys only serves items backed by a funded account — so the node refuses to start when a bundler URL is set without this. It is sent as the `x-irys-paid-by` header on every upload. | +| `GITLAWB_BUNDLER_TOKEN` | Payment-token slug the funded account holds (e.g. `matic` on devnet, `ethereum` on mainnet). Irys bills uploads at `/tx/{token}`, so this names the token, not an API key, and is NOT sent as `x-irys-paid-by` (that header carries the account). The node refuses to start when a bundler URL is set without it. | +| `GITLAWB_ARWEAVE_GATEWAY` | Arweave gateway used to resolve anchors for `/verify` and the anchors listing. Has no default: the node refuses to start when a bundler is configured without an explicit gateway, because an anchor is only resolvable through the gateway of the network that recorded it (a devnet bundler pairs with the devnet gateway, mainnet Irys with `https://arweave.net`). | | `GITLAWB_ARWEAVE_RATE_LIMIT` | Per-client-IP rate limit for the verify endpoint, requests per hour (defaults to 120; `0` disables). | Production note: change the default Postgres password before exposing a node publicly. diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 9668d7be..54b7ed89 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -72,8 +72,14 @@ pub async fn list_anchors( Query(q): Query, ) -> Result> { // Clamp to a sane bound; a negative value would become LIMIT -1 in SQL, - // which Postgres rejects. Treat anything below 1 as the default. - let limit = q.limit.clamp(1, 200); + // which Postgres rejects. A value below 1 means "unset" and uses the serde + // default, NOT the clamp floor: `?limit=0` must behave like the parameter + // being absent (default 50), not like `?limit=1`. + let limit = if q.limit < 1 { + default_limit() + } else { + q.limit.min(200) + }; // Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and // map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251). let anchors = state @@ -265,4 +271,65 @@ mod closed_pool_tests { "arweave_url should carry the safe origin plus path prefix, got: {body}" ); } + + /// #224 review: `?limit=0` must behave like the parameter being absent + /// (the serde default of 50), not like `?limit=1`. The old + /// `q.limit.clamp(1, 200)` collapsed 0 to 1, silently narrowing the + /// listing; the fix routes sub-1 values through `default_limit()`. + #[sqlx::test] + async fn list_anchors_limit_zero_uses_default_limit(pool: PgPool) { + use clap::Parser as _; + + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--arweave-gateway", + "https://arweave.net", + ])); + + // Seed three distinct transitions. + for (ref_name, old_sha, new_sha) in [ + ("refs/heads/main", "a".repeat(40), "b".repeat(40)), + ("refs/heads/dev", "c".repeat(40), "d".repeat(40)), + ("refs/tags/v1", "e".repeat(40), "f".repeat(40)), + ] { + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name, + old_sha: &old_sha, + new_sha: &new_sha, + cid: Some("bafy1test"), + arweave_tx_id: &"f".repeat(43), + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + } + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors?limit=0") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let v: Value = serde_json::from_slice(&bytes).expect("json body"); + assert_eq!( + v["count"], 3, + "limit=0 must fall back to the default limit, not clamp to 1" + ); + } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 5a860a3e..62ba4ad2 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -2322,15 +2322,220 @@ async fn run_post_receive_job( } } + // The replication tail's spawned task re-derives the announce decision and + // the pin's sha→CID map; the durable Arweave anchoring unit below needs + // both. The tail reports them over a oneshot once the pin has landed, so + // the job body only anchors after the pinned objects (and their CIDs) + // exist — the anchor embeds the real CID, and that is part of the job's + // durability scope. The tail itself stays best-effort: it also does the + // lower-priority gossip / GraphQL / peer-notify steps, none of which gate + // this job's completion. + let (anchor_cid_tx, anchor_cid_rx) = tokio::sync::oneshot::channel(); post_receive_replication_tail( state.clone(), - record, - ref_updates, + record.clone(), + ref_updates.clone(), disk_path, did.to_string(), - ref_certs, + ref_certs.clone(), + anchor_cid_tx, ) .await; + + // Durable Arweave anchoring (#224 review): awaited so the job only reaches + // `done` after every anchor's upload AND DB row are on record. A tail that + // dies before reporting (panic) drops the sender; that is a job failure, + // not a silent skip, so the startup drain retries it. + let (announce, cid_map) = anchor_cid_rx + .await + .map_err(|_| anyhow::anyhow!("replication tail died before reporting announce/CID"))?; + anchor_ref_updates( + state, + &record, + &ref_updates, + &ref_certs, + announce, + &cid_map, + did, + ) + .await?; + Ok(()) +} + +/// Durable Arweave anchoring for a post-receive job (#224 review): one awaited +/// unit of work per ref transition, so the job only reaches `done` after every +/// anchor's upload AND its DB row are on record. This is the part of the +/// replication tail that the durability contract covers — Pinata pins, gossip, +/// GraphQL broadcast, and peer notify are explicitly best-effort and outside it. +/// +/// Failure semantics, per the review: +/// - A transition already anchored (existence check) skips the upload entirely, +/// so a startup replay of an already-anchored job never spends bundler +/// balance on a duplicate on-chain artifact. +/// - A DB error during the existence check is NOT treated as "not anchored": +/// the upload is skipped (fail-closed) so an unknown state never pays for a +/// duplicate upload, and the check error fails the job so the startup drain +/// retries, by which time the check can be answered. +/// - An upload error, or an upload whose row could not be persisted, returns +/// `Err` and fails the job. The drain retries; the existence check then makes +/// the retry a no-op for a transition whose row landed meanwhile. +async fn anchor_ref_updates( + state: &AppState, + record: &crate::db::RepoRecord, + ref_updates: &[RefUpdate], + ref_certs: &std::collections::HashMap, + announce: bool, + cid_map: &std::collections::HashMap, + node_did: &str, +) -> anyhow::Result<()> { + // Arweave permanent anchoring — suppressed for repos the public cannot read + // (public permanent ledger). `announce` is the same fail-closed decision the + // replication tail produced (re-derived for coalesced pushes, false when the + // walk failed or the repo is not listable at root). + let bundler_url = &state.config.bundler_url; + if !announce || bundler_url.is_empty() { + return Ok(()); + } + let repo_slug = format!( + "{}/{}", + crate::db::normalize_owner_key(&record.owner_did), + record.name + ); + let bundler_account = &state.config.bundler_account; + let bundler_token = &state.config.bundler_token; + let now_ts = chrono::Utc::now().to_rfc3339(); + for update in ref_updates { + let cid = cid_map.get(&update.new_sha).cloned(); + // Use the per-update certificate issued above, not a repo-wide latest, + // so each anchor embeds the exact certificate for its own ref + // transition. + let cert = match ref_certs.get(&update.ref_name) { + Some(c) => c.clone(), + None => { + // Certificate issuance failed for this ref update. Anchoring + // without a cert would produce a permanent artifact that + // verify_anchor must reject — skip instead of publishing an + // unverifiable anchor. + tracing::warn!( + ref_name = %update.ref_name, + "skipping arweave anchor — no certificate was issued" + ); + continue; + } + }; + // #224: a startup replay of this push's job would otherwise re-run the + // upload and pay for a SECOND permanent artifact for the same + // transition. The anchor existence check makes the upload idempotent: + // the exact (repo, ref, old→new) transition already anchored skips the + // upload entirely (the recorded anchor, cert, and tx_id from the + // original run stand). A transient DB error must not read as "not + // anchored" — that would spend bundler balance on a duplicate for a + // transition that may already be anchored — and it must not be skipped + // silently either (that would drop the anchor forever). It fails the + // job: the startup drain retries, by which time the check can be + // answered, and whichever way it lands no money has been wasted and no + // anchor has been lost. + match state + .db + .arweave_anchor_exists( + &repo_slug, + &update.ref_name, + &update.old_sha, + &update.new_sha, + ) + .await + { + Ok(true) => { + tracing::debug!( + repo = %repo_slug, + ref_name = %update.ref_name, + old_sha = %update.old_sha, + new_sha = %update.new_sha, + "skipping arweave anchor — transition already anchored" + ); + continue; + } + Ok(false) => {} + Err(e) => { + return Err(anyhow::anyhow!( + "cannot check whether {}/{} is already anchored: {e} — \ + failing the job so the startup drain retries; the upload is \ + skipped until the check can be answered", + repo_slug, + update.ref_name + )); + } + } + let anchor = crate::arweave::RefAnchor { + repo: repo_slug.clone(), + repo_id: record.id.clone(), + owner_did: record.owner_did.clone(), + ref_name: update.ref_name.clone(), + old_sha: update.old_sha.clone(), + new_sha: update.new_sha.clone(), + cid: cid.clone(), + timestamp: now_ts.clone(), + node_did: node_did.to_string(), + certificate: Some(cert.clone()), + }; + let tx_id = crate::arweave::anchor_ref_update( + &state.http_client, + bundler_url, + bundler_account, + bundler_token, + &anchor, + &state.node_keypair, + ) + .await + .map_err(|e| { + // A push must never fail over anchoring, but this is a durable job + // retried by the startup drain, not the live push. Name the two + // common causes (unfunded bundler account, config only checks it + // at boot) so operators can tell them apart in the job's error. + anyhow::anyhow!( + "arweave anchor for {}/{} failed: {e} — if the bundler reports \ + 'Not enough balance', fund GITLAWB_BUNDLER_ACCOUNT (for the token in \ + GITLAWB_BUNDLER_TOKEN); an unfunded node retries and loses anchors forever", + repo_slug, + update.ref_name + ) + })?; + if tx_id.is_empty() { + continue; + } + // Upload succeeded — the DB row is the only durable record of it. A + // failed insert is a FAILED UNIT OF WORK, not a warning: returning Err + // fails the job, and the startup drain retries. On retry the existence + // check skips the upload if the row landed (e.g. another instance + // recorded it), and fails closed while it cannot be checked. + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: &repo_slug, + owner_did: &record.owner_did, + ref_name: &update.ref_name, + old_sha: &update.old_sha, + new_sha: &update.new_sha, + cid: cid.as_deref(), + arweave_tx_id: &tx_id, + node_did, + cert_id: Some(cert.id.clone()), + }) + .await + .map_err(|e| { + anyhow::anyhow!( + "uploaded arweave anchor {tx_id} for {}/{} but could not persist it: {e}", + repo_slug, + update.ref_name + ) + })?; + tracing::info!( + tx_id, + repo = %repo_slug, + ref_name = %update.ref_name, + "recorded arweave anchor" + ); + } Ok(()) } @@ -2341,12 +2546,15 @@ async fn run_post_receive_job( /// Rows a previous process left `processing` or `failed` are reset to /// `pending` — a fresh process has no in-flight jobs, so resetting is safe — /// and every pending row is spawned through the same processor the handler -/// uses. Each effect is idempotent (`record_push_job` keys on the job id, -/// certificate ids are deterministic per (job, ref), the Arweave anchor is -/// gated on an existence check), so a replay completes exactly the work the -/// original run owed without double-counting or double-issuing. A drain that -/// errors out is logged; the unprocessed rows stay `pending` and are retried on -/// the next restart (the job table IS the retry policy). +/// uses. Each durable effect is idempotent (`record_push_job` keys on the job +/// id, certificate ids are deterministic per (job, ref), the Arweave anchor is +/// gated on an existence check), so a replay completes exactly the accounting, +/// certificate, and anchor work the original run owed without double-counting, +/// double-issuing, or paying for a duplicate on-chain artifact. The rest of the +/// replication tail (Pinata pins, gossip, GraphQL broadcast, peer notify) is +/// best-effort and NOT recovered here. A drain that errors out is logged; the +/// unprocessed rows stay `pending` and are retried on the next restart (the job +/// table IS the retry policy). pub(crate) async fn drain_post_receive_jobs(state: AppState) -> anyhow::Result { state.db.reset_stale_post_receive_jobs().await?; let pending = state.db.list_pending_post_receive_jobs().await?; @@ -2377,6 +2585,7 @@ async fn post_receive_replication_tail( disk_path: std::path::PathBuf, did: String, ref_certs: std::collections::HashMap, + anchor_cid_tx: tokio::sync::oneshot::Sender<(bool, std::collections::HashMap)>, ) { // Replication enforcement (Phase 2): decide once per push whether the public // may read this repo at all and, if so, which blob OIDs must not leave the @@ -2557,11 +2766,14 @@ async fn post_receive_replication_tail( // #174 P2-2 scope note: this SECOND detached spawn is deliberately NOT brought // under the per-repo encryption coalescing above, because unlike the idempotent // recovery-copy walk it does PER-PUSH, PER-REF work — branch→CID upserts, gossip - // publish, GraphQL subscription broadcast, Arweave anchoring, and peer notify, each - // keyed to THIS push's ref_updates. Coalescing (or shedding) it against an in-flight - // task for the same repo would DROP a later push's ref-update announcements (a - // correctness regression), not merely delay a duplicate. So the task stays one per - // push and every push's effects fire exactly once. + // publish, GraphQL subscription broadcast, and peer notify, each keyed to THIS + // push's ref_updates. Coalescing (or shedding) it against an in-flight task for + // the same repo would DROP a later push's ref-update announcements (a correctness + // regression), not merely delay a duplicate. So the task stays one per push and + // every push's effects fire exactly once. Arweave anchoring is NOT part of this + // spawn (#224 review): it is the durable, awaited unit in the job body, which + // consumes this task's announce/CID report over a oneshot. Everything this spawn + // does is best-effort and outside the post-receive job's durability contract. // // #174 F2 / KTD-3: {bounded memory, no dropped effects, no handler latency} are // jointly unsatisfiable by coalesce/shed/block, so instead of retaining the full @@ -2590,10 +2802,6 @@ async fn post_receive_replication_tail( let pusher_did_clone = did.to_string(); let db_for_peers = state.db.clone(); let ref_update_tx = state.ref_update_tx.clone(); - let bundler_url = state.config.bundler_url.clone(); - let bundler_account = state.config.bundler_account.clone(); - let bundler_token = state.config.bundler_token.clone(); - let owner_did_for_arweave = record.owner_did.clone(); let self_public_url = state.config.public_url.clone(); let node_keypair = Arc::clone(&state.node_keypair); // #174 F2a: gated on the cheap announce predicate, not on `withheld`. @@ -2682,6 +2890,15 @@ async fn post_receive_replication_tail( // Build sha→cid map from pinned objects let cid_map: std::collections::HashMap = pinned.into_iter().collect(); + // Report the announce decision and pin CID map to the durable + // Arweave anchoring unit in the job body. Sent before the + // lower-priority, best-effort steps below (gossip, GraphQL + // broadcast, peer notify) so the job does not wait on them; they + // are outside the job's durability scope. A panic before this + // point drops the sender, and the job body treats that as a + // failure to be retried by the next startup drain. + let _ = anchor_cid_tx.send((announce, cid_map.clone())); + // Record branch→CID for each ref update and publish gossip for (ref_name, old_sha, new_sha) in &ref_updates_clone { let cid = cid_map.get(new_sha).map(|s| s.as_str()); @@ -2738,113 +2955,6 @@ async fn post_receive_replication_tail( } } - // Arweave permanent anchoring — fire for each ref update. - // Suppressed for repos the public cannot read (public permanent ledger). - if announce && !bundler_url.is_empty() { - for (ref_name, old_sha, new_sha) in &ref_updates_clone { - let cid = cid_map.get(new_sha).cloned(); - // Use the per-update certificate issued above, not a - // repo-wide latest, so each anchor embeds the exact - // certificate for its own ref transition. - let cert = ref_certs_clone.get(ref_name).cloned(); - if cert.is_none() { - // Certificate issuance failed for this ref update. - // Anchoring without a cert would produce a permanent - // artifact that verify_anchor must reject — skip - // instead of publishing an unverifiable anchor. - tracing::warn!( - ref_name, - "skipping arweave anchor — no certificate was issued" - ); - continue; - } - let cert_id = cert.as_ref().map(|c| c.id.clone()); - // #224: a startup replay of this push's job would otherwise - // re-run this loop and upload a SECOND permanent artifact - // for the same transition. The anchor existence check makes - // the upload idempotent: the exact (repo, ref, old→new) - // transition already anchored skips the upload entirely - // (the recorded anchor, cert, and tx_id from the original - // run stand). Narrow TOCTOU window between check and upload - // is accepted: the node's own anchor is single-flight per - // transition in practice, and the duplicate would at worst - // be an extra on-chain artifact, not data corruption. - if db_clone - .arweave_anchor_exists(&repo_slug, ref_name, old_sha, new_sha) - .await - .unwrap_or(false) - { - tracing::debug!( - repo = %repo_slug, - ref_name, - old_sha, - new_sha, - "skipping arweave anchor — transition already anchored" - ); - continue; - } - let anchor = crate::arweave::RefAnchor { - repo: repo_slug.clone(), - repo_id: record.id.clone(), - owner_did: owner_did_for_arweave.clone(), - ref_name: ref_name.clone(), - old_sha: old_sha.clone(), - new_sha: new_sha.clone(), - cid: cid.clone(), - timestamp: now_ts.clone(), - node_did: node_did_str.clone(), - certificate: cert, - }; - match crate::arweave::anchor_ref_update( - &http_client, - &bundler_url, - &bundler_account, - &bundler_token, - &anchor, - &node_keypair, - ) - .await - { - Ok(tx_id) if !tx_id.is_empty() => { - if let Err(e) = db_clone - .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { - repo: &repo_slug, - owner_did: &owner_did_for_arweave, - ref_name, - old_sha, - new_sha, - cid: cid.as_deref(), - arweave_tx_id: &tx_id, - node_did: &node_did_str, - cert_id, - }) - .await - { - tracing::warn!(repo=%repo_slug, tx_id=%tx_id, err=%e, "failed to persist arweave anchor"); - } - } - Ok(_) => {} - Err(e) => { - // A push must never fail over anchoring, but a - // failure here is permanent data loss: the anchor - // is never written. Name the two common causes - // (unfunded bundler account, config only checks it - // at boot) so operators can tell them apart. - tracing::warn!( - repo=%repo_slug, - bundler_account=%bundler_account, - bundler_token=%bundler_token, - err=%e, - "Arweave anchor failed — if the bundler reports 'Not enough \ - balance', fund GITLAWB_BUNDLER_ACCOUNT (for the token in \ - GITLAWB_BUNDLER_TOKEN); an unfunded node \ - silently loses every anchor" - ) - } - } - } - } - // HTTP peer notification — notify all known peers to pull from us. // This is the reliable fallback when Gossipsub p2p is not yet connected. // Suppressed for repos the public cannot read. Runs last so a slow or @@ -9202,6 +9312,22 @@ mod tests { }] } + /// Drive the replication tail with a throwaway announce/CID channel. Tests + /// that do not exercise the durable Arweave anchor unit (they never await + /// the receiver) discard both ends; the spawned Pinata task's report is a + /// no-op either way. + async fn f2a_tail( + state: AppState, + rec: crate::db::RepoRecord, + updates: Vec, + path: std::path::PathBuf, + did: String, + certs: std::collections::HashMap, + ) { + let (_tx, _rx) = tokio::sync::oneshot::channel(); + post_receive_replication_tail(state, rec, updates, path, did, certs, _tx).await; + } + const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; /// Scenario 1 (the finding). A second rapid push to the same repo coalesces @@ -9227,7 +9353,7 @@ mod tests { state.pin_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); let _held = state.pin_semaphore.clone().acquire_owned().await.unwrap(); - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/main", &c2), @@ -9248,7 +9374,7 @@ mod tests { "the admitted push's task holds the repo key while it is parked on the pin pool" ); - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/second", &c1), @@ -9322,7 +9448,7 @@ mod tests { let held = state.pin_semaphore.clone().acquire_owned().await.unwrap(); // Push A is admitted; its task then parks on the held pin pool, key retained. - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/main", &c2), @@ -9411,6 +9537,7 @@ mod tests { repo.path().to_path_buf(), F2A_PUSHER.to_string(), std::collections::HashMap::new(), + tokio::sync::oneshot::channel().0, )); f2a_wait_for(|| started.exists(), "the admitted push's walk to start").await; @@ -9514,7 +9641,7 @@ mod tests { crate::state::BeginOutcome::Coalesced => panic!("the first begin must admit"), }; - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -9797,7 +9924,7 @@ mod tests { crate::state::BeginOutcome::Coalesced => panic!("the first begin must admit"), }; - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -9857,7 +9984,7 @@ mod tests { let (state, mut rec) = f2a_state(pool, &git_bin, "z6f2apriv", "v1", false).await; rec.is_public = false; - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/main", &c1), @@ -9895,7 +10022,7 @@ mod tests { let (_server, cid) = f2a_pinata(&mut state).await; let mut updates = state.ref_update_tx.subscribe(); - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -9993,7 +10120,7 @@ mod tests { // Nothing pre-takes the coalescing key, so this push is ADMITTED and runs its // own walk. - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -10203,4 +10330,411 @@ mod tests { "replaying the job must not mint a second certificate" ); } + + // ---- #224 review, P4/P5: the Arweave anchor is a durable unit ---- + + /// A mock Irys bundler that counts uploads and fails a fixed number of the + /// first ones with 500 before succeeding. Returns the base URL and a call + /// counter. + async fn f2a_bundler( + fail_first: usize, + ) -> (String, std::sync::Arc) { + use axum::http::StatusCode; + use axum::response::IntoResponse; + use std::sync::atomic::Ordering; + + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let failures_left = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(fail_first)); + let app = { + let calls_srv = calls.clone(); + let failures_srv = failures_left.clone(); + axum::Router::new().route( + "/tx/{token}", + axum::routing::post(move || { + let calls = calls_srv.clone(); + let failures = failures_srv.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + if failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| { + if n > 0 { + Some(n - 1) + } else { + None + } + }) + .is_ok() + { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "simulated bundler outage", + ) + .into_response() + } else { + ( + StatusCode::OK, + axum::Json(serde_json::json!({"id": "f".repeat(43)})), + ) + .into_response() + } + } + }), + ) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), calls) + } + + async fn f2a_job_status(pool: &sqlx::PgPool, job_id: &str) -> String { + sqlx::query_scalar::<_, String>("SELECT status FROM post_receive_jobs WHERE id = $1") + .bind(job_id) + .fetch_one(pool) + .await + .unwrap() + } + + fn f2a_anchor_record() -> crate::db::RepoRecord { + let now = chrono::Utc::now(); + crate::db::RepoRecord { + id: "repo-anchor-1".to_string(), + name: "myrepo".to_string(), + owner_did: "did:key:zAlice".to_string(), + description: None, + is_public: true, + default_branch: "main".to_string(), + created_at: now, + updated_at: now, + disk_path: "/tmp/myrepo".to_string(), + forked_from: None, + machine_id: None, + } + } + + fn f2a_anchor_update() -> RefUpdate { + RefUpdate { + old_sha: "a".repeat(40), + new_sha: "b".repeat(40), + ref_name: "refs/heads/main".to_string(), + } + } + + fn f2a_anchor_cert(record: &crate::db::RepoRecord) -> crate::db::RefCertificate { + crate::db::RefCertificate { + id: "cert-anchor-1".to_string(), + repo_id: record.id.clone(), + ref_name: "refs/heads/main".to_string(), + old_sha: "a".repeat(40), + new_sha: "b".repeat(40), + pusher_did: "did:key:zAlice".to_string(), + node_did: "did:key:zNode".to_string(), + signature: "sig".to_string(), + issued_at: chrono::Utc::now().to_rfc3339(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + } + } + + /// #224 review, P4: an upload that succeeds but whose DB row cannot be + /// written is a FAILED unit of work. The job body must return `Err` — the + /// job is then `failed`, not `done`, and the startup drain retries it — and + /// the retry must not re-call the bundler once the row exists. The CHECK + /// constraint makes the INSERT fail deterministically while the existence + /// check (a SELECT) keeps working. + #[sqlx::test] + async fn anchor_upload_ok_but_db_row_fails_is_retried_without_double_pay(pool: sqlx::PgPool) { + use clap::Parser as _; + + let (bundler_url, calls) = f2a_bundler(0).await; + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + "https://arweave.net", + ])); + + // Block the row this transition would write (repo slug "zAlice/myrepo") + // while leaving the existence check fully functional. + sqlx::query( + "ALTER TABLE arweave_anchors ADD CONSTRAINT anchor_test_block \ + CHECK (repo <> 'zAlice/myrepo')", + ) + .execute(&pool) + .await + .unwrap(); + + let record = f2a_anchor_record(); + let update = f2a_anchor_update(); + let mut certs = std::collections::HashMap::new(); + certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); + let empty_cid = std::collections::HashMap::new(); + + // Run 1: the upload lands but the row cannot be written → Err, so the + // job body would fail the job and the startup drain would retry. + let err = anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect_err("a row-less successful upload must fail the job body"); + assert!( + err.to_string().contains("could not persist"), + "the error must name the unpersisted upload: {err}" + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!( + !state + .db + .arweave_anchor_exists( + "zAlice/myrepo", + "refs/heads/main", + &"a".repeat(40), + &"b".repeat(40), + ) + .await + .unwrap(), + "the failed unit must not leave a row" + ); + + // Unblock, then the drain-style retry succeeds and records the anchor. + sqlx::query("ALTER TABLE arweave_anchors DROP CONSTRAINT anchor_test_block") + .execute(&pool) + .await + .unwrap(); + anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect("the retried unit must succeed once the row can be written"); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert!( + state + .db + .arweave_anchor_exists( + "zAlice/myrepo", + "refs/heads/main", + &"a".repeat(40), + &"b".repeat(40), + ) + .await + .unwrap(), + "the retry must record the anchor" + ); + + // Replay with the row present: the existence check skips the upload, so + // the bundler is NOT called again (no second paid on-chain artifact). + anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect("an already-anchored transition is a no-op"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 2, + "an already-anchored transition must not spend bundler balance again" + ); + } + + /// #224 review, P5: an unknown existence state must never pay for a + /// duplicate upload. When the existence check itself cannot be answered + /// (closed pool), the upload is skipped (fail-closed) and the job body + /// fails so the startup drain retries; the bundler is never called while + /// the state is unknown. + #[sqlx::test] + async fn anchor_existence_check_failure_never_uploads(pool: sqlx::PgPool) { + use clap::Parser as _; + + let (bundler_url, calls) = f2a_bundler(0).await; + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + "https://arweave.net", + ])); + + let record = f2a_anchor_record(); + let update = f2a_anchor_update(); + let mut certs = std::collections::HashMap::new(); + certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); + let empty_cid = std::collections::HashMap::new(); + + // Take the DB away: the existence check can no longer be answered. + pool.close().await; + + let err = anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect_err("an unanswerable existence check must fail the job body"); + assert!( + err.to_string().contains("already anchored"), + "the error must name the unanswerable check: {err}" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "an unknown existence state must never trigger a paid upload" + ); + } + + /// #224 review, P4 end-to-end: a post-receive job whose Arweave anchor + /// upload fails (bundler returns 500) is NOT terminal — the startup drain + /// retries it — and once the anchor row exists, replaying the job never + /// re-calls the bundler. Drives the same crash fixture as + /// `post_receive_job_survives_handler_abort`, with a counting bundler. + #[cfg(unix)] + #[sqlx::test] + async fn post_receive_job_anchor_failure_retries_and_replay_never_reuploads( + pool: sqlx::PgPool, + ) { + use clap::Parser as _; + + let bin = tempfile::TempDir::new().unwrap(); + let log = bin.path().join("git.log"); + let git_bin = f2a_logging_git(bin.path(), &log); + let (mut state, rec) = f2a_state(pool.clone(), &git_bin, "z6anchor", "a1", true).await; + let repos_dir = tempfile::TempDir::new().unwrap(); + state.repo_store = crate::git::repo_store::RepoStore::for_testing( + repos_dir.path().to_path_buf(), + pool.clone(), + ); + state + .db + .register_agent(F2A_PUSHER, &["agent".to_string()]) + .await + .unwrap(); + + // First upload fails (500), then the bundler behaves. + let (bundler_url, calls) = f2a_bundler(1).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + "https://arweave.net", + ])); + + let (_, repo_path) = state + .repo_store + .local_path(&rec.owner_did, &rec.name) + .unwrap(); + std::fs::create_dir_all(&repo_path).unwrap(); + u5_init_repo(&repo_path); + let c1 = u5_commit_file(&repo_path, "a.txt", "one\n"); + + let update = f2a_update("refs/heads/main", &c1); + let job = crate::db::PostReceiveJob { + id: uuid::Uuid::new_v4().to_string(), + pusher_did: F2A_PUSHER.to_string(), + owner_did: rec.owner_did.clone(), + repo_name: rec.name.clone(), + repo_id: rec.id.clone(), + ref_updates: update + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation::default(), + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + state.db.enqueue_post_receive_job(&job).await.unwrap(); + + // Run 1: the bundler fails the upload, so the anchor unit fails and the + // job is NOT done — it stays `failed` for the startup drain to retry. + process_post_receive_job(state.clone(), job.clone()).await; + assert_eq!( + f2a_job_status(&pool, &job.id).await, + "failed", + "a job whose anchor upload failed must not be terminal" + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!( + !state + .db + .arweave_anchor_exists(&f2a_slug(&rec), "refs/heads/main", ZERO_SHA, &c1) + .await + .unwrap(), + "a failed anchor must not leave a row" + ); + + // Drain retry: the bundler now succeeds, the anchor row lands, `done`. + state.db.reset_stale_post_receive_jobs().await.unwrap(); + let pending = state.db.list_pending_post_receive_jobs().await.unwrap(); + assert_eq!(pending.len(), 1, "the failed job must be drained"); + for job in pending { + process_post_receive_job(state.clone(), job).await; + } + assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert!( + state + .db + .arweave_anchor_exists(&f2a_slug(&rec), "refs/heads/main", ZERO_SHA, &c1) + .await + .unwrap(), + "the retried anchor must be recorded" + ); + + // Replay with the row present: the existence gate skips the upload, so + // the bundler is never called again. + process_post_receive_job(state.clone(), job.clone()).await; + assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 2, + "replaying an anchored job must not pay for a second upload" + ); + } } diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 9f7c3f65..2dc199d5 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -153,12 +153,15 @@ pub struct Config { pub bundler_token: String, /// Arweave gateway URL for resolving arweave_tx_id to data items. - /// Used by the verify endpoint. Default: https://arweave.net - #[arg( - long, - env = "GITLAWB_ARWEAVE_GATEWAY", - default_value = "https://arweave.net" - )] + /// Used by the verify endpoint and the anchors listing. + /// Required whenever `bundler_url` is set: anchors uploaded to a bundler + /// are only resolvable through the gateway of the SAME network, and the + /// inference that used to pair the two silently broke production verify + /// reads (a devnet bundler's txns are not resolvable via arweave.net, and + /// vice versa), so the operator must pick the network consciously. + /// No default: an unset gateway keeps the node's /verify and anchor + /// resolution inert, which is correct for a node that does not anchor. + #[arg(long, env = "GITLAWB_ARWEAVE_GATEWAY", default_value = "")] pub arweave_gateway: String, /// Base L2 DID registry contract address (0x...) @@ -606,24 +609,6 @@ impl Config { PathBuf::from(&self.key_path) } - /// Whether `arweave_gateway` was set explicitly by the operator — via the - /// `--arweave-gateway` CLI flag or the `GITLAWB_ARWEAVE_GATEWAY` env var — - /// rather than falling back to the clap default. Startup inference (pairing - /// the gateway to the bundler URL) must not overwrite an explicitly chosen - /// gateway. Pass `std::env::args_os()` at runtime. - pub fn arweave_gateway_explicitly_set(args: I) -> bool - where - I: IntoIterator, - T: Into + Clone, - { - use clap::parser::ValueSource; - use clap::CommandFactory; - Config::command() - .get_matches_from(args) - .value_source("arweave_gateway") - .is_some_and(|s| s != ValueSource::DefaultValue) - } - /// DB connections reserved for everything other than held write-locks: auth /// lookups, visibility-rule reads, the post-receive tail's own DB writes, and /// admin tooling. A write pins one pooled connection for its whole duration, so @@ -678,6 +663,24 @@ impl Config { .to_string(), ); } + // Anchoring is enabled, so the gateway must be chosen deliberately + // (#224 review). Anchors uploaded to a bundler are only resolvable + // through the gateway of the SAME network — an Irys devnet bundler's + // transactions are not resolvable via arweave.net, and mainnet Irys + // transactions are not resolvable via the devnet gateway — and the + // node refuses to start here rather than silently pair the two. The + // same fail-fast shape as the funded-account/token checks above. + if !self.bundler_url.trim().is_empty() && self.arweave_gateway.trim().is_empty() { + return Err(format!( + "GITLAWB_BUNDLER_URL is set to {} but GITLAWB_ARWEAVE_GATEWAY is not: an anchor \ + is only resolvable through the gateway of the network that recorded it. Set \ + GITLAWB_ARWEAVE_GATEWAY to the matching gateway for your bundler network \ + (devnet bundler https://devnet.irys.xyz pairs with the devnet gateway; \ + production bundler https://node2.irys.xyz pairs with https://arweave.net), or \ + clear GITLAWB_BUNDLER_URL to disable anchoring.", + crate::server::mask_credential_url(&self.bundler_url) + )); + } Ok(()) } @@ -1169,7 +1172,8 @@ mod tests { "error must name the missing token: {err}" ); - // URL plus account plus token validates. + // URL plus account plus token validates (with an explicit gateway, as + // `bundler_url_requires_an_explicit_gateway` now requires). Config::parse_from([ "gitlawb-node", "--bundler-url", @@ -1178,18 +1182,92 @@ mod tests { "zBundlerAccount", "--bundler-token", "matic", + "--arweave-gateway", + "https://devnet.irys.xyz", ]) .validate() .expect("bundler URL with a funded account and token must validate"); } + /// #224 review: anchoring is enabled, so the gateway must be chosen + /// deliberately — the old behavior silently paired the gateway to the + /// bundler URL, which broke /verify for production deployments (devnet + /// transactions are not resolvable via arweave.net). A bundler URL without + /// an explicit gateway must refuse to start, naming both URLs and which + /// network each must be on. + #[test] + fn bundler_url_requires_an_explicit_gateway() { + // Defaults (no bundler) validate with an unset gateway: a node that + // does not anchor has no need of gateway resolution. + Config::parse_from(["gitlawb-node"]) + .validate() + .expect("default config must validate"); + + // Bundler + account + token but no gateway must be rejected. + let no_gateway = Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + ]); + let err = no_gateway + .validate() + .expect_err("bundler URL without an explicit gateway must be rejected"); + assert!( + err.contains("GITLAWB_ARWEAVE_GATEWAY"), + "error must name the missing gateway: {err}" + ); + assert!( + err.contains("https://devnet.irys.xyz"), + "error must name the bundler URL: {err}" + ); + assert!( + err.contains("https://arweave.net"), + "error must name the matching production gateway: {err}" + ); + + // Bundler + account + token + gateway validates. + Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + "https://devnet.irys.xyz", + ]) + .validate() + .expect("bundler URL with a funded account, token, and explicit gateway must validate"); + + // Production shape: mainnet bundler + arweave.net gateway validates. + Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://node2.irys.xyz", + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "ethereum", + "--arweave-gateway", + "https://arweave.net", + ]) + .validate() + .expect("production bundler + arweave.net gateway must validate"); + } + /// The shipped `.env.example` must stay startable. Anchoring is paid, and - /// `validate()` refuses a bundler URL without both a funded account and a - /// payment token, so the example must never ship a non-empty - /// `GITLAWB_BUNDLER_URL` that the file itself does not also back with a - /// `GITLAWB_BUNDLER_ACCOUNT` and `GITLAWB_BUNDLER_TOKEN`. The app has no - /// dotenv loader, so this test keys on the file's active (non-commented) - /// lines the way a user `source`-ing the example would. + /// `validate()` refuses a bundler URL without both a funded account, a + /// payment token, and an explicit gateway, so the example must never ship a + /// non-empty `GITLAWB_BUNDLER_URL` that the file itself does not also back + /// with `GITLAWB_BUNDLER_ACCOUNT`, `GITLAWB_BUNDLER_TOKEN`, and + /// `GITLAWB_ARWEAVE_GATEWAY`. The app has no dotenv loader, so this test + /// keys on the file's active (non-commented) lines the way a user + /// `source`-ing the example would. #[test] fn env_example_bundler_block_is_startable() { let example_path = @@ -1210,6 +1288,7 @@ mod tests { let url = active("GITLAWB_BUNDLER_URL="); let account = active("GITLAWB_BUNDLER_ACCOUNT="); let token = active("GITLAWB_BUNDLER_TOKEN="); + let gateway = active("GITLAWB_ARWEAVE_GATEWAY="); if !url.is_empty() { assert!( !account.is_empty(), @@ -1219,6 +1298,10 @@ mod tests { !token.is_empty(), ".env.example sets GITLAWB_BUNDLER_URL but no active GITLAWB_BUNDLER_TOKEN" ); + assert!( + !gateway.is_empty(), + ".env.example sets GITLAWB_BUNDLER_URL but no active GITLAWB_ARWEAVE_GATEWAY" + ); } // Whatever the example ships, it must be a shape `validate()` accepts, so a @@ -1231,38 +1314,35 @@ mod tests { &account, "--bundler-token", &token, + "--arweave-gateway", + &gateway, ]; Config::parse_from(args) .validate() .unwrap_or_else(|e| panic!("the shipped .env.example must be startable: {e}")); } - /// #247: an explicit `--arweave-gateway` must not be overwritten by the - /// bundler-URL inference. The inference keys on the value source, so only - /// the clap default (no CLI flag, no env var) counts as "not explicit". - /// (The env-var arm of the detection is exercised indirectly: clap's `env` - /// feature routes `GITLAWB_ARWEAVE_GATEWAY` through the same - /// `ValueSource::EnvVariable` arm that the CLI-flag tests cover, and mutating - /// process env from a parallel test would race other cases.) + /// #224 review: the gateway-inference behavior is gone, so there is no + /// notion of an "explicit" gateway source to detect — `validate()` instead + /// requires a non-empty gateway whenever a bundler is configured (see + /// `bundler_url_requires_an_explicit_gateway`). The clap field carries no + /// default, so an unset gateway is simply empty and the pairing footgun + /// cannot silently select a network for the operator. #[test] - fn arweave_gateway_explicit_set_detection() { - use std::ffi::OsString; - - // No flag, env unset (in the test process) → not explicit. - assert!(!Config::arweave_gateway_explicitly_set(["gitlawb-node"])); + fn arweave_gateway_has_no_default_network() { + // No flag, no env (in the test process) → empty, not a network URL. + assert_eq!( + Config::parse_from(["gitlawb-node"]).arweave_gateway, + "", + "arweave_gateway must have no default network so a missing gateway is a hard error" + ); - // CLI flag → explicit. - assert!(Config::arweave_gateway_explicitly_set([ + // An operator-chosen gateway is preserved verbatim. + let cfg = Config::parse_from([ "gitlawb-node", "--arweave-gateway", "https://custom.example.com", - ])); - - // CLI flag via = form → explicit. - assert!(Config::arweave_gateway_explicitly_set([ - "gitlawb-node".into(), - "--arweave-gateway=https://custom.example.com".into(), - ] - as [OsString; 2])); + ]); + assert_eq!(cfg.arweave_gateway, "https://custom.example.com"); } } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index f2f2a427..486540c5 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -173,17 +173,21 @@ pub struct PostReceiveAttestation { pub request_path: Option, } -/// A durable post-receive job (#224 review): everything a landed push owes -/// after git accepted the pack — trust-score `record_push`, per-ref signed -/// certificates, and the replication tail — with its inputs persisted BEFORE -/// the push is acknowledged. Tokio cancels spawned tasks on restart/shutdown, -/// so a push whose continuation task died before reaching the bookkeeping left -/// a durable ref update with no certificate, accounting, anchor, or replication -/// and no way to recover it. Persisting the job first makes that interval +/// A durable post-receive job (#224 review): the post-ack work a landed push +/// owes that the durability contract covers — trust-score `record_push`, +/// per-ref signed certificates, and the Arweave anchor (upload + its DB row), +/// each awaited in the job body before the job reaches `done` — with its inputs +/// persisted BEFORE the push is acknowledged. Tokio cancels spawned tasks on +/// restart/shutdown, so a push whose continuation task died before reaching the +/// bookkeeping left a durable ref update with no certificate, accounting, or +/// anchor and no way to recover it. Persisting the job first makes that interval /// recoverable: startup resets stale rows to `pending` and replays them, and /// each effect is idempotent (`record_push` keys on the job id, certificates on /// a deterministic per-(job, ref) id, the Arweave anchor on an existence /// check), so a replay never double-counts, double-issues, or double-anchors. +/// The rest of the replication tail — Pinata pins, gossip publish, GraphQL +/// broadcast, peer notify — is explicitly best-effort and OUTSIDE this +/// contract: those steps are not recovered by a replay. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostReceiveJob { pub id: String, diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index c055b79f..486fe4e7 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -106,22 +106,14 @@ async fn main() -> Result<()> { } } - // When a bundler URL is configured (whether via the modern - // GITLAWB_BUNDLER_URL or the legacy GITLAWB_IRYS_URL alias) and the gateway - // was not explicitly set — neither via --arweave-gateway nor the - // GITLAWB_ARWEAVE_GATEWAY env var — pair the gateway to the same network so - // that anchors uploaded to a devnet bundler (whose transactions are not - // resolvable via the arweave.net default gateway) are verifiable through - // the verify endpoint. An operator-chosen gateway is never overwritten. - if !config.bundler_url.is_empty() - && !Config::arweave_gateway_explicitly_set(std::env::args_os()) - { - config.arweave_gateway = config.bundler_url.clone(); - tracing::warn!( - "GITLAWB_ARWEAVE_GATEWAY unset — inferred from bundler URL as {}", - crate::server::mask_credential_url(&config.arweave_gateway) - ); - } + // The bundler gateway pairing is NOT inferred here (#224 review): silently + // setting the gateway to the bundler URL paired a devnet bundler with a + // devnet gateway behind the operator's back, which is exactly the shape of + // config surprise the old default had — and production deployments that + // anchor through a mainnet bundler would have resolve broken anchors via + // the devnet gateway. `Config::validate()` now fails fast at boot when a + // bundler is configured without an explicit GITLAWB_ARWEAVE_GATEWAY, + // forcing the operator to name the network on each side. // Merge the embedded seed list of public network nodes into the runtime // bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS. From 30b95acb8622006931ae57a4ba1dc9d502260ff4 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 20 Aug 2026 13:34:47 +0600 Subject: [PATCH 24/25] fix(node): per-transition durable anchor outbox, distinct-signer threshold, accurate security docs Round-4 review consolidation on #224. The anchor row is now a durable per-transition outbox/state machine instead of a retry wrapper around the bundler HTTP call: - claim: an atomic INSERT ... ON CONFLICT DO NOTHING against a new unique (repo, ref_name, old_sha, new_sha) index creates the durable claim BEFORE any paid upload. Competing workers converge on a single payer. - prepare: the signed item's deterministic ANS-104 id is persisted on the row before the request is sent. - upload: outcomes are classified (Accepted / Rejected / Uncertain). A connection drop or malformed success leaves the row uploading, never recorded. - record: the accepted tx id is persisted as the terminal state. - recovery of a non-terminal claim probes the gateway for the persisted item id before re-uploading: present -> record as-is (no double pay), absent -> re-upload, no verdict -> fail closed without uploading. Also on the review: - satisfies_threshold counts distinct signer DIDs, not signature entries (regression test: a duplicated signature of one key fails a 2-of-3). - the anchor's issuer is state.node_did, not the pusher; the e2e test asserts the stored row's node_did matches the node and not the pusher. - certificate issuance failure fails the job (retryable) instead of a warning followed by done. - post-receive job processing claims the job atomically; two concurrent drainers converge on one executor (new test). - the upload client validates the bundler's success id at the boundary: empty/missing/malformed ids are Uncertain, only a 43-char base64url id is Accepted (new test). - list_anchors omits arweave_url when no gateway is configured (new test) and lists only recorded (terminal) rows. - SECURITY.md corrected to the runtime: UCANs are signed JSON envelopes, not JWTs; the middleware verifies the delegation chain; read enforcement is wired; owner-push enforcement defaults off. - migration v20 commentary attributes the dropped index to v10; migration v22 carries the outbox schema (state/item_id/claim_token/claimed_at, arweave_tx_id nullable, dedup, unique transition index). Full suite: 1417 passed / 0 failed across the workspace (877 in gitlawb-node). clippy --workspace --all-targets -D warnings clean. --- SECURITY.md | 34 +- crates/gitlawb-core/src/cert.rs | 37 +- crates/gitlawb-node/src/ans104.rs | 14 +- crates/gitlawb-node/src/api/arweave.rs | 77 ++- crates/gitlawb-node/src/api/repos.rs | 738 +++++++++++++++++++------ crates/gitlawb-node/src/arweave.rs | 346 +++++++++--- crates/gitlawb-node/src/db/mod.rs | 227 +++++++- 7 files changed, 1201 insertions(+), 272 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 422c1582..18f3abbe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,9 +29,15 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d - Tamper-evident by construction — a modified object changes its CID **UCAN capability tokens** -- Bootstrap UCAN tokens issued at registration -- Capability-scoped: `git:push`, `git:fetch`, `issue:create`, `pr:open` -- JWT-format tokens with expiry +- Issued at registration as a signed JSON envelope `{ "payload": {...}, "s": "" }` — not a JWT (#224 review: the policy must describe the actual wire format) +- Capability-scoped: `git/push`, `git/fetch`, `issue/create`, `pr/open` +- Expiry enforced on every verification +- The auth middleware (`require_ucan_chain`) verifies the full delegation chain when the `X-Ucan` header is present: 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 + +**Authorization** +- Every repo-scoped read and mutation binds the caller to an authorization decision before serving or mutating anything +- Per-repository read enforcement is wired: `authorize_repo_read` denies with the same 404 a missing repo returns, and content endpoints pass the specific path so a withheld subtree is denied even on an otherwise-public repo +- Owner-only mutations (visibility, webhooks, protected branches, merges) are gated to the repo owner; star/unstar, replica registration, and bounty actions have their own intended gates **Smart contracts (Base Sepolia testnet)** - `GitlawbDIDRegistry` — on-chain DID → document registry @@ -52,11 +58,16 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d These are **documented, accepted limitations** for the current live release and should be prioritized without breaking existing nodes during rolling upgrades. -### UCAN chain validation -- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain. -- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push. +### UCAN chain validation is optional per request +- The middleware verifies the full UCAN delegation chain only when the client presents an `X-Ucan` header. Requests without the header pass through unchanged, so agents that predate UCAN delegation are not forced off. +- **Impact:** A client can still authenticate with a bare RFC 9421 HTTP Signature and skip delegation-chain enforcement entirely; capability delegation is enforced only for clients that opt into presenting a UCAN. - **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization. -- **Fix target:** v0.2 +- **Fix target:** make UCAN presentation mandatory for pushes (planned together with owner-push enforcement). + +### Owner-push enforcement defaults off +- `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false`: a valid did:key HTTP Signature is authentication, not authorization, so any registered agent can push to a repo until the operator enables owner-only writes. +- **Impact:** Anyone who can register an agent can push to any repo while the flag is off. +- **Mitigation:** Enable `GITLAWB_ENFORCE_OWNER_PUSH=true` in production; keep write endpoints signed in the meantime. ### UCAN revocation - Issued UCAN tokens cannot be revoked before expiry. @@ -71,10 +82,9 @@ These are **documented, accepted limitations** for the current live release and - **Fix target:** v0.2 ### Private repository reads -- Repository records have an `is_public` field and the node exposes `GITLAWB_PUBLIC_READ`, but per-repository private-read enforcement is not wired in the current live release. -- **Impact:** Do not store private repositories or secrets on public nodes. -- **Mitigation:** Run isolated nodes for non-public data and restrict network access at the reverse proxy or firewall layer. -- **Fix target:** v0.2 +- Per-repository private-read enforcement IS wired: `authorize_repo_read` and per-path visibility rules deny non-readers with an opaque 404, on reads and writes alike. +- **Impact:** The remaining risk is operational, not structural: a public node should still not be handed secrets, because read access is granted by the repo owner's visibility rules and any node operator can see everything stored on their own node. +- **Mitigation:** Keep secrets on isolated nodes and restrict network access at the reverse proxy or firewall layer. ### Peer route hardening rollout - Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present. @@ -101,7 +111,7 @@ These are **documented, accepted limitations** for the current live release and | Key storage | PKCS#8 PEM, 0600 permissions | | Content hashing | SHA-256 via CIDv1 | | HTTP Signatures | RFC 9421 (Ed25519 + SHA-256 Content-Digest) | -| UCAN tokens | JWT (Ed25519 signatures) | +| UCAN tokens | Signed JSON envelope (Ed25519 over the payload JSON), not JWT | | On-chain | ECDSA secp256k1 (Base L2 / Ethereum) | --- diff --git a/crates/gitlawb-core/src/cert.rs b/crates/gitlawb-core/src/cert.rs index 9da2b44e..5f30ce9c 100644 --- a/crates/gitlawb-core/src/cert.rs +++ b/crates/gitlawb-core/src/cert.rs @@ -6,6 +6,8 @@ //! The schema is frozen at v1. All fields are mandatory for forward compatibility. //! Nodes that receive a certificate with an unknown version MUST reject it. +use std::collections::HashSet; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -135,10 +137,14 @@ impl RefUpdateCert { /// Check if this certificate satisfies a threshold of valid signatures /// from the provided set of authorized maintainer DIDs. + /// + /// Counts distinct signer DIDs, not signature entries: a repeated + /// signature from the same maintainer counts once. pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result { let valid = self.verify_all()?; - let count = valid.iter().filter(|d| maintainers.contains(d)).count(); - Ok(count >= threshold) + let distinct_signers: HashSet<&Did> = + valid.iter().filter(|d| maintainers.contains(d)).collect(); + Ok(distinct_signers.len() >= threshold) } /// Validate the certificate structure (not signatures). @@ -344,6 +350,33 @@ mod tests { assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap()); } + #[test] + fn satisfies_threshold_rejects_duplicated_signature() { + let kp1 = Keypair::generate(); + let kp2 = Keypair::generate(); + let kp3 = Keypair::generate(); + let repo_did = kp1.did(); + + let mut cert = RefUpdateCert::new( + repo_did, + "refs/heads/main".to_string(), + dummy_hash('0'), + dummy_hash('a'), + 1, + &kp1, + ) + .unwrap(); + // Copy-paste the only real signature onto the certificate a second + // time. Same signer, same valid signature, still one real signer. + let dup = cert.signatures[0].clone(); + cert.signatures.push(dup); + + let maintainers = vec![kp1.did(), kp2.did(), kp3.did()]; + // Two signature entries but a single distinct signer must not + // satisfy a 2-of-3 threshold. + assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap()); + } + #[test] fn threshold_zero_is_always_satisfied() { let kp = Keypair::generate(); diff --git a/crates/gitlawb-node/src/ans104.rs b/crates/gitlawb-node/src/ans104.rs index 4d2299fc..c5bc580d 100644 --- a/crates/gitlawb-node/src/ans104.rs +++ b/crates/gitlawb-node/src/ans104.rs @@ -38,7 +38,8 @@ use anyhow::Result; #[cfg(test)] use anyhow::{anyhow, bail}; -use sha2::{Digest, Sha384}; +use base64::Engine as _; +use sha2::{Digest, Sha256, Sha384}; /// SignatureConfig value for Ed25519 data items (ANS-104). pub const SIGNATURE_TYPE_ED25519: u16 = 2; @@ -96,6 +97,17 @@ pub fn build_signed_data_item( Ok(item) } +/// The ANS-104 data-item id: `base64url(sha256(item bytes))`. This is the id +/// the bundler returns for a data item and the id gateways resolve +/// `{gateway}/{id}` under, so it is a stable, content-derived remote identity: +/// the durable job persists it BEFORE the upload request is sent, and a +/// recovery probes that id to decide whether a crashed upload actually landed +/// before ever issuing a second paid request (#224 review). +pub fn data_item_id(item: &[u8]) -> String { + let digest = Sha256::digest(item); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) +} + /// Parse a data item and verify its Ed25519 signature against `verifying_key` /// over the deepHash of its own fields. Returns the parsed item (tags + data) /// on success. This is exactly what a bundler/gateway does on receipt, so a diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 54b7ed89..935ffcb6 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -9,16 +9,6 @@ use serde::Deserialize; use crate::error::{AppError, Result}; use crate::state::AppState; -/// Validate an Arweave transaction ID: 43-character base64url string. -fn is_valid_tx_id(tx_id: &str) -> bool { - if tx_id.len() != 43 { - return false; - } - tx_id - .bytes() - .all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')) -} - /// GET /api/v1/arweave/verify/:tx_id /// /// Fetch the anchor from Arweave via the configured gateway, extract the embedded @@ -36,7 +26,7 @@ pub async fn verify_anchor_endpoint( State(state): State, Path(tx_id): Path, ) -> Result> { - if !is_valid_tx_id(&tx_id) { + if !crate::arweave::is_valid_tx_id(&tx_id) { return Err(AppError::BadRequest( "invalid transaction ID: expected 43-character base64url".to_string(), )); @@ -89,14 +79,19 @@ pub async fn list_anchors( // The gateway config may carry credentials (e.g. an Irys user:pass). Those // must never leak into a public listing, so only the credential-free origin - // is embedded in each anchor's URL. + // is embedded in each anchor's URL. A node with NO gateway configured emits + // no presentation URL at all: the recorded tx id stays durable and listable + // (it is the anchor's identity), but a `/tx_id`-shaped relative string would + // resolve against the node's own origin and mislead clients (#224 review). let gateway = crate::server::mask_credential_url(state.config.arweave_gateway.trim_end_matches('/')); let anchors: Vec = anchors .into_iter() .map(|mut a| { a.irys_tx_id = Some(a.arweave_tx_id.clone()); - a.arweave_url = Some(format!("{}/{}", gateway, a.arweave_tx_id)); + if !gateway.is_empty() { + a.arweave_url = Some(format!("{}/{}", gateway, a.arweave_tx_id)); + } a }) .collect(); @@ -272,6 +267,62 @@ mod closed_pool_tests { ); } + /// #224 review, P2: a node with recorded anchors but NO gateway configured + /// must not emit a relative `/tx_id` string as `arweave_url` — it would + /// resolve against the node's own origin and mislead clients. The recorded + /// tx id stays durable and listable (it is the anchor's identity); the + /// presentation URL is simply omitted. + #[sqlx::test] + async fn list_anchors_without_gateway_omits_arweave_url(pool: PgPool) { + // test_state's default config has no gateway configured. + let state = crate::test_support::test_state(pool.clone()).await; + + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: Some("bafy1test"), + arweave_tx_id: &"f".repeat(43), + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let v: Value = serde_json::from_slice(&bytes).expect("json body"); + let anchor = v["anchors"][0].clone(); + assert_eq!( + anchor["arweave_tx_id"], + "f".repeat(43), + "the durable tx id must still be listable" + ); + assert!( + anchor["arweave_url"].is_null(), + "with no gateway the arweave_url must be omitted, got: {}", + anchor["arweave_url"] + ); + } + /// #224 review: `?limit=0` must behave like the parameter being absent /// (the serde default of 50), not like `?limit=1`. The old /// `q.limit.clamp(1, 200)` collapsed 0 to 1, silently narrowing the diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 62ba4ad2..263eb350 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -2202,12 +2202,23 @@ fn deterministic_cert_id(job_id: &str, ref_name: &str) -> String { /// elsewhere. This task is spawned by the handler on success and by the startup /// drain for every row a previous process left pending. pub(crate) async fn process_post_receive_job(state: AppState, job: crate::db::PostReceiveJob) { - if let Err(e) = state - .db - .update_post_receive_job(&job.id, "processing", None) - .await - { - tracing::error!(job_id = %job.id, err = %e, "failed to mark post-receive job processing"); + // Conditional claim: exactly one worker may run a job. A concurrent drainer + // (or a handler + drainer racing on the same row) loses the UPDATE and must + // not run the body — otherwise two workers would each attempt the paid + // anchor for the same transition (#224 review). + match state.db.claim_post_receive_job(&job.id).await { + Ok(true) => {} + Ok(false) => { + tracing::info!( + job_id = %job.id, + "post-receive job already claimed by another worker; skipping" + ); + return; + } + Err(e) => { + tracing::error!(job_id = %job.id, err = %e, "failed to claim post-receive job"); + return; + } } match run_post_receive_job(&state, &job).await { @@ -2293,11 +2304,18 @@ async fn run_post_receive_job( // Issue a signed certificate for every ref this push advanced, each // carrying that ref's real old→new transition. A multi-ref push must // not collapse to a single cert covering only the first ref. + // + // A certificate is a REQUIRED durable output of a landed push: issuance + // failure (a transient insert/sequence/DB error) must fail the job so the + // startup drain retries it, never a warning followed by `done` that would + // lose the cert and its anchor permanently (#224 review). Retries re-issue + // the same deterministic cert id, which `insert_ref_certificate_tx`'s + // `ON CONFLICT (id) DO NOTHING` turns into a no-op. let mut ref_certs: std::collections::HashMap = std::collections::HashMap::new(); for update in &ref_updates { let cert_id = deterministic_cert_id(&job.id, &update.ref_name); - match cert::issue_ref_certificate( + let cert = cert::issue_ref_certificate( state, &record.id, &update.ref_name, @@ -2311,15 +2329,16 @@ async fn run_post_receive_job( job.attestation.request_path.clone(), ) .await - { - Ok(c) => { - tracing::info!(cert_id = %c.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate"); - ref_certs.insert(update.ref_name.clone(), c); - } - Err(e) => { - tracing::warn!(err = %e, ref_name = %update.ref_name, "failed to issue ref certificate") - } - } + .map_err(|e| { + anyhow::anyhow!( + "failed to issue ref certificate for {}/{}: {e} — the job stays retryable \ + so the startup drain re-issues it", + record.name, + update.ref_name + ) + })?; + tracing::info!(cert_id = %cert.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate"); + ref_certs.insert(update.ref_name.clone(), cert); } // The replication tail's spawned task re-derives the announce decision and @@ -2349,6 +2368,10 @@ async fn run_post_receive_job( let (announce, cid_map) = anchor_cid_rx .await .map_err(|_| anyhow::anyhow!("replication tail died before reporting announce/CID"))?; + // The anchor's issuer is the NODE, not the pusher: `verify_anchor` compares + // the anchor's outer node_did against the embedded certificate's issuer and + // rejects a mismatch, and the certificate is signed with `state.node_keypair`. + // `job.pusher_did` belongs only in the pusher/provenance fields (#224 review). anchor_ref_updates( state, &record, @@ -2356,7 +2379,7 @@ async fn run_post_receive_job( &ref_certs, announce, &cid_map, - did, + &state.node_did.to_string(), ) .await?; Ok(()) @@ -2368,17 +2391,31 @@ async fn run_post_receive_job( /// replication tail that the durability contract covers — Pinata pins, gossip, /// GraphQL broadcast, and peer notify are explicitly best-effort and outside it. /// -/// Failure semantics, per the review: -/// - A transition already anchored (existence check) skips the upload entirely, -/// so a startup replay of an already-anchored job never spends bundler -/// balance on a duplicate on-chain artifact. -/// - A DB error during the existence check is NOT treated as "not anchored": -/// the upload is skipped (fail-closed) so an unknown state never pays for a -/// duplicate upload, and the check error fails the job so the startup drain -/// retries, by which time the check can be answered. -/// - An upload error, or an upload whose row could not be persisted, returns -/// `Err` and fails the job. The drain retries; the existence check then makes -/// the retry a no-op for a transition whose row landed meanwhile. +/// The anchor row is a per-transition outbox/state machine, not a retry wrapper +/// around an HTTP call: +/// +/// - `claim` — an atomic `INSERT ... ON CONFLICT DO NOTHING` against the unique +/// (repo, ref_name, old_sha, new_sha) transition index creates the durable +/// claim BEFORE any paid upload is attempted. Competing workers converge: +/// exactly one INSERT wins, so exactly one worker can ever pay for a given +/// transition. A `recorded` claim is a replay of an already-anchored job and +/// skips the upload entirely. +/// - `prepare` — the signed item is built and its deterministic ANS-104 id +/// (`base64url(sha256(item))`) is persisted on the row BEFORE the request is +/// sent. That id is the durable request identity a crash-recovery probes. +/// - `upload` — the outcome is classified. A definitive provider rejection +/// marks the row `failed` (safe to re-upload later); a connection drop or a +/// malformed success marks nothing and leaves the row `uploading` because the +/// item MAY have been accepted. +/// - `record` — the accepted transaction id is persisted (`recorded`, the +/// terminal state) and `item_id` becomes the id the gateway resolves. +/// +/// Recovery of a non-terminal claim (`pending`/`uploading`/`failed`): if the row +/// carries an `item_id`, the gateway is probed for it BEFORE any re-upload. +/// Present → the earlier upload landed and is recorded as-is (no second paid +/// request); absent → it did not land, re-upload is safe; a probe that cannot +/// reach a verdict fails the job without uploading (fail-closed, no double-pay). +/// A row with no `item_id` was never prepared/sent, so a fresh upload is safe. async fn anchor_ref_updates( state: &AppState, record: &crate::db::RepoRecord, @@ -2403,69 +2440,126 @@ async fn anchor_ref_updates( ); let bundler_account = &state.config.bundler_account; let bundler_token = &state.config.bundler_token; - let now_ts = chrono::Utc::now().to_rfc3339(); for update in ref_updates { let cid = cid_map.get(&update.new_sha).cloned(); // Use the per-update certificate issued above, not a repo-wide latest, // so each anchor embeds the exact certificate for its own ref - // transition. + // transition. Issuance failure already fails the job before this point; + // a missing cert here is a hard error, never a silent skip — anchoring + // without a cert would publish an artifact verify_anchor must reject. let cert = match ref_certs.get(&update.ref_name) { Some(c) => c.clone(), None => { - // Certificate issuance failed for this ref update. Anchoring - // without a cert would produce a permanent artifact that - // verify_anchor must reject — skip instead of publishing an - // unverifiable anchor. - tracing::warn!( - ref_name = %update.ref_name, - "skipping arweave anchor — no certificate was issued" - ); - continue; + return Err(anyhow::anyhow!( + "no certificate was issued for {}/{} — refusing to anchor an \ + unverifiable transition", + repo_slug, + update.ref_name + )); } }; - // #224: a startup replay of this push's job would otherwise re-run the - // upload and pay for a SECOND permanent artifact for the same - // transition. The anchor existence check makes the upload idempotent: - // the exact (repo, ref, old→new) transition already anchored skips the - // upload entirely (the recorded anchor, cert, and tx_id from the - // original run stand). A transient DB error must not read as "not - // anchored" — that would spend bundler balance on a duplicate for a - // transition that may already be anchored — and it must not be skipped - // silently either (that would drop the anchor forever). It fails the - // job: the startup drain retries, by which time the check can be - // answered, and whichever way it lands no money has been wasted and no - // anchor has been lost. - match state + let claim_token = uuid::Uuid::new_v4().to_string(); + let claimed_at = chrono::Utc::now().to_rfc3339(); + // Atomic claim BEFORE any paid upload. This is the durable per-transition + // outbox state; the unique transition index makes concurrent workers + // converge on a single owner for the upload obligation. + let claim = state .db - .arweave_anchor_exists( - &repo_slug, - &update.ref_name, - &update.old_sha, - &update.new_sha, - ) + .claim_anchor_claim(&crate::db::ClaimAnchorInput { + repo: &repo_slug, + owner_did: &record.owner_did, + ref_name: &update.ref_name, + old_sha: &update.old_sha, + new_sha: &update.new_sha, + cid: cid.as_deref(), + node_did, + cert_id: Some(&cert.id), + claim_token: &claim_token, + claimed_at: &claimed_at, + }) .await - { - Ok(true) => { + .map_err(|e| { + anyhow::anyhow!( + "cannot claim arweave anchor for {}/{}: {e}", + repo_slug, + update.ref_name + ) + })?; + let claim_id = match claim { + crate::db::AnchorClaim::AlreadyRecorded => { tracing::debug!( repo = %repo_slug, ref_name = %update.ref_name, - old_sha = %update.old_sha, - new_sha = %update.new_sha, - "skipping arweave anchor — transition already anchored" + "skipping arweave anchor — transition already recorded" ); continue; } - Ok(false) => {} - Err(e) => { - return Err(anyhow::anyhow!( - "cannot check whether {}/{} is already anchored: {e} — \ - failing the job so the startup drain retries; the upload is \ - skipped until the check can be answered", - repo_slug, - update.ref_name - )); + crate::db::AnchorClaim::Claimed { id } => id, + crate::db::AnchorClaim::Recover { + id, + state: recovered_state, + item_id, + } => { + tracing::debug!( + repo = %repo_slug, + ref_name = %update.ref_name, + state = %recovered_state, + "recovering non-terminal arweave anchor claim" + ); + // A previous attempt of this same transition did not reach + // `recorded`. Reconcile BEFORE any re-upload: an `item_id` that + // is already on the gateway means the earlier upload landed and + // we must not pay for a second artifact. + match item_id { + None => id, + Some(persisted_item) => { + match crate::arweave::anchor_item_present( + &state.http_client, + &state.config.arweave_gateway, + &persisted_item, + ) + .await + { + Ok(true) => { + state + .db + .record_claimed_anchor(&id, &persisted_item) + .await + .map_err(|e| { + anyhow::anyhow!( + "recovered arweave anchor {persisted_item} for \ + {}/{} but could not persist it: {e}", + repo_slug, + update.ref_name + ) + })?; + tracing::info!( + tx_id = %persisted_item, + repo = %repo_slug, + ref_name = %update.ref_name, + "recovered already-uploaded arweave anchor without re-uploading" + ); + continue; + } + Ok(false) => id, + Err(e) => { + // Fail closed: the gateway could not be queried, + // so we cannot know whether an upload happened. + // Failing the job (no upload) keeps the drain + // retrying until the probe can be answered. + return Err(anyhow::anyhow!( + "cannot reconcile possibly-uploaded arweave anchor for \ + {}/{} (item {persisted_item}): {e} — failing the job \ + without uploading so no second paid artifact is created", + repo_slug, + update.ref_name + )); + } + } + } + } } - } + }; let anchor = crate::arweave::RefAnchor { repo: repo_slug.clone(), repo_id: record.id.clone(), @@ -2474,53 +2568,82 @@ async fn anchor_ref_updates( old_sha: update.old_sha.clone(), new_sha: update.new_sha.clone(), cid: cid.clone(), - timestamp: now_ts.clone(), + timestamp: claimed_at.clone(), node_did: node_did.to_string(), certificate: Some(cert.clone()), }; - let tx_id = crate::arweave::anchor_ref_update( + // Build the signed item, persist its deterministic id, then send. + let item = + crate::arweave::build_ref_anchor_item(&anchor, &state.node_keypair).map_err(|e| { + anyhow::anyhow!( + "failed to build arweave anchor for {}/{}: {e}", + repo_slug, + update.ref_name + ) + })?; + let item_id = crate::ans104::data_item_id(&item); + state + .db + .set_anchor_uploading(&claim_id, &item_id) + .await + .map_err(|e| { + anyhow::anyhow!( + "cannot mark arweave anchor uploading for {}/{}: {e}", + repo_slug, + update.ref_name + ) + })?; + let outcome = crate::arweave::upload_ref_anchor_item( &state.http_client, bundler_url, bundler_account, bundler_token, - &anchor, - &state.node_keypair, + &item, ) .await .map_err(|e| { - // A push must never fail over anchoring, but this is a durable job - // retried by the startup drain, not the live push. Name the two - // common causes (unfunded bundler account, config only checks it - // at boot) so operators can tell them apart in the job's error. anyhow::anyhow!( - "arweave anchor for {}/{} failed: {e} — if the bundler reports \ - 'Not enough balance', fund GITLAWB_BUNDLER_ACCOUNT (for the token in \ - GITLAWB_BUNDLER_TOKEN); an unfunded node retries and loses anchors forever", + "arweave anchor upload for {}/{} could not be classified: {e}", repo_slug, update.ref_name ) })?; - if tx_id.is_empty() { - continue; - } - // Upload succeeded — the DB row is the only durable record of it. A - // failed insert is a FAILED UNIT OF WORK, not a warning: returning Err - // fails the job, and the startup drain retries. On retry the existence - // check skips the upload if the row landed (e.g. another instance - // recorded it), and fails closed while it cannot be checked. + let tx_id = match outcome { + crate::arweave::UploadOutcome::Accepted { tx_id } => tx_id, + crate::arweave::UploadOutcome::Rejected { message } => { + // The provider definitively did not accept the item; a later + // drain re-uploads. The row stays reserved for that drain. + let _ = state.db.set_anchor_failed(&claim_id).await; + return Err(anyhow::anyhow!( + "arweave anchor for {}/{} rejected by the bundler: {message} — if the \ + bundler reports 'Not enough balance', fund GITLAWB_BUNDLER_ACCOUNT \ + (for the token in GITLAWB_BUNDLER_TOKEN); an unfunded node retries \ + and loses anchors forever", + repo_slug, + update.ref_name + )); + } + crate::arweave::UploadOutcome::Uncertain { message } => { + // The outcome is unknown (connection drop or malformed success): + // the item MAY have been accepted. Leave the row `uploading` and + // fail the job; the drain's recovery probes the gateway and + // records without re-uploading if the item landed. + return Err(anyhow::anyhow!( + "arweave anchor for {}/{} has an unknown upload outcome: {message} — \ + the startup drain will probe the gateway before deciding whether \ + to re-upload", + repo_slug, + update.ref_name + )); + } + }; + // Upload accepted — persist the durable terminal state. A failed UPDATE + // is a FAILED UNIT OF WORK: the row stays `uploading` with its item_id, + // so the drain's recovery probes the gateway and records the already- + // landed item without paying for a second artifact. state .db - .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { - repo: &repo_slug, - owner_did: &record.owner_did, - ref_name: &update.ref_name, - old_sha: &update.old_sha, - new_sha: &update.new_sha, - cid: cid.as_deref(), - arweave_tx_id: &tx_id, - node_did, - cert_id: Some(cert.id.clone()), - }) + .record_claimed_anchor(&claim_id, &tx_id) .await .map_err(|e| { anyhow::anyhow!( @@ -10389,6 +10512,50 @@ mod tests { (format!("http://{addr}"), calls) } + /// A mock Arweave gateway that answers item-presence probes (`GET + /// /{item_id}`). Modes: `present` (200 — the earlier upload landed), + /// `absent` (404 — it did not), `error` (500 — no verdict). Returns the base + /// URL and a probe counter. + async fn f2a_gateway( + mode: &'static str, + ) -> (String, std::sync::Arc) { + use axum::response::IntoResponse; + use std::sync::atomic::Ordering; + + let probes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let app = { + let probes_srv = probes.clone(); + axum::Router::new().route( + "/{item_id}", + axum::routing::get(move |path: axum::extract::Path| { + let probes = probes_srv.clone(); + async move { + probes.fetch_add(1, Ordering::SeqCst); + match mode { + "present" => ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ "id": path.0 })), + ) + .into_response(), + "absent" => (axum::http::StatusCode::NOT_FOUND, "{}").into_response(), + _ => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "simulated gateway outage", + ) + .into_response(), + } + } + }), + ) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), probes) + } + async fn f2a_job_status(pool: &sqlx::PgPool, job_id: &str) -> String { sqlx::query_scalar::<_, String>("SELECT status FROM post_receive_jobs WHERE id = $1") .bind(job_id) @@ -10442,17 +10609,20 @@ mod tests { } } - /// #224 review, P4: an upload that succeeds but whose DB row cannot be - /// written is a FAILED unit of work. The job body must return `Err` — the - /// job is then `failed`, not `done`, and the startup drain retries it — and - /// the retry must not re-call the bundler once the row exists. The CHECK - /// constraint makes the INSERT fail deterministically while the existence - /// check (a SELECT) keeps working. + /// #224 review, P1-4: an accepted upload whose `recorded` transition cannot + /// be persisted is a FAILED unit of work — the job body returns `Err`, the + /// row is left `uploading` with its item id — and the drain's recovery + /// probes the gateway, finds the item present, and records it WITHOUT paying + /// for a second upload. The CHECK constraint blocks only the + /// `UPDATE ... SET state = 'recorded'` (the claim INSERT and the `uploading` + /// transition both stay allowed), so the failure lands exactly where the + /// real crash does. #[sqlx::test] - async fn anchor_upload_ok_but_db_row_fails_is_retried_without_double_pay(pool: sqlx::PgPool) { + async fn anchor_record_failure_is_reconciled_without_double_pay(pool: sqlx::PgPool) { use clap::Parser as _; let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, probes) = f2a_gateway("present").await; let mut state = crate::test_support::test_state(pool.clone()).await; state.config = std::sync::Arc::new(crate::config::Config::parse_from([ "gitlawb-node", @@ -10463,14 +10633,15 @@ mod tests { "--bundler-token", "matic", "--arweave-gateway", - "https://arweave.net", + &gateway_url, ])); - // Block the row this transition would write (repo slug "zAlice/myrepo") - // while leaving the existence check fully functional. + // Block only the transition to `recorded` for this repo's slug: the + // claim INSERT (`state='pending'`) and the `uploading` UPDATE must both + // succeed so the failure lands exactly where the real crash does. sqlx::query( "ALTER TABLE arweave_anchors ADD CONSTRAINT anchor_test_block \ - CHECK (repo <> 'zAlice/myrepo')", + CHECK (NOT (state = 'recorded' AND repo = 'zAlice/myrepo'))", ) .execute(&pool) .await @@ -10482,8 +10653,9 @@ mod tests { certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); let empty_cid = std::collections::HashMap::new(); - // Run 1: the upload lands but the row cannot be written → Err, so the - // job body would fail the job and the startup drain would retry. + // Run 1: the upload is accepted but the row cannot be recorded → Err, so + // the job body fails the job and the startup drain retries. The row is + // left `uploading` with its item id — the durable trace of the request. let err = anchor_ref_updates( &state, &record, @@ -10494,27 +10666,33 @@ mod tests { "did:key:zNode", ) .await - .expect_err("a row-less successful upload must fail the job body"); + .expect_err("an unrecordable accepted upload must fail the job body"); assert!( err.to_string().contains("could not persist"), "the error must name the unpersisted upload: {err}" ); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); - assert!( - !state - .db - .arweave_anchor_exists( - "zAlice/myrepo", - "refs/heads/main", - &"a".repeat(40), - &"b".repeat(40), - ) + let row_state: String = + sqlx::query_scalar("SELECT state FROM arweave_anchors WHERE repo = 'zAlice/myrepo'") + .fetch_one(&pool) .await - .unwrap(), - "the failed unit must not leave a row" + .unwrap(); + assert_eq!( + row_state, "uploading", + "the accepted-but-unrecorded upload must leave the row uploading" + ); + let item_id: String = + sqlx::query_scalar("SELECT item_id FROM arweave_anchors WHERE repo = 'zAlice/myrepo'") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + !item_id.is_empty(), + "the unrecorded upload must still carry its persisted item id" ); - // Unblock, then the drain-style retry succeeds and records the anchor. + // Unblock; the drain-style retry finds a non-terminal claim, probes the + // gateway, sees the item, and records it without uploading again. sqlx::query("ALTER TABLE arweave_anchors DROP CONSTRAINT anchor_test_block") .execute(&pool) .await @@ -10529,8 +10707,17 @@ mod tests { "did:key:zNode", ) .await - .expect("the retried unit must succeed once the row can be written"); - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + .expect("the reconciled retry must succeed once the row can be recorded"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "an item the gateway already has must not be uploaded a second time" + ); + assert_eq!( + probes.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the recovery must probe the gateway exactly once" + ); assert!( state .db @@ -10542,10 +10729,10 @@ mod tests { ) .await .unwrap(), - "the retry must record the anchor" + "the reconciled retry must record the anchor" ); - // Replay with the row present: the existence check skips the upload, so + // Replay with the row recorded: the claim itself says AlreadyRecorded, so // the bundler is NOT called again (no second paid on-chain artifact). anchor_ref_updates( &state, @@ -10557,24 +10744,24 @@ mod tests { "did:key:zNode", ) .await - .expect("an already-anchored transition is a no-op"); + .expect("an already-recorded transition is a no-op"); assert_eq!( calls.load(std::sync::atomic::Ordering::SeqCst), - 2, - "an already-anchored transition must not spend bundler balance again" + 1, + "an already-recorded transition must not spend bundler balance again" ); } - /// #224 review, P5: an unknown existence state must never pay for a - /// duplicate upload. When the existence check itself cannot be answered - /// (closed pool), the upload is skipped (fail-closed) and the job body - /// fails so the startup drain retries; the bundler is never called while - /// the state is unknown. + /// #224 review, P1-4: a worker that cannot even make its atomic claim (DB + /// down) must fail closed — the job body returns `Err`, the bundler is never + /// called, and nothing is uploaded while the durable state cannot be + /// consulted. #[sqlx::test] - async fn anchor_existence_check_failure_never_uploads(pool: sqlx::PgPool) { + async fn anchor_claim_db_failure_never_uploads(pool: sqlx::PgPool) { use clap::Parser as _; let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, _probes) = f2a_gateway("absent").await; let mut state = crate::test_support::test_state(pool.clone()).await; state.config = std::sync::Arc::new(crate::config::Config::parse_from([ "gitlawb-node", @@ -10585,7 +10772,7 @@ mod tests { "--bundler-token", "matic", "--arweave-gateway", - "https://arweave.net", + &gateway_url, ])); let record = f2a_anchor_record(); @@ -10594,7 +10781,7 @@ mod tests { certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); let empty_cid = std::collections::HashMap::new(); - // Take the DB away: the existence check can no longer be answered. + // Take the DB away: the claim can no longer be answered. pool.close().await; let err = anchor_ref_updates( @@ -10607,23 +10794,119 @@ mod tests { "did:key:zNode", ) .await - .expect_err("an unanswerable existence check must fail the job body"); + .expect_err("an unclaimable anchor must fail the job body"); + assert!( + err.to_string().contains("cannot claim"), + "the error must name the unclaimable anchor: {err}" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "an unknown durable state must never trigger a paid upload" + ); + } + + /// #224 review, P1-4: a recovery probe that cannot reach a verdict (gateway + /// 500) fails closed — the job body returns `Err`, the row stays + /// non-terminal, and the bundler is NOT called, because an upload MAY have + /// landed and a second one would be a duplicate paid artifact. + #[sqlx::test] + async fn anchor_probe_failure_never_uploads(pool: sqlx::PgPool) { + use clap::Parser as _; + + let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, probes) = f2a_gateway("error").await; + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + &gateway_url, + ])); + + let record = f2a_anchor_record(); + let update = f2a_anchor_update(); + let mut certs = std::collections::HashMap::new(); + certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); + let empty_cid = std::collections::HashMap::new(); + + // Simulate a prior crash between "upload accepted" and "recorded": a + // non-terminal claim with a persisted item id. + let claim = state + .db + .claim_anchor_claim(&crate::db::ClaimAnchorInput { + repo: "zAlice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: None, + node_did: "did:key:zNode", + cert_id: Some("cert-anchor-1"), + claim_token: "claim-token", + claimed_at: &chrono::Utc::now().to_rfc3339(), + }) + .await + .unwrap(); + let claim_id = match claim { + crate::db::AnchorClaim::Claimed { id } => id, + other => panic!("expected a fresh claim, got {other:?}"), + }; + state + .db + .set_anchor_uploading(&claim_id, "item-probe-123") + .await + .unwrap(); + + let err = anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect_err("a probe that cannot reach a verdict must fail the job body"); assert!( - err.to_string().contains("already anchored"), - "the error must name the unanswerable check: {err}" + err.to_string().contains("cannot reconcile"), + "the error must name the unresolved reconciliation: {err}" ); assert_eq!( calls.load(std::sync::atomic::Ordering::SeqCst), 0, - "an unknown existence state must never trigger a paid upload" + "an unknown upload outcome must never pay for a second artifact" + ); + assert_eq!( + probes.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the failed job must still have probed the gateway once" + ); + let row_state: String = + sqlx::query_scalar("SELECT state FROM arweave_anchors WHERE repo = 'zAlice/myrepo'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row_state, "uploading", + "an unresolved reconciliation must leave the row non-terminal, not recorded" ); } - /// #224 review, P4 end-to-end: a post-receive job whose Arweave anchor + /// #224 review, P1-4 end-to-end: a post-receive job whose Arweave anchor /// upload fails (bundler returns 500) is NOT terminal — the startup drain - /// retries it — and once the anchor row exists, replaying the job never - /// re-calls the bundler. Drives the same crash fixture as - /// `post_receive_job_survives_handler_abort`, with a counting bundler. + /// retries it. The retry's recovery probes the gateway, sees the rejected + /// item was never indexed, re-uploads, and records the anchor; once the row + /// is recorded, replaying the job never re-calls the bundler. Also asserts + /// the stored anchor names the NODE as issuer (state.node_did), not the + /// pusher (#224 review, P1-2). Drives the same crash fixture as + /// `post_receive_job_survives_handler_abort`, with counting mocks. #[cfg(unix)] #[sqlx::test] async fn post_receive_job_anchor_failure_retries_and_replay_never_reuploads( @@ -10646,8 +10929,10 @@ mod tests { .await .unwrap(); - // First upload fails (500), then the bundler behaves. + // First upload fails (500), then the bundler behaves; the gateway says + // the rejected item was never indexed. let (bundler_url, calls) = f2a_bundler(1).await; + let (gateway_url, probes) = f2a_gateway("absent").await; state.config = std::sync::Arc::new(crate::config::Config::parse_from([ "gitlawb-node", "--bundler-url", @@ -10657,7 +10942,7 @@ mod tests { "--bundler-token", "matic", "--arweave-gateway", - "https://arweave.net", + &gateway_url, ])); let (_, repo_path) = state @@ -10691,25 +10976,29 @@ mod tests { }; state.db.enqueue_post_receive_job(&job).await.unwrap(); - // Run 1: the bundler fails the upload, so the anchor unit fails and the + // Run 1: the bundler rejects the upload, so the anchor unit fails and the // job is NOT done — it stays `failed` for the startup drain to retry. process_post_receive_job(state.clone(), job.clone()).await; assert_eq!( f2a_job_status(&pool, &job.id).await, "failed", - "a job whose anchor upload failed must not be terminal" + "a job whose anchor upload was rejected must not be terminal" ); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); - assert!( - !state - .db - .arweave_anchor_exists(&f2a_slug(&rec), "refs/heads/main", ZERO_SHA, &c1) - .await - .unwrap(), - "a failed anchor must not leave a row" + let row_state: String = sqlx::query_scalar( + "SELECT state FROM arweave_anchors WHERE repo = $1 AND ref_name = 'refs/heads/main'", + ) + .bind(f2a_slug(&rec)) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row_state, "failed", + "a definitively rejected upload must leave the row failed" ); - // Drain retry: the bundler now succeeds, the anchor row lands, `done`. + // Drain retry: recovery probes the gateway, sees the item absent, + // re-uploads (the bundler now behaves), records the anchor, `done`. state.db.reset_stale_post_receive_jobs().await.unwrap(); let pending = state.db.list_pending_post_receive_jobs().await.unwrap(); assert_eq!(pending.len(), 1, "the failed job must be drained"); @@ -10718,6 +11007,10 @@ mod tests { } assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert!( + probes.load(std::sync::atomic::Ordering::SeqCst) >= 1, + "the recovery must probe the gateway before deciding to re-upload" + ); assert!( state .db @@ -10727,8 +11020,30 @@ mod tests { "the retried anchor must be recorded" ); - // Replay with the row present: the existence gate skips the upload, so - // the bundler is never called again. + // The stored anchor names the NODE as issuer (state.node_did), not the + // pusher whose push triggered the job (#224 review, P1-2). + let stored_node: String = sqlx::query_scalar( + "SELECT node_did FROM arweave_anchors + WHERE repo = $1 AND ref_name = 'refs/heads/main' AND old_sha = $2 AND new_sha = $3", + ) + .bind(f2a_slug(&rec)) + .bind(ZERO_SHA) + .bind(&c1) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + stored_node, + state.node_did.to_string(), + "the anchor must be issued by the node's own DID" + ); + assert_ne!( + stored_node, F2A_PUSHER, + "the pusher must not be recorded as the anchor issuer" + ); + + // Replay with the row recorded: the claim says AlreadyRecorded, so the + // bundler is never called again. process_post_receive_job(state.clone(), job.clone()).await; assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); assert_eq!( @@ -10737,4 +11052,95 @@ mod tests { "replaying an anchored job must not pay for a second upload" ); } + + /// #224 review, P1-4: two workers processing the SAME job concurrently must + /// converge on a single executor. The atomic conditional claim lets exactly + /// one win; the loser's claim updates zero rows and it skips the body. The + /// bundler is called exactly once and the anchor is recorded exactly once. + #[sqlx::test] + async fn two_concurrent_workers_claim_the_job_once(pool: sqlx::PgPool) { + use clap::Parser as _; + + let bin = tempfile::TempDir::new().unwrap(); + let log = bin.path().join("git.log"); + let git_bin = f2a_logging_git(bin.path(), &log); + let (mut state, rec) = f2a_state(pool.clone(), &git_bin, "z6anchor", "a2", false).await; + let repos_dir = tempfile::TempDir::new().unwrap(); + state.repo_store = crate::git::repo_store::RepoStore::for_testing( + repos_dir.path().to_path_buf(), + pool.clone(), + ); + state + .db + .register_agent(F2A_PUSHER, &["agent".to_string()]) + .await + .unwrap(); + + let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, _probes) = f2a_gateway("absent").await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + &gateway_url, + ])); + + let (_, repo_path) = state + .repo_store + .local_path(&rec.owner_did, &rec.name) + .unwrap(); + std::fs::create_dir_all(&repo_path).unwrap(); + u5_init_repo(&repo_path); + let c1 = u5_commit_file(&repo_path, "a.txt", "one\n"); + + let update = f2a_update("refs/heads/main", &c1); + let job = crate::db::PostReceiveJob { + id: uuid::Uuid::new_v4().to_string(), + pusher_did: F2A_PUSHER.to_string(), + owner_did: rec.owner_did.clone(), + repo_name: rec.name.clone(), + repo_id: rec.id.clone(), + ref_updates: update + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation::default(), + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + state.db.enqueue_post_receive_job(&job).await.unwrap(); + + // Two drainers race on the same job; the conditional claim lets only one + // run the body. + let ((), ()) = tokio::join!( + process_post_receive_job(state.clone(), job.clone()), + process_post_receive_job(state.clone(), job.clone()), + ); + + assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "only the claiming worker may run the job body" + ); + assert!( + state + .db + .arweave_anchor_exists(&f2a_slug(&rec), "refs/heads/main", ZERO_SHA, &c1) + .await + .unwrap(), + "the anchor must be recorded exactly once" + ); + } } diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 2b7d8a23..3c574724 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -25,11 +25,18 @@ //! - devnet (faucet-funded): https://devnet.irys.xyz //! - mainnet: https://node2.irys.xyz //! -//! Configure `GITLAWB_ARWEAVE_GATEWAY` to override the gateway used for resolving anchors -//! (defaults to https://arweave.net). +//! `GITLAWB_ARWEAVE_GATEWAY` has NO default. An anchoring node MUST set it to a +//! gateway on the SAME network as the bundler (devnet → the devnet gateway, +//! mainnet → https://arweave.net): the old implicit arweave.net default paired +//! the gateway to the bundler URL and made /verify fail for devnet +//! transactions, which arweave.net cannot resolve. `Config::validate()` refuses +//! to start with a bundler configured but no explicit gateway; a node that +//! does not anchor may leave the gateway unset (existing recorded anchors stay +//! durable and listable, but carry no presentation URL). //! -//! Each anchor returns a transaction ID (43-char base58 string). -//! The permanent Arweave URL is: / +//! Each anchor returns a transaction ID (43-char base64url) that is the +//! content-derived id of the signed data item. The permanent Arweave URL is: +//! / //! //! Anchors are stored in the `arweave_anchors` table for auditability. use anyhow::Result; @@ -57,24 +64,46 @@ pub struct RefAnchor { /// serialized and embedded so a verifier can validate the chain. pub certificate: Option, } -/// Anchor a ref-update to Arweave via Irys. +/// Validate an Arweave transaction / data-item ID: 43-character base64url. +/// This is the expected wire format for both a bundler's `{"id": ...}` response +/// and the id under which a gateway resolves a data item. The durable job and +/// the public `/verify` endpoint share this boundary so a malformed id is +/// rejected the same way everywhere. +pub(crate) fn is_valid_tx_id(tx_id: &str) -> bool { + if tx_id.len() != 43 { + return false; + } + tx_id + .bytes() + .all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')) +} + +/// Classified outcome of a bundler upload, so the durable job can decide +/// whether a retry may safely pay for another upload (#224 review): /// -/// The payload is uploaded as a signed ANS-104 data item: `node_keypair` signs -/// the item and the indexing metadata (App-Name, Schema, Repo, Ref, SHA, -/// Node-DID) is embedded as data-item tags inside the signed item — never in a -/// request header. Returns the Irys/Arweave transaction ID on success. -/// Returns `Ok("")` if `bundler_url` is empty (anchoring disabled). -pub async fn anchor_ref_update( - client: &reqwest::Client, - bundler_url: &str, - bundler_account: &str, - bundler_token: &str, +/// - [`UploadOutcome::Accepted`] — the provider returned a well-formed +/// transaction id; the item is permanently accepted. +/// - [`UploadOutcome::Rejected`] — the provider returned a definitive +/// non-acceptance (HTTP error body). The item was NOT accepted, so a retry +/// may re-upload safely. +/// - [`UploadOutcome::Uncertain`] — the request failed before a verdict +/// (connection drop, or a success response that did not carry a valid id). +/// The item MAY have been accepted; a retry must reconcile via the gateway +/// probe before issuing another paid request, never re-upload blindly. +#[derive(Debug)] +pub enum UploadOutcome { + Accepted { tx_id: String }, + Rejected { message: String }, + Uncertain { message: String }, +} + +/// Build the signed ANS-104 data item for a ref-update anchor. The metadata is +/// embedded as tags inside the item (where the bundler verifies them against +/// the signature); nothing is passed out-of-band. +pub(crate) fn build_ref_anchor_item( anchor: &RefAnchor, node_keypair: &gitlawb_core::identity::Keypair, -) -> Result { - if bundler_url.is_empty() { - return Ok(String::new()); - } +) -> Result> { let mut payload = json!({ "schema": "gitlawb/ref-update/v1", "repo": anchor.repo, @@ -107,55 +136,127 @@ pub async fn anchor_ref_update( (name.to_string(), value.to_string()) }) .collect(); - let data_item = crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body)?; + crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body) +} + +/// Upload a signed ANS-104 data item to the bundler and classify the outcome. +/// The caller supplies the already-signed item so it can persist the item's +/// deterministic id ([`crate::ans104::data_item_id`]) BEFORE the request is +/// sent — that is the durable request identity a crash-recovery probes. +pub async fn upload_ref_anchor_item( + client: &reqwest::Client, + bundler_url: &str, + bundler_account: &str, + bundler_token: &str, + item: &[u8], +) -> Result { // Irys upload target: {bundler_url}/tx/{token}. Built structurally so a // query on the base URL is preserved and a fragment is rejected outright. let url = bundler_upload_url(bundler_url, bundler_token)?; let display_url = crate::server::mask_credential_url(&url); - let resp = client + let resp = match client .post(&url) .header("Content-Type", "application/octet-stream") .header("x-irys-paid-by", bundler_account) - .body(data_item) + .body(item.to_vec()) .send() .await - .map_err(|e| remote_send_error("Bundler upload failed", &e, &url, &display_url))?; + { + Ok(r) => r, + Err(e) => { + // The request did not reach a verdict: the item MAY have been + // accepted. The message is already redacted/masked. + return Ok(UploadOutcome::Uncertain { + message: remote_send_error("Bundler upload failed", &e, &url, &display_url) + .to_string(), + }); + } + }; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(remote_response_error( + let message = remote_response_error( "Bundler upload", &status, &body, &url, &display_url, &[bundler_account, bundler_token], - )); + ) + .to_string(); + return Ok(UploadOutcome::Rejected { message }); } - let json: serde_json::Value = resp - .json() - .await - .map_err(|e| anyhow::anyhow!("failed to parse Bundler response: {e}"))?; + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + // Success status but no parseable body: outcome unknown. Treating a + // malformed success as `Accepted` would let a misbehaving bundler + // turn a required anchor into a silent no-op; treating it as + // `Rejected` would risk a second paid artifact if the item landed. + return Ok(UploadOutcome::Uncertain { + message: format!("failed to parse Bundler response: {e}"), + }); + } + }; // Bundler response: {"id": "", "timestamp": ..., "version": ...} - let tx_id = json["id"] - .as_str() - .ok_or_else(|| { - anyhow::anyhow!( - "no 'id' in Bundler response: {}", - truncate_for_error(&json.to_string(), 512) - ) - })? - .to_string(); - tracing::info!( - repo = %anchor.repo, - ref_name = %anchor.ref_name, - new_sha = %anchor.new_sha, - tx_id = %tx_id, - bundler_account = %bundler_account, - bundler_token = %bundler_token, - "anchored ref update to Arweave via bundler" - ); - Ok(tx_id) + // The id must be a well-formed non-empty Arweave id; an empty/malformed + // success response is an Uncertain outcome, not success (#224 review). + let tx_id = match json["id"].as_str() { + Some(id) if is_valid_tx_id(id) => id.to_string(), + _ => { + return Ok(UploadOutcome::Uncertain { + message: format!( + "Bundler returned a malformed transaction id in its success response: {}", + truncate_for_error(&json.to_string(), 512) + ), + }); + } + }; + Ok(UploadOutcome::Accepted { tx_id }) +} + +/// Anchor a ref-update to Arweave via Irys. +/// +/// The payload is uploaded as a signed ANS-104 data item: `node_keypair` signs +/// the item and the indexing metadata (App-Name, Schema, Repo, Ref, SHA, +/// Node-DID) is embedded as data-item tags inside the signed item — never in a +/// request header. Returns the Irys/Arweave transaction ID on success. +/// Returns `Ok("")` if `bundler_url` is empty (anchoring disabled). +/// +/// The durable post-receive job does not call this directly: it drives +/// [`build_ref_anchor_item`] + [`upload_ref_anchor_item`] so it can persist the +/// item id before the request and classify the outcome. This thin wrapper keeps +/// the manifest/tail call sites and tests on a `Result` contract. +#[cfg(test)] +pub async fn anchor_ref_update( + client: &reqwest::Client, + bundler_url: &str, + bundler_account: &str, + bundler_token: &str, + anchor: &RefAnchor, + node_keypair: &gitlawb_core::identity::Keypair, +) -> Result { + if bundler_url.is_empty() { + return Ok(String::new()); + } + let item = build_ref_anchor_item(anchor, node_keypair)?; + match upload_ref_anchor_item(client, bundler_url, bundler_account, bundler_token, &item).await? + { + UploadOutcome::Accepted { tx_id } => { + tracing::info!( + repo = %anchor.repo, + ref_name = %anchor.ref_name, + new_sha = %anchor.new_sha, + tx_id = %tx_id, + bundler_account = %bundler_account, + bundler_token = %bundler_token, + "anchored ref update to Arweave via bundler" + ); + Ok(tx_id) + } + UploadOutcome::Rejected { message } => Err(anyhow::anyhow!(message)), + UploadOutcome::Uncertain { message } => Err(anyhow::anyhow!(message)), + } } /// A per-push manifest of the blobs encrypted this push (Option B3). The /// `blobs` slice is `(oid, cid)` tuples. Anchored directly to Arweave as its JSON @@ -175,7 +276,7 @@ pub struct EncryptedManifest<'a> { /// recipients, so the reader set must not be written to Arweave either. /// /// The manifest is uploaded as a signed ANS-104 data item (same scheme as -/// [`anchor_ref_update`]); the discovery tags are embedded inside the item. +/// `anchor_ref_update`); the discovery tags are embedded inside the item. /// /// Returns the Arweave transaction ID, or `Ok("")` when `bundler_url` is empty /// (anchoring disabled) or there are no blobs to anchor. @@ -246,15 +347,18 @@ pub async fn anchor_encrypted_manifest( .json() .await .map_err(|e| anyhow::anyhow!("failed to parse Bundler response: {e}"))?; - let tx_id = json["id"] - .as_str() - .ok_or_else(|| { - anyhow::anyhow!( - "no 'id' in Bundler response: {}", + // Bundler response: {"id": "", "timestamp": ..., "version": ...} + // A success without a well-formed, non-empty id must be an error (never a + // silent no-op), so a malformed success cannot fake an anchor (#224 review). + let tx_id = match json["id"].as_str() { + Some(id) if is_valid_tx_id(id) => id.to_string(), + _ => { + return Err(anyhow::anyhow!( + "Bundler returned a malformed transaction id in its success response: {}", truncate_for_error(&json.to_string(), 512) - ) - })? - .to_string(); + )); + } + }; tracing::info!( repo = %manifest.repo, tx_id = %tx_id, @@ -325,6 +429,42 @@ fn bundler_upload_url(bundler_url: &str, token: &str) -> Result { fn gateway_tx_url(gateway_url: &str, tx_id: &str) -> Result { join_url_path(gateway_url, &[tx_id], "gateway URL") } +/// Whether a data item with the given id is resolvable at the configured +/// gateway (`GET {gateway}/{id}`). This is the reconciliation probe a durable +/// job uses to decide whether a crashed upload actually landed before issuing +/// a second paid request (#224 review): present → record the item id and skip +/// the upload; absent → the earlier upload did not land, re-upload is safe; +/// any other failure to reach a verdict → the caller must fail closed (no +/// upload). A 404/400/410 means the item is absent; a missing gateway means +/// the probe cannot run at all and is an error, never a silent "absent". +pub(crate) async fn anchor_item_present( + client: &reqwest::Client, + gateway_url: &str, + item_id: &str, +) -> Result { + if gateway_url.trim().is_empty() { + return Err(anyhow::anyhow!( + "no GITLAWB_ARWEAVE_GATEWAY configured to reconcile a possibly-uploaded anchor" + )); + } + let url = gateway_tx_url(gateway_url, item_id)?; + let display_url = crate::server::mask_credential_url(&url); + let resp = + client.get(&url).send().await.map_err(|e| { + remote_send_error("Arweave gateway probe failed", &e, &url, &display_url) + })?; + if resp.status().is_success() { + return Ok(true); + } + match resp.status() { + reqwest::StatusCode::NOT_FOUND + | reqwest::StatusCode::BAD_REQUEST + | reqwest::StatusCode::GONE => Ok(false), + other => Err(anyhow::anyhow!( + "Arweave gateway probe returned {other} for {display_url}" + )), + } +} /// Cap a value for error messages/logs so a hostile or misbehaving endpoint /// cannot drive unbounded allocations or output through an error string. fn truncate_for_error(s: &str, max: usize) -> String { @@ -1130,6 +1270,73 @@ mod tests { "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk" ); } + /// #224 review, P2: the client validates the bundler's success id at the + /// boundary. An empty, missing, or malformed transaction id in a 200 + /// response must NOT read as success — it is Uncertain (the item may or may + /// not have been accepted), so the durable job probes the gateway instead of + /// recording a fabricated anchor. Only a well-formed 43-char base64url id is + /// Accepted. + #[tokio::test] + async fn test_upload_rejects_empty_missing_and_malformed_success_ids() { + use axum::response::IntoResponse; + use std::sync::atomic::Ordering; + + async fn mock_bundler( + body: &'static str, + ) -> (String, std::sync::Arc) { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let app = { + let calls_srv = calls.clone(); + axum::Router::new().route( + "/tx/matic", + axum::routing::post(move || { + let calls = calls_srv.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + (axum::http::StatusCode::OK, body).into_response() + } + }), + ) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), calls) + } + + let client = reqwest::Client::new(); + let item = b"signed-data-item-bytes".to_vec(); + + for (label, body) in [ + ("empty id", r#"{"id":""}"#), + ("missing id", r#"{"foo":"bar"}"#), + ("malformed id", r#"{"id":"WAY_TOO_SHORT"}"#), + ] { + let (server, calls) = mock_bundler(body).await; + let outcome = + upload_ref_anchor_item(&client, &server, "zBundlerAccount", "matic", &item) + .await + .unwrap(); + assert!( + matches!(outcome, UploadOutcome::Uncertain { .. }), + "{label} must classify as Uncertain: {outcome:?}" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + // The well-formed case still lands as Accepted. + let (server, _calls) = + mock_bundler(r#"{"id":"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"}"#).await; + let outcome = upload_ref_anchor_item(&client, &server, "zBundlerAccount", "matic", &item) + .await + .unwrap(); + assert!( + matches!(&outcome, UploadOutcome::Accepted { tx_id } if tx_id == "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"), + "a well-formed id must be Accepted: {outcome:?}" + ); + } /// The funded bundler account must ride on the upload request: the item /// signature is authorship, not payment, so an upload that omits the /// account must be refused — it would otherwise be billed to nobody. @@ -1181,7 +1388,7 @@ mod tests { "/tx/matic", &[("App-Name", "gitlawb")], move |j| j["old_sha"] == real_old && j["new_sha"] == real_new, - "TX_REAL_OLD_SHA", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO1", ) .await; let client = reqwest::Client::new(); @@ -1199,7 +1406,10 @@ mod tests { }; let result = anchor_ref_update(&client, &server, "zBundlerAccount", "matic", &anchor, &kp).await; - assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA"); + assert_eq!( + result.unwrap(), + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO1" + ); } #[tokio::test] async fn test_anchor_rejected_when_signed_by_other_key() { @@ -1308,7 +1518,7 @@ mod tests { ("Node-DID", "did:key:zN"), ], |j| j["repo"] == "alice/r" && j["blobs"].as_array().is_some_and(|b| b.len() == 1), - "MANIFESTTX123", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO2", ) .await; let client = reqwest::Client::new(); @@ -1322,7 +1532,7 @@ mod tests { }; let r = anchor_encrypted_manifest(&client, &server, "zBundlerAccount", "matic", &m, &kp).await; - assert_eq!(r.unwrap(), "MANIFESTTX123"); + assert_eq!(r.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO2"); } /// A minimal ref-update anchor for the URL-join tests. fn test_anchor(repo: &str, new_sha: &str) -> RefAnchor { @@ -1350,7 +1560,7 @@ mod tests { "/prefix/tx/matic", &[("App-Name", "gitlawb")], |_| true, - "PREFIXED_TX", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO3", ) .await; let client = reqwest::Client::new(); @@ -1367,7 +1577,10 @@ mod tests { &kp, ) .await; - assert_eq!(result.unwrap(), "PREFIXED_TX"); + assert_eq!( + result.unwrap(), + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO3" + ); } /// A query on the bundler base must ride along on the upload request target /// (`/tx/matic?token=secret`) rather than being dropped by string concat. @@ -1380,7 +1593,7 @@ mod tests { "/tx/matic?token=secret", &[("App-Name", "gitlawb")], |_| true, - "QUERY_TX", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO4", ) .await; let client = reqwest::Client::new(); @@ -1397,7 +1610,10 @@ mod tests { &kp, ) .await; - assert_eq!(result.unwrap(), "QUERY_TX"); + assert_eq!( + result.unwrap(), + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO4" + ); } /// A fragment in the bundler URL must be rejected outright for both upload /// paths: it is never sent to the bundler, so sending it silently would diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 486540c5..dc9187d7 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -530,9 +530,11 @@ impl Db { // signature_input, content_digest, and request_path are added by v19. // New installs reach v18/v19 via sequential migration; existing installs with // the columns already present are no-ops via IF NOT EXISTS. v20 drops the -// superseded (repo_id, ref_name) unique index that v1 bundled; that drop is +// superseded (repo_id, ref_name) unique index that v10 created; that drop is // one-way and rollback-unsupported (see the migration's own comment). -// v21 adds the durable post-receive job table. +// v21 adds the durable post-receive job table, and v22 turns the +// `arweave_anchors` row into a per-transition durable claim/outbox +// (state, item_id, claim_token) with a unique transition index. // // Each migration runs in a single transaction, so statements that Postgres // forbids inside a transaction (notably `CREATE INDEX CONCURRENTLY`) cannot be @@ -1032,16 +1034,16 @@ const MIGRATIONS: &[Migration] = &[ Migration { version: 20, name: "drop_ref_certs_repo_ref_unique", - // ONE-WAY, ROLLBACK-UNSUPPORTED: this drops the unique index that v1 - // bundled. Rolling back to v19 would require re-creating - // `idx_ref_certs_repo_ref`, which a release built at v20+ cannot do - // (the migration that created it has been superseded). Operators must - // treat v20 as terminal: there is no supported downgrade past it. The - // drop itself is the point of the migration — the old index would - // reject the second cert insert for a ref, which the append-only cert - // chain (v19) requires. + // ONE-WAY, ROLLBACK-UNSUPPORTED: this drops the unique index that v10 + // (ref_cert_unique_per_ref) created. Rolling back to v19 would require + // re-creating `idx_ref_certs_repo_ref`, which a release built at v20+ + // cannot do (the migration that created it has been superseded). + // Operators must treat v20 as terminal: there is no supported downgrade + // past it. The drop itself is the point of the migration — the old + // index would reject the second cert insert for a ref, which the + // append-only cert chain (v19) requires. stmts: &[ - // Remove the superseded (repo_id, ref_name) unique index. v19 makes + // Remove the superseded (repo_id, ref_name) unique index (v10). v19 makes // the cert chain append-only, which requires multiple rows per // (repo_id, ref_name); the unique index would reject the second // insert for a ref. Deferring the drop is impossible for the same @@ -1078,6 +1080,44 @@ const MIGRATIONS: &[Migration] = &[ "CREATE INDEX IF NOT EXISTS idx_post_receive_jobs_status ON post_receive_jobs(status, enqueued_at)", ], }, + // Per-transition Arweave anchor outbox (#224 review): the anchor row IS the + // durable claim. `anchor_ref_updates` atomically INSERTs the transition row + // in `pending` BEFORE any paid upload is attempted, then moves it through + // `uploading` → `recorded` (or `failed`). The unique (repo, ref_name, + // old_sha, new_sha) index makes competing workers converge: only one INSERT + // wins, so only one worker can ever pay for a given transition. `item_id` + // is the ANS-104 data-item id computed from the signed item BEFORE the + // upload request is sent; a recovery that finds the row in `pending`/ + // `uploading` with an `item_id` probes the gateway for that id to decide + // whether the crashed upload actually landed before ever issuing a second + // paid request. `claim_token`/`claimed_at` record who holds the lease. + Migration { + version: 22, + name: "arweave_anchor_outbox", + stmts: &[ + // Existing rows were all uploaded and recorded by earlier code, so + // backfill them as `recorded` (the durable terminal state). + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'recorded'", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS item_id TEXT", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS claim_token TEXT", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS claimed_at TEXT", + // A claimed (pending/uploading) outbox row has no transaction id yet; + // only the recorded row carries one. + "ALTER TABLE arweave_anchors ALTER COLUMN arweave_tx_id DROP NOT NULL", + // Dedup before the unique index: earlier releases had no uniqueness + // on a transition, so an existing database could carry two anchors + // for one (repo, ref, old→new). Keep the earliest recorded row (the + // original artifact) and drop the stragglers' LISTING rows — the + // permanent on-chain artifacts themselves cannot be un-published, + // but the audit table must not block the claim index. + r#"DELETE FROM arweave_anchors a + USING arweave_anchors b + WHERE a.repo = b.repo AND a.ref_name = b.ref_name + AND a.old_sha = b.old_sha AND a.new_sha = b.new_sha + AND (a.anchored_at, a.id) > (b.anchored_at, b.id)"#, + "CREATE UNIQUE INDEX IF NOT EXISTS idx_arweave_anchors_transition ON arweave_anchors(repo, ref_name, old_sha, new_sha)", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -1959,6 +1999,26 @@ impl Db { Ok(()) } + /// Atomically claim a post-receive job for processing. The conditional + /// `WHERE status IN ('pending','failed')` means only one worker wins the + /// claim; a concurrent drainer's claim updates zero rows and it must not + /// run the job body (#224 review: two simultaneous drainers must converge + /// on one executor per job). `done`/`failed` transitions are unconditional + /// because only the claiming worker runs the body. + pub async fn claim_post_receive_job(&self, id: &str) -> Result { + let now = Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE post_receive_jobs + SET status = 'processing', attempted_at = $1, attempts = attempts + 1, error = NULL + WHERE id = $2 AND status IN ('pending', 'failed')", + ) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + /// Advance a job's status. `done` stamps `processed_at`; `processing` /// stamps `attempted_at` and increments `attempts`. `failed` records the /// error so operators can see why a job never completed. @@ -3441,6 +3501,7 @@ pub struct ArweaveAnchor { } /// Input parameters for recording an Arweave anchor. +#[cfg(test)] pub struct RecordAnchorInputV2<'a> { pub repo: &'a str, pub owner_did: &'a str, @@ -3454,7 +3515,51 @@ pub struct RecordAnchorInputV2<'a> { pub cert_id: Option, } +/// Outcome of atomically claiming a per-transition Arweave anchor outbox row +/// (#224 review). The claim row IS the durable per-transition state: it is +/// created BEFORE any paid upload is attempted, so a worker that wins the claim +/// is the only one that can pay for that transition. +#[derive(Debug)] +pub enum AnchorClaim { + /// This worker INSERTed the row (state `pending`); it owns the upload + /// obligation and must drive the row to `recorded`. + Claimed { id: String }, + /// A `recorded` row already exists for this exact transition — a replay of + /// an already-anchored job; nothing to do. + AlreadyRecorded, + /// A row exists in a non-terminal state (`pending`/`uploading`/`failed`). + /// `item_id` is the ANS-104 data-item id persisted before the last upload + /// attempt (`None` when no request was ever prepared/sent). The worker must + /// reconcile it (probe the gateway) before deciding whether another paid + /// upload is safe. + Recover { + id: String, + state: String, + item_id: Option, + }, +} + +/// Everything the claim of a per-transition anchor outbox row needs. Bundled +/// into a struct so the atomic-claim contract stays a single unit rather than +/// a ten-argument call. +pub struct ClaimAnchorInput<'a> { + pub repo: &'a str, + pub owner_did: &'a str, + pub ref_name: &'a str, + pub old_sha: &'a str, + pub new_sha: &'a str, + pub cid: Option<&'a str>, + /// The NODE's DID — the anchor issuer (never the pusher, #224 review). + pub node_did: &'a str, + pub cert_id: Option<&'a str>, + /// Opaque per-claim lease token (for operator forensics on mid-flight rows). + pub claim_token: &'a str, + /// RFC 3339 timestamp of this claim. + pub claimed_at: &'a str, +} + impl Db { + #[cfg(test)] pub async fn record_arweave_anchor(&self, input: &RecordAnchorInputV2<'_>) -> Result<()> { let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); @@ -3478,11 +3583,107 @@ impl Db { Ok(()) } + /// Atomically claim the per-transition anchor outbox row for + /// (repo, ref_name, old_sha, new_sha). The unique transition index makes + /// competing workers converge: exactly one INSERT wins, so exactly one + /// worker can pay for a given transition. A won claim leaves the row in + /// `pending` with a NULL item id — no upload has been attempted. + pub async fn claim_anchor_claim(&self, input: &ClaimAnchorInput<'_>) -> Result { + let id = Uuid::new_v4().to_string(); + let result = sqlx::query( + "INSERT INTO arweave_anchors + (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id, state, item_id, claim_token, claimed_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,NULL,$8,$9,$10,'pending',NULL,$11,$12) + ON CONFLICT (repo, ref_name, old_sha, new_sha) DO NOTHING", + ) + .bind(&id) + .bind(input.repo) + .bind(input.owner_did) + .bind(input.ref_name) + .bind(input.old_sha) + .bind(input.new_sha) + .bind(input.cid) + .bind(input.node_did) + .bind(input.claimed_at) + .bind(input.cert_id) + .bind(input.claim_token) + .bind(input.claimed_at) + .execute(&self.pool) + .await?; + if result.rows_affected() == 1 { + return Ok(AnchorClaim::Claimed { id }); + } + let row = sqlx::query( + "SELECT id, state, item_id FROM arweave_anchors + WHERE repo = $1 AND ref_name = $2 AND old_sha = $3 AND new_sha = $4", + ) + .bind(input.repo) + .bind(input.ref_name) + .bind(input.old_sha) + .bind(input.new_sha) + .fetch_one(&self.pool) + .await?; + let state: String = row.get("state"); + if state == "recorded" { + return Ok(AnchorClaim::AlreadyRecorded); + } + Ok(AnchorClaim::Recover { + id: row.get("id"), + state, + item_id: row.get("item_id"), + }) + } + + /// Move a claimed outbox row to `uploading` and persist the ANS-104 + /// data-item id that the upload request is about to send. Persisting the id + /// BEFORE the request is what lets a crash-recovery probe that id to decide + /// whether the upload landed (#224 review). + pub async fn set_anchor_uploading(&self, id: &str, item_id: &str) -> Result<()> { + sqlx::query("UPDATE arweave_anchors SET state = 'uploading', item_id = $1 WHERE id = $2") + .bind(item_id) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Mark a claimed outbox row `failed` (the provider definitively rejected + /// the upload). The job row carries the error detail; the transition stays + /// reserved so a later drain owns it and re-uploads. + pub async fn set_anchor_failed(&self, id: &str) -> Result<()> { + sqlx::query("UPDATE arweave_anchors SET state = 'failed' WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Persist the accepted upload on a claimed outbox row: `recorded` state, + /// the transaction id the provider returned (also the id the gateway + /// resolves the item under, so it becomes the probe id for later replays), + /// and the anchor timestamp. This is the durable terminal state; a retry + /// that finds it skips the upload entirely. + pub async fn record_claimed_anchor(&self, id: &str, tx_id: &str) -> Result<()> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE arweave_anchors + SET state = 'recorded', arweave_tx_id = $1, item_id = $1, anchored_at = $2 + WHERE id = $3", + ) + .bind(tx_id) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + /// Whether this exact ref transition (same repo slug, ref, old→new SHAs) /// already has a recorded Arweave anchor. The durable post-receive job /// checks this BEFORE uploading, so a startup replay of an already-anchored /// job skips the upload instead of writing a second permanent on-chain /// artifact for the same transition (#224). + #[cfg(test)] pub async fn arweave_anchor_exists( &self, repo: &str, @@ -3513,7 +3714,7 @@ impl Db { let rows = if let Some(repo) = repo { sqlx::query( "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id - FROM arweave_anchors WHERE repo=$1 ORDER BY anchored_at DESC LIMIT $2", + FROM arweave_anchors WHERE repo=$1 AND state = 'recorded' ORDER BY anchored_at DESC LIMIT $2", ) .bind(repo) .bind(limit) @@ -3522,7 +3723,7 @@ impl Db { } else { sqlx::query( "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id - FROM arweave_anchors ORDER BY anchored_at DESC LIMIT $1", + FROM arweave_anchors WHERE state = 'recorded' ORDER BY anchored_at DESC LIMIT $1", ) .bind(limit) .fetch_all(&self.pool) From 386e0a39c0dcbe57014919c6c7f12754a36e8ca0 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 20 Aug 2026 15:15:43 +0600 Subject: [PATCH 25/25] chore(node): bump h2 to 0.4.16 (RUSTSEC-2026-0258) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6233016..57a890e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3542,9 +3542,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes",