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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,19 @@ GITLAWB_MAX_PACK_BYTES=2147483648
# ── Sync ─────────────────────────────────────────────────────────────────
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false

# ── 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.
# Default off = fully inert (no behavior change).
# off - gate disabled (default)
# shadow - verify + log would-be rejections, but always allow
# enforce - reject requests without a valid, strong-enough proof
ICAPTCHA_MODE=off
# Optional base64url Ed25519 public key override; if unset it is fetched from
# ICAPTCHA_URL/v1/pubkey at startup.
ICAPTCHA_PUBKEY=
# Minimum proof difficulty level required to pass.
ICAPTCHA_REQUIRED_LEVEL=3
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Base URL of the iCaptcha service.
ICAPTCHA_URL=https://icaptcha.gitlawb.com
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 crates/gitlawb-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
ed25519-dalek = { workspace = true }
base64 = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
9 changes: 9 additions & 0 deletions crates/gitlawb-node/src/api/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ pub struct RegisterResponse {
pub async fn register(
State(state): State<AppState>,
axum::Extension(auth): axum::Extension<crate::auth::AuthenticatedDid>,
headers: axum::http::HeaderMap,
Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>)> {
// iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify up front; spend
// the proof only once the request is admissible, just before the write, so a
// rejected request never burns a valid proof.
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;

// Parse and validate the DID
let agent_did: Did = req
.did
Expand All @@ -52,6 +58,9 @@ pub async fn register(
));
}

// Request is admissible — spend the proof now, immediately before the write.
proof.consume(&state.db).await?;

// Store the agent in the local index
state
.db
Expand Down
10 changes: 10 additions & 0 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,15 @@ pub struct InfoRefsQuery {
pub async fn create_repo(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
headers: axum::http::HeaderMap,
Json(req): Json<CreateRepoRequest>,
) -> Result<(StatusCode, Json<RepoResponse>)> {
// iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify the proof up
// front so an invalid/missing proof is rejected early; the proof is only
// spent once the request is admissible, just before the first write — so a
// rejected request (bad name, already exists) never burns a valid proof.
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;

// Sanitize name: alphanumeric, hyphens, underscores only
if !req
.name
Expand All @@ -160,6 +167,9 @@ pub async fn create_repo(
return Err(AppError::RepoExists(req.name));
}

// Request is admissible — spend the proof now, immediately before the write.
proof.consume(&state.db).await?;

let disk_path = state
.repo_store
.init(&owner_did, &req.name)
Expand Down
54 changes: 54 additions & 0 deletions crates/gitlawb-node/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,21 @@ const MIGRATIONS: &[Migration] = &[
"CREATE INDEX IF NOT EXISTS idx_repos_owner_key_name ON repos ((CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END), name)",
],
},
Migration {
version: 8,
name: "icaptcha_consumed_proofs",
stmts: &[
// Single-use ledger for iCaptcha proof ids (jti). A proof may be
// spent once per gated action; replays are rejected until the row
// is swept after the proof's own expiry. `expires_at` is the
// proof's unix-seconds exp, used for cleanup.
r#"CREATE TABLE IF NOT EXISTS icaptcha_consumed_proofs (
jti TEXT NOT NULL PRIMARY KEY,
expires_at BIGINT NOT NULL
)"#,
"CREATE INDEX IF NOT EXISTS idx_icaptcha_consumed_expires ON icaptcha_consumed_proofs(expires_at)",
],
},
];

// ── Repos ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1097,6 +1112,45 @@ impl Db {
Ok(())
}

/// Atomically consume an iCaptcha proof id (`jti`). Returns `Ok(true)` if it
/// was newly recorded (the proof may be used), `Ok(false)` if it was already
/// spent (a replay). `expires_at` is the proof's unix-seconds `exp`, kept so
/// the ledger row can be swept once the proof can no longer be valid.
pub async fn consume_proof_jti(&self, jti: &str, expires_at: i64) -> Result<bool> {
let result = sqlx::query(
"INSERT INTO icaptcha_consumed_proofs (jti, expires_at)
VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING",
)
.bind(jti)
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}

