Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,13 @@ 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.
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 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). |
| `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
28 changes: 25 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,36 @@ 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 `cid` stays an
/// always-string field. The raw `pinata_cid` is not surfaced under its own
/// key, but it does appear as the `cid` value for Pinata-only rows; that is
/// the intended fallback, so the field is always a real CIDv1, never node
/// infrastructure detail beyond the CID itself.
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| {
// Synthesize a usable CID from pinata_cid when this is a
// Pinata-only row (no local IPFS pin).
let display_cid = p.cid.clone().or_else(|| p.pinata_cid.clone());
serde_json::json!({
"sha256_hex": p.sha256_hex,
"cid": display_cid,
"pinned_at": p.pinned_at,
})
})
.collect();

Ok(Json(serde_json::json!({
"pins": pins,
"count": pins.len(),
Expand Down
14 changes: 14 additions & 0 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
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