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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -305,6 +305,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 @@ -395,6 +395,7 @@ Important node settings:
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `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 @@ -2130,14 +2130,41 @@ async fn gate_and_serve(

/// 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
31 changes: 21 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 @@ -1403,6 +1403,7 @@ async fn pin_new_objects_gated(
db,
repo_id,
batch_budget,
None,
)
.await
}
Expand Down Expand Up @@ -1471,7 +1472,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 @@ -2731,6 +2739,9 @@ async fn post_receive_replication_tail(
&db_clone,
&repo_id,
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 @@ -129,6 +129,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