/// Read-only check if an iCaptcha proof id (`jti`) has already been consumed.
/// Returns `Ok(true)` if the jti is fresh (not yet consumed), `Ok(false)` if
/// it is a replay. Used by shadow mode to detect replays without mutating state.
pub async fn check_proof_jti(&self, jti: &str) -> Result<bool> {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM icaptcha_consumed_proofs WHERE jti = $1)",
)
.bind(jti)
.fetch_one(&self.pool)
.await?;
Ok(!exists)
}
Comment on lines +1132 to +1143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Record first-seen proofs in shadow mode before checking replay.

check_proof_jti is read-only, and the downstream shadow path only calls this method, so repeated shadow-mode uses remain absent from icaptcha_consumed_proofs and keep reporting as fresh. That misses the PR goal of logging would-be replay rejections.

Suggested direction
-    pub async fn check_proof_jti(&self, jti: &str) -> Result<bool> {
-        let exists = sqlx::query_scalar::<_, bool>(
-            "SELECT EXISTS(SELECT 1 FROM icaptcha_consumed_proofs WHERE jti = $1)",
-        )
-        .bind(jti)
-        .fetch_one(&self.pool)
-        .await?;
-        Ok(!exists)
+    pub async fn observe_proof_jti(&self, jti: &str, expires_at: i64) -> Result<bool> {
+        self.consume_proof_jti(jti, expires_at).await
     }

Then update the shadow caller to log on false but still allow the request.

-                let is_fresh = db.check_proof_jti(&job.jti).await?;
+                let is_fresh = db.observe_proof_jti(&job.jti, job.exp).await?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Read-only check if an iCaptcha proof id (`jti`) has already been consumed.
/// Returns `Ok(true)` if the jti is fresh (not yet consumed), `Ok(false)` if
/// it is a replay. Used by shadow mode to detect replays without mutating state.
pub async fn check_proof_jti(&self, jti: &str) -> Result<bool> {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM icaptcha_consumed_proofs WHERE jti = $1)",
)
.bind(jti)
.fetch_one(&self.pool)
.await?;
Ok(!exists)
}
/// Read-only check if an iCaptcha proof id (`jti`) has already been consumed.
/// Returns `Ok(true)` if the jti is fresh (not yet consumed), `Ok(false)` if
/// it is a replay. Used by shadow mode to detect replays without mutating state.
pub async fn observe_proof_jti(&self, jti: &str, expires_at: i64) -> Result<bool> {
self.consume_proof_jti(jti, expires_at).await
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/db/mod.rs` around lines 1132 - 1143,
`check_proof_jti` is currently read-only, so shadow-mode requests never get
recorded and repeated proofs keep appearing fresh. Update the shadow-mode caller
that uses `check_proof_jti` to first record the proof as consumed on the first
seen request, then use the check only to decide whether to log a would-be replay
rejection while still allowing the request through. Keep the replay-detection
behavior centered around `check_proof_jti` and the downstream shadow path so the
first-seen proof is persisted before subsequent shadow checks.


/// Delete consumed-proof rows whose proof has expired. Returns rows removed.
pub async fn sweep_expired_proofs(&self, now: i64) -> Result<u64> {
let result = sqlx::query("DELETE FROM icaptcha_consumed_proofs WHERE expires_at < $1")
.bind(now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}

pub async fn get_trust_score(&self, agent_did: &str) -> Result<f64> {
let row = sqlx::query("SELECT trust_score FROM agents WHERE did = $1")
.bind(agent_did)
Expand Down
10 changes: 10 additions & 0 deletions crates/gitlawb-node/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub enum AppError {
#[allow(dead_code)]
Forbidden(String),

#[error("icaptcha proof required: {0}")]
IcaptchaProofRequired(String),

#[error("invalid request: {0}")]
BadRequest(String),

Expand Down Expand Up @@ -52,6 +55,13 @@ impl IntoResponse for AppError {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
// Distinct from `not_an_agent`: the caller IS an authenticated agent
// but must present a valid, fresh iCaptcha proof to proceed.
AppError::IcaptchaProofRequired(msg) => (
StatusCode::UNAUTHORIZED,
"icaptcha_proof_required",
msg.clone(),
),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
Expand Down
Loading
Loading