Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8c69722
fix(node): implement reconciliation sweep as durability backstop (#218)
Gravirei Jul 24, 2026
b40702a
fix(reconciliation): address review findings
Gravirei Jul 24, 2026
8b91184
fix(reconciliation): keyset pagination and gap tracking
Gravirei Jul 24, 2026
3dba453
fix(tests): replace broken integration test with compile-time gate check
Gravirei Jul 24, 2026
f8ab623
fix(reconciliation): enhance repo visibility checks and manage active…
Gravirei Jul 24, 2026
b0186a4
fix(git): pipe stdout/stderr in GitCommand::output()
Gravirei Jul 25, 2026
f21dff5
coordinate spawn_registered with shared cancellation state
Gravirei Jul 25, 2026
7630d5b
address all code review findings
Gravirei Jul 26, 2026
74c9592
fix(reconciliation): enhance reconciliation sweep configuration and b…
Gravirei Jul 27, 2026
9dc9bd6
fix(db): add node_state table and clean up pinned_cid provenance
Gravirei Aug 7, 2026
7db6754
fix(reconciliation): gate spawn on config, persist cursor, and harden…
Gravirei Aug 7, 2026
c1a91c0
fix(git): release registry lock before waiting and route cat-file via…
Gravirei Aug 7, 2026
7d570be
fix(pin): count only DB-persisted pins as filled
Gravirei Aug 7, 2026
984b38a
fix(api): stop leaking pinata_cid and skip rows with no CID at all
Gravirei Aug 7, 2026
3df2b6a
fix(node): gate sweep-started log on spawn and qualify backstop wording
Gravirei Aug 7, 2026
e5beeb9
docs: document GITLAWB_RECONCILIATION_SWEEP
Gravirei Aug 7, 2026
34f0619
merge: upstream main into issue-218 reconciliation sweep v2
Gravirei Aug 10, 2026
c868820
fix(node): harden reconciliation sweep against visibility narrows
Gravirei Aug 10, 2026
673c6e3
fix(core): reject weak-key Ed25519 signatures with strict verification
Gravirei Aug 12, 2026
9de6859
fix(node): stop leaking sqlx/anyhow detail in 500 error bodies (#226)
Gravirei Aug 12, 2026
810d71c
Merge remote-tracking branch 'upstream/main' into fix/issue-218-recon…
Gravirei Aug 12, 2026
639ebaa
fix(node): fence reconciliation pin batches against mid-batch policy …
Gravirei Aug 15, 2026
b44c951
fix(node): hold one pin permit per repo, capture encrypted fence befo…
Gravirei Aug 15, 2026
b3892ac
Merge remote-tracking branch 'upstream/main' into fix/issue-218-recon…
Gravirei Aug 17, 2026
f8d4fd3
fix(node): renumber sweep migrations to 27-29 past #173's 18-26
Gravirei Aug 17, 2026
286414d
fix(node): give the mid-scan visibility re-filter its own fresh deadline
Gravirei Aug 17, 2026
1eb02fd
fix(node): refuse to record an IPFS pin whose 2xx response carries no…
Gravirei Aug 17, 2026
ce8a6ff
fix(node): map only NULL to None when listing pinned CIDs; document s…
Gravirei Aug 17, 2026
07a4878
style(node): rustfmt the delaying_endpoint write_all call
Gravirei Aug 17, 2026
4c60331
fix: address review findings from 07a48788
Gravirei Aug 21, 2026
9588098
fix: address cargo audit advisories
Gravirei Aug 21, 2026
0832c23
fix: address P1/P2 findings from re-review
Gravirei Aug 22, 2026
69f41d8
fix: architectural consolidation from fourth review
Gravirei Aug 23, 2026
07db93b
fix: restore h2 audit ignore; suppress dead_code warnings
Gravirei Aug 23, 2026
c2d690b
fix: thread deadline Instant through visibility walk; add cat-file phase
Gravirei Aug 24, 2026
e54c3b8
fix: deny empty-path objects in visibility filter; fix Pinata CID res…
Gravirei Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,16 @@ ignore = [
# ever built with the `mysql` feature, or any other consumer of rsa enters
# the build, at which point it becomes a real reachable advisory.
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)

# h2 0.4.13 (unbounded empty DATA frames DoS). Present in Cargo.lock because
# reqwest/hyper transitively depend on h2. The fix requires h2 >=0.4.16.
# REMOVE once #368 lands with a compatible h2 update.
"RUSTSEC-2026-0258", # h2 unbounded empty DATA frames

# lru 0.16.4 (use-after-free in pop()). Reachable via alloy -> alloy-provider.
# No fix available: alloy 1.7.3 pins alloy-provider which uses lru 0.16.4.
# The lru 0.12.5 advisory (RUSTSEC-2026-0253) is also present (via aws-sdk-s3)
# but that was fixed by reverting aws-sdk-s3 upgrade (we kept the older version
# to avoid the h2 issue). REMOVE once alloy updates its lru dependency.
"RUSTSEC-2026-0253", # lru use-after-free (both 0.12.5 and 0.16.4)
]
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,20 @@ GITLAWB_TRUSTED_PROXY=
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false

# ── Reconciliation sweep ─────────────────────────────────────────────────
# Periodic durability sweep: re-derives the public pin set and the withheld-blob
# recovery set each hour and fills gaps so a dropped replication job never means
# data loss. Defaults to true; set to false to disable the sweep even when a pin
# backend (IPFS/Pinata) is configured.
#
# Phase-capability matrix:
# - Public pin repair: IPFS-only, Pinata-only, or both (requires the
# respective backend to be configured).
# - Encrypted recovery repair: requires local IPFS (GITLAWB_IPFS_API).
# Pinata-only nodes reconcile public pins only; encrypted recovery
# reconciliation is not performed.
GITLAWB_RECONCILIATION_SWEEP=true

# ── iCaptcha proof-of-intelligence gate ───────────────────────────────────
# Optional gate on create_repo + register: require callers to present an
# iCaptcha proof (X-ICaptcha-Proof header) earned at icaptcha.gitlawb.com.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ Important node settings:
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_RECONCILIATION_SWEEP` | Enable the hourly durability sweep that re-pins/backstops missing objects (default `true`; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend (IPFS, Pinata, or both). Encrypted recovery repair requires local IPFS (`GITLAWB_IPFS_API`); Pinata-only nodes reconcile public pins only. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
Expand Down
31 changes: 31 additions & 0 deletions crates/gitlawb-attest/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,37 @@ mod tests {
assert!(matches!(err, Error::Signature(_)));
}

/// The identity-point forgery must be rejected: the shared attestation
/// verifier is a cert-bound provenance gate, so accepting the weak-key
/// signature would let anyone mint a forged attestation that verifies.
/// Strict verification rejects small-order public keys and R (the identity
/// point here), which ordinary verification does not.
#[test]
fn verify_rejects_identity_point_forgery() {
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&fresh(), cert_hash);

// Public key A = identity point (0,1); signature R = identity, S = 0.
// The equation `[S]B = R + [k]A` then holds for any k and any message.
let identity = [
1u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
];
let mut buf = Vec::with_capacity(34);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&identity);
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let mut sig = [0u8; 64];
sig[..32].copy_from_slice(&identity);
att.sig = B64U.encode(sig);

let err = att.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}

/// A payload that happens to contain a `cert_hash` field of its own does
/// not interfere with the outer binding: the attestation envelope's
/// `cert_hash` is the only field consulted by `verify_signature`, and the
Expand Down
40 changes: 40 additions & 0 deletions crates/gitlawb-core/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ impl Keypair {
}

/// Verify an Ed25519 signature.
///
/// Strict verification: rejects small-order `R` and small-order public keys
/// (the identity point, and any point of low order). Ordinary `verify` accepts
/// a signature forged with the identity point as the public key plus
/// `R = identity, S = 0`, which verifies for *any* message. `identity::verify`
/// is the shared primitive behind HTTP request authentication, UCANs, and
/// certificates, so weak-key acceptance is an authentication bypass, not a
/// malleability nuance.
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
let sig = Signature::from_bytes(sig_bytes);
verifying_key
Expand Down Expand Up @@ -208,6 +216,38 @@ mod tests {
);
}

/// The identity-point forgery: with public key A = identity, R = identity,
/// and S = 0, the equation `[S]B = R + [k]A` holds for every message,
/// because `[k]·identity = identity`. Ordinary (non-strict) Ed25519
/// verification accepts it, so the shared `verify` primitive must use
/// strict verification, which rejects small-order R and public keys.
#[test]
fn verify_rejects_identity_point_forgery() {
use ed25519_dalek::Verifier;
// The identity point (0,1) compresses to y = 1 with sign bit 0.
let identity = [
1u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
];
let vk = VerifyingKey::from_bytes(&identity).expect("identity point is on the curve");
let mut sig_bytes = [0u8; 64];
sig_bytes[..32].copy_from_slice(&identity);
let msg = b"arbitrary message the key owner never signed";

// Prove the forged signature satisfies the ordinary verification
// equation, so the strict check below is what actually defends the
// boundary (not a signature that was already invalid everywhere).
assert!(
vk.verify(msg, &Signature::from_bytes(&sig_bytes)).is_ok(),
"identity-point forgery must satisfy ordinary verification (this is why strict is needed)"
);

assert!(
verify(&vk, msg, &sig_bytes).is_err(),
"strict verification must reject the identity-point forgery"
);
}

#[test]
fn verify_rejects_weak_key_signature() {
// Regression guard for strict verification: a signature forged under a
Expand Down
1 change: 1 addition & 0 deletions crates/gitlawb-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ alloy = { version = "1", default-features = false, features = [
"rpc-types-eth",
] }
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
rand = { workspace = true }

[dev-dependencies]
mockito = "1"
Expand Down
33 changes: 30 additions & 3 deletions crates/gitlawb-node/src/api/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,14 +680,41 @@ pub async fn get_by_cid(

/// GET /api/v1/ipfs/pins
///
/// Returns all CIDs that have been pinned to the local IPFS node from git
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
/// Returns all CIDs that have been pinned from git objects received via push.
/// Each entry includes the git SHA-256 hex, a CIDv1 string, and the timestamp
/// when it was pinned. For Pinata-only rows (no local IPFS pin), the `cid`
/// field carries `pinata_cid` so CLI consumers see a usable value.
///
/// Rows with neither a local nor a Pinata CID are omitted so the response
/// only contains rows with at least one backend. Both `cid` (local IPFS) and
/// `pinata_cid` (Pinata) are nullable: a row with only `cid` set is local-only,
/// a row with only `pinata_cid` set is remote-only, and a row with both has
/// been replicated to both backends.
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
// 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 pins = state.db.list_pinned_cids().await?;

let pins: Vec<serde_json::Value> = pins
.into_iter()
.filter(|p| p.cid.is_some() || p.pinata_cid.is_some())
.map(|p| {
// Backward compatibility: `cid` in the response is the local CID
// when present, falling back to the Pinata CID for remote-only rows.
// Clients like `gl ipfs list` read only `pin["cid"]`; a NULL here
// would render as "?". Both provenance fields are always included so
// consumers can distinguish local-only, remote-only, and dual rows.
let effective_cid = p.cid.as_deref().or(p.pinata_cid.as_deref());
serde_json::json!({
"sha256_hex": p.sha256_hex,
"cid": effective_cid,
"local_cid": p.cid,
"pinata_cid": p.pinata_cid,
"pinned_at": p.pinned_at,
})
})
.collect();

Ok(Json(serde_json::json!({
"pins": pins,
"count": pins.len(),
Expand Down
34 changes: 24 additions & 10 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,17 +149,17 @@ async fn fail_closed_full_scan_objects(
// this push rather than the previous silent ~2x hold; size the budget so both
// phases normally fit.
let deadline = std::time::Instant::now() + timeout;
let allowed = crate::git::visibility_pack::replicable_blob_set_bounded(
&disk_path,
&git_bin,
deadline.saturating_duration_since(std::time::Instant::now()),
&rules,
is_public,
&owner_did,
)?;
let all_blobs = crate::git::push_delta::all_blob_oids(&disk_path, &git_bin, deadline)?;
let (allowed, allowed_trees, all_blobs, all_trees) =
crate::git::visibility_pack::allowed_blob_tree_sets_bounded(
&disk_path,
&git_bin,
deadline,
&rules,
is_public,
&owner_did,
)?;
Ok(crate::git::visibility_pack::replicable_objects_fail_closed(
candidates, &allowed, &all_blobs,
candidates, &allowed, &all_blobs, &allowed_trees, &all_trees,
))
})
.await
Expand Down Expand Up @@ -1170,6 +1170,10 @@ async fn pin_new_objects_gated(
object_list,
db,
crate::ipfs_pin::PIN_BATCH_BUDGET,
// The push path derives its object list at admission and holds a write
// lease, so no sweep-style batch snapshot crosses the dispatch boundary
// (see PolicyFence's doc).
None,
)
.await
}
Expand Down Expand Up @@ -1227,7 +1231,14 @@ async fn pin_and_encrypt_objects(
&ctx.db,
repo_id,
&node_seed,
// The real git, not `ctx.git_bin`: tests point that at a fake
// walk git, and the seal reads must run the real one.
"git",
crate::ipfs_pin::PIN_BATCH_BUDGET,
&recipients,
// Push path: recipients derived at admission under a write lease,
// no sweep-style snapshot to fence (see PolicyFence's doc).
None,
)
.await;

Expand Down Expand Up @@ -2434,6 +2445,9 @@ async fn post_receive_replication_tail(
object_list,
&db_clone,
crate::ipfs_pin::PIN_BATCH_BUDGET,
// Push path: no sweep-style batch snapshot to fence (see
// PolicyFence's doc).
None,
)
.await,
)
Expand Down
11 changes: 11 additions & 0 deletions crates/gitlawb-node/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ pub struct Config {
#[arg(long, env = "GITLAWB_AUTO_SYNC", default_value_t = false)]
pub auto_sync: bool,

/// Enable the periodic reconciliation sweep that re-derives pin/seal sets
/// and fills durability gaps. Defaults to true; set to false to disable
/// the sweep even when a pin backend (IPFS/Pinata) is configured.
#[arg(
long,
env = "GITLAWB_RECONCILIATION_SWEEP",
default_value_t = true,
action = clap::ArgAction::Set
)]
pub reconciliation_sweep: 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 = "")]
Expand Down
Loading
Loading