diff --git a/.github/workflows/aien-verification.yml b/.github/workflows/aien-verification.yml index b2a0a00..bf1fb14 100644 --- a/.github/workflows/aien-verification.yml +++ b/.github/workflows/aien-verification.yml @@ -82,23 +82,28 @@ jobs: ref: ${{ env.AIEN_TEST_PIN }} path: .aien-test-src - - name: Cache the pinned aien-test build + - name: Move the pinned source outside the candidate Cargo configuration + if: steps.anchors.outputs.enrolled == 'true' + run: mv .aien-test-src "$RUNNER_TEMP/aien-test-src" + + # Cache dependencies only, never verifier executables or candidate-controlled target output. + - name: Cache the pinned aien-test dependencies uses: actions/cache@v4 if: steps.anchors.outputs.enrolled == 'true' with: path: | ~/.cargo/registry ~/.cargo/git - .aien-test-src/target - key: aien-test-${{ runner.os }}-${{ env.AIEN_TEST_PIN }} + key: aien-test-isolated-v1-${{ runner.os }}-${{ env.AIEN_TEST_PIN }} - - name: Build aien-test from the pin (never from the tree under test), then remove the source + - name: Build aien-test outside the tree under test if: steps.anchors.outputs.enrolled == 'true' run: | - cargo build --release -p aien-test --manifest-path .aien-test-src/Cargo.toml - mkdir -p "$RUNNER_TEMP/bin" && cp .aien-test-src/target/release/aien-test "$RUNNER_TEMP/bin/aien-test" + cd "$RUNNER_TEMP/aien-test-src" + cargo build --release -p aien-test --target-dir "$RUNNER_TEMP/aien-test-target" + mkdir -p "$RUNNER_TEMP/bin" && cp "$RUNNER_TEMP/aien-test-target/release/aien-test" "$RUNNER_TEMP/bin/aien-test" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - rm -rf .aien-test-src + cd "$GITHUB_WORKSPACE" git status --porcelain --untracked-files=normal - name: Portable smoke set (gates whose requires is only portable, named at the base ref) @@ -172,21 +177,25 @@ jobs: ref: ${{ env.AIEN_TEST_PIN }} path: .aien-test-src - - name: Cache the pinned aien-test build + - name: Move the pinned source outside the candidate Cargo configuration + run: mv .aien-test-src "$RUNNER_TEMP/aien-test-src" + + # Cache dependencies only, never verifier executables or candidate-controlled target output. + - name: Cache the pinned aien-test dependencies uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git - .aien-test-src/target - key: aien-test-${{ runner.os }}-${{ env.AIEN_TEST_PIN }} + key: aien-test-isolated-v1-${{ runner.os }}-${{ env.AIEN_TEST_PIN }} - - name: Build aien-test from the pin (never from the tree under test), then remove the source + - name: Build aien-test outside the tree under test run: | - cargo build --release -p aien-test --manifest-path .aien-test-src/Cargo.toml - mkdir -p "$RUNNER_TEMP/bin" && cp .aien-test-src/target/release/aien-test "$RUNNER_TEMP/bin/aien-test" + cd "$RUNNER_TEMP/aien-test-src" + cargo build --release -p aien-test --target-dir "$RUNNER_TEMP/aien-test-target" + mkdir -p "$RUNNER_TEMP/bin" && cp "$RUNNER_TEMP/aien-test-target/release/aien-test" "$RUNNER_TEMP/bin/aien-test" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - rm -rf .aien-test-src + cd "$GITHUB_WORKSPACE" git status --porcelain --untracked-files=normal - name: Fetch the signed Spark bundle for the tagged commit diff --git a/crates/synapse-api/src/advisory.rs b/crates/synapse-api/src/advisory.rs index 716dc9e..1ee4d8b 100644 --- a/crates/synapse-api/src/advisory.rs +++ b/crates/synapse-api/src/advisory.rs @@ -542,7 +542,9 @@ impl SynapseService { let mut stack = vec![start]; while let Some(id) = stack.pop() { if out.insert(id) { - let cs = self.store.get_changeset(id, None, snap)?; + let cs = self + .store + .get_changeset_in_repo(&self.repo_id, id, None, snap)?; stack.extend(cs.envelope.parent_changesets.iter().copied()); } } diff --git a/crates/synapse-api/src/evidence.rs b/crates/synapse-api/src/evidence.rs index 3a46b04..a5e5bb6 100644 --- a/crates/synapse-api/src/evidence.rs +++ b/crates/synapse-api/src/evidence.rs @@ -206,7 +206,7 @@ impl SynapseService { ) -> SynapseResult { let envelope = self .store - .get_changeset(changeset_id, None, snapshot_seq)? + .get_changeset_in_repo(&self.repo_id, changeset_id, None, snapshot_seq)? .envelope; let base_tree = self.base_tree(&envelope, snapshot_seq)?; // The changeset's own tree is the v1 input root: the tree the runner tested. diff --git a/crates/synapse-api/src/lib.rs b/crates/synapse-api/src/lib.rs index c9f9131..cf74578 100644 --- a/crates/synapse-api/src/lib.rs +++ b/crates/synapse-api/src/lib.rs @@ -867,7 +867,8 @@ impl SynapseService { now_ms, )?; let (cs_id, min_seq, snap_seq) = req.payload; - self.store.get_changeset(cs_id, min_seq, snap_seq) + self.store + .get_changeset_in_repo(&self.repo_id, cs_id, min_seq, snap_seq) } /// Read a file's raw bytes at `TreeTarget` without cloning or checking out a working tree (`Capability::Read`). @@ -970,7 +971,12 @@ impl SynapseService { let mut stack = vec![head_id]; while let Some(curr) = stack.pop() { if set.insert(curr) { - let stored = self.store.get_changeset(curr, None, Some(snap_seq))?; + let stored = self.store.get_changeset_in_repo( + &self.repo_id, + curr, + None, + Some(snap_seq), + )?; for p in &stored.envelope.parent_changesets { stack.push(*p); } @@ -1037,7 +1043,9 @@ impl SynapseService { now_ms, )?; let (cs_id, snap_seq) = req.payload; - let stored = self.store.get_changeset(cs_id, None, snap_seq)?; + let stored = self + .store + .get_changeset_in_repo(&self.repo_id, cs_id, None, snap_seq)?; let github_projection = self .store .github_projections() @@ -1089,15 +1097,23 @@ impl SynapseService { now_ms, )?; let snap_seq = self.validate_page_request(&req.payload.page)?; - let cand_stored = self - .store - .get_changeset(req.payload.candidate, None, Some(snap_seq))?; + let cand_stored = self.store.get_changeset_in_repo( + &self.repo_id, + req.payload.candidate, + None, + Some(snap_seq), + )?; let peers: Vec = if let Some(ref explicit) = req.payload.against { let mut list = Vec::with_capacity(explicit.len()); for &id in explicit { if id != req.payload.candidate { - list.push(self.store.get_changeset(id, None, Some(snap_seq))?); + list.push(self.store.get_changeset_in_repo( + &self.repo_id, + id, + None, + Some(snap_seq), + )?); } } list @@ -1472,7 +1488,9 @@ impl SynapseService { TreeTarget::Changeset(cs_id) => cs_id, }; // Visibility at `snapshot_seq` is checked on every lookup, memoized or not. - let stored = self.store.get_changeset(cs_id, None, snapshot_seq)?; + let stored = self + .store + .get_changeset_in_repo(&self.repo_id, cs_id, None, snapshot_seq)?; if !force { if let Some(root) = self.trees.memo_root(cs_id) { return Ok(root); @@ -1820,6 +1838,224 @@ mod tests { fs::create_dir_all(&dir).unwrap(); dir } + #[test] + fn repository_grant_cannot_read_foreign_changesets_in_mixed_store() { + let dir = temp_dir("mixed-repositories"); + let a = RepoId::new("example/repo-a").unwrap(); + let b = RepoId::new("example/repo-b").unwrap(); + let mut svc = SynapseService::open( + &dir, + a.clone(), + FsyncPolicy::EveryCommit, + RecoveryPolicy::StrictFailClosed, + StoreLimits::default(), + ) + .unwrap(); + let authority = SigningKey::from_bytes(&[11; 32]); + let agent = SigningKey::from_bytes(&[12; 32]); + svc.register_authority(encode_did_key_ed25519( + &authority.verifying_key().to_bytes(), + )); + let grant = AgentCapabilityGrant::issue( + &authority, + [1; 32], + a.clone(), + encode_did_key_ed25519(&agent.verifying_key().to_bytes()), + vec![Capability::Read], + 1_000_000, + 100, + 60_000, + 0, + 100_000, + ); + let base = GitOid([7; 20]); + svc.register_base_tree(base, vec![]).unwrap(); + let make = |repo_id: RepoId, tag: u8, parents: Vec| { + let mut env = ChangesetEnvelope { + version: SYNAPSE_ENVELOPE_VERSION, + repo_id, + base_git_oid: base, + parent_changesets: parents, + author_did: String::new(), + idempotency_key: IdempotencyKey([tag; 32]), + created_at_ms: 1_000, + intent_summary: "security test".into(), + mutations: vec![PathMutation { + path: "private.txt".into(), + op: MutationOp::Delete, + }], + evidence_receipts: vec![], + signature: vec![], + }; + env.sign(&agent); + env + }; + let own = match svc + .store_mut() + .ingest_changeset(make(a.clone(), 1, vec![]), &[]) + .unwrap() + { + IngestOutcome::Committed { changeset_id, .. } => changeset_id, + _ => unreachable!(), + }; + let foreign = match svc + .store_mut() + .ingest_changeset(make(b, 2, vec![]), &[]) + .unwrap() + { + IngestOutcome::Committed { changeset_id, .. } => changeset_id, + _ => unreachable!(), + }; + let foreign_child = match svc + .store_mut() + .ingest_changeset( + make(RepoId::new("example/repo-b").unwrap(), 3, vec![foreign]), + &[], + ) + .unwrap() + { + IngestOutcome::Committed { changeset_id, .. } => changeset_id, + _ => unreachable!(), + }; + let mut nonce = 0u8; + macro_rules! request { + ($payload:expr) => {{ + nonce += 1; + AuthenticatedRequest::sign_new( + &agent, + grant.clone(), + a.clone(), + Capability::Read, + [nonce; 32], + 2_000, + 0, + $payload, + ) + .unwrap() + }}; + } + assert_eq!( + svc.get(&request!((own, None, None)), 2_000) + .unwrap() + .changeset_id, + own + ); + assert_eq!( + svc.get(&request!((foreign, None, None)), 2_000) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.get(&request!((ChangesetId([0xFF; 32]), None, None)), 2_000) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.provenance(&request!((foreign, None)), 2_000) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.tree( + &request!(TreeQueryPayload { + target: TreeTarget::Changeset(foreign), + path_prefix: None, + page: PageRequest::default(), + }), + 2_000 + ) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.tree( + &request!(TreeQueryPayload { + target: TreeTarget::Changeset(foreign_child), + path_prefix: None, + page: PageRequest::default(), + }), + 2_000 + ) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.evidence_base( + &request!(EvidenceBaseQueryPayload { + changeset_id: foreign, + snapshot_seq: None, + }), + 2_000 + ) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.changeset_advisory( + &request!(ChangesetAdvisoryPayload { + base: TreeTarget::BaseGitCommit(base), + latest: Some(TreeTarget::Changeset(foreign)), + paths: vec!["private.txt".into()], + agent: "reader".into(), + snapshot_seq: None, + }), + None, + 2_000 + ) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.history( + &request!(HistoryQueryPayload { + ancestor_of: Some(foreign), + path_filter: None, + page: PageRequest::default(), + }), + 2_000 + ) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + assert_eq!( + svc.history( + &request!(HistoryQueryPayload { + ancestor_of: Some(foreign_child), + path_filter: None, + page: PageRequest::default(), + }), + 2_000 + ) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + for (candidate, against) in [(foreign, vec![own]), (own, vec![foreign])] { + assert_eq!( + svc.conflicts( + &request!(ConflictQueryPayload { + candidate, + against: Some(against), + page: PageRequest::default(), + }), + 2_000 + ) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + } + assert!(svc.provenance(&request!((own, None)), 2_000).is_ok()); + let _ = fs::remove_dir_all(dir); + } #[test] fn test_virtual_tree_and_diff_parity_against_git_cli() { diff --git a/crates/synapse-auth/src/chain.rs b/crates/synapse-auth/src/chain.rs index b319c86..55bd9b3 100644 --- a/crates/synapse-auth/src/chain.rs +++ b/crates/synapse-auth/src/chain.rs @@ -952,6 +952,7 @@ impl PolicyEnforcer { request_seq: WalSeq, now_ms: u64, ) -> SynapseResult<()> { + self.audit_log.ensure_capacity()?; let result = self.check_chained_request( expected_repo_id, expected_capability, @@ -974,7 +975,7 @@ impl PolicyEnforcer { expected_capability, operation, outcome, - ); + )?; result } diff --git a/crates/synapse-auth/src/lib.rs b/crates/synapse-auth/src/lib.rs index b717a46..79b7917 100644 --- a/crates/synapse-auth/src/lib.rs +++ b/crates/synapse-auth/src/lib.rs @@ -295,8 +295,27 @@ impl AuditEntry { } } +/// Maximum retained audit decisions. Exhaustion stops authorization rather than dropping +/// evidence; the caller must persist the complete log and restart with a fresh enforcer. +pub const MAX_AUDIT_ENTRIES: usize = 4_096; +/// Maximum retained bytes per attacker-controlled audit string. Oversized values retain a +/// SHA-256 commitment and original length rather than occupying an unbounded log entry. +const MAX_AUDIT_FIELD_BYTES: usize = 1_024; + +fn bounded_audit_field(value: String) -> String { + if value.len() <= MAX_AUDIT_FIELD_BYTES { + value + } else { + format!( + "[oversize {} bytes sha256={}]", + value.len(), + hex::encode(Sha256::digest(value.as_bytes())) + ) + } +} + /// Tamper-evident hash-chained security audit log. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize)] pub struct AuditLog { entries: Vec, last_sha256: [u8; 32], @@ -311,9 +330,20 @@ impl AuditLog { &self.entries } + #[cfg(test)] pub fn entries_mut_for_testing(&mut self) -> &mut Vec { &mut self.entries } + /// Reserve space before any authorization state (nonce or quota) is changed. + pub fn ensure_capacity(&self) -> SynapseResult<()> { + if self.entries.len() >= MAX_AUDIT_ENTRIES { + return Err(SynapseError::new( + RefusalCode::BackpressureOverCapacity, + "audit log full; no authorization decision was made", + )); + } + Ok(()) + } #[allow(clippy::too_many_arguments)] pub fn record( @@ -325,16 +355,24 @@ impl AuditLog { capability: Capability, operation: impl Into, outcome: AuditOutcome, - ) -> &AuditEntry { - let audit_seq = (self.entries.len() as u64).saturating_add(1); + ) -> SynapseResult<&AuditEntry> { + self.ensure_capacity()?; + let audit_seq = (self.entries.len() as u64) + 1; + let outcome = match outcome { + AuditOutcome::Refused { code, reason } => AuditOutcome::Refused { + code, + reason: bounded_audit_field(reason), + }, + admitted => admitted, + }; let mut entry = AuditEntry { audit_seq, timestamp_ms, boundary, repo_id, - actor_did, + actor_did: bounded_audit_field(actor_did), capability, - operation: operation.into(), + operation: bounded_audit_field(operation.into()), outcome, prev_entry_sha256: self.last_sha256, entry_sha256: [0u8; 32], @@ -342,7 +380,7 @@ impl AuditLog { entry.entry_sha256 = entry.compute_sha256(); self.last_sha256 = entry.entry_sha256; self.entries.push(entry); - self.entries.last().expect("just pushed") + Ok(self.entries.last().expect("just pushed")) } /// Verify the entire hash chain and sequence continuity of the audit log. @@ -437,6 +475,7 @@ impl PolicyEnforcer { req: &AuthenticatedRequest, now_ms: u64, ) -> SynapseResult<()> { + self.audit_log.ensure_capacity()?; let result = self.check_request_inner(expected_repo_id, expected_capability, req, now_ms); match &result { Ok(()) => { @@ -448,7 +487,7 @@ impl PolicyEnforcer { expected_capability, operation, AuditOutcome::Admitted, - ); + )?; } Err(err) => { self.audit_log.record( @@ -462,7 +501,7 @@ impl PolicyEnforcer { code: err.code, reason: err.message.clone(), }, - ); + )?; } } result @@ -613,6 +652,139 @@ fn put_str(buf: &mut Vec, s: &str) { mod tests { use super::*; + #[test] + fn unauthenticated_refusals_cannot_grow_audit_without_bound() { + let mut log = AuditLog::new(); + let repo = RepoId::new("example/repo").unwrap(); + for _ in 0..MAX_AUDIT_ENTRIES { + log.record( + 1, + BoundaryKind::Api, + repo.clone(), + "untrusted".into(), + Capability::Propose, + "wire_submit", + AuditOutcome::Refused { + code: RefusalCode::AuthChainBroken, + reason: "untrusted chain".into(), + }, + ) + .unwrap(); + } + let head = log.verify_chain().unwrap(); + assert_eq!( + log.record( + 1, + BoundaryKind::Api, + repo, + "untrusted".into(), + Capability::Propose, + "wire_submit", + AuditOutcome::Admitted + ) + .unwrap_err() + .code, + RefusalCode::BackpressureOverCapacity + ); + assert_eq!(log.entries().len(), MAX_AUDIT_ENTRIES); + assert_eq!(log.verify_chain().unwrap(), head); + } + + #[test] + fn oversized_untrusted_audit_fields_keep_explicit_digest_not_payload() { + let mut log = AuditLog::new(); + let value = "x".repeat(MAX_AUDIT_FIELD_BYTES + 1); + let entry = log + .record( + 1, + BoundaryKind::Api, + RepoId::new("example/repo").unwrap(), + value.clone(), + Capability::Propose, + "wire_submit", + AuditOutcome::Refused { + code: RefusalCode::AuthChainBroken, + reason: value.clone(), + }, + ) + .unwrap(); + let digest = hex::encode(Sha256::digest(value.as_bytes())); + assert!(entry.actor_did.contains(&digest)); + assert!( + matches!(&entry.outcome, AuditOutcome::Refused { reason, .. } + if reason.contains(&digest) && reason.len() < MAX_AUDIT_FIELD_BYTES) + ); + assert!(log.verify_chain().is_ok()); + } + + #[test] + fn full_audit_refuses_before_consuming_nonce_or_rate_quota() { + let authority = SigningKey::from_bytes(&[31; 32]); + let agent = SigningKey::from_bytes(&[32; 32]); + let repo = RepoId::new("example/repo").unwrap(); + let grant = AgentCapabilityGrant::issue( + &authority, + [7; 32], + repo.clone(), + encode_did_key_ed25519(&agent.verifying_key().to_bytes()), + vec![Capability::Propose], + 1024, + 1, + 60_000, + 0, + 100_000, + ); + let req = AuthenticatedRequest::sign_new( + &agent, + grant, + repo.clone(), + Capability::Propose, + [8; 32], + 2_000, + 0, + "proposal", + ) + .unwrap(); + let mut auth = PolicyEnforcer::new(); + auth.register_repo_authority( + repo.clone(), + encode_did_key_ed25519(&authority.verifying_key().to_bytes()), + ); + for _ in 0..MAX_AUDIT_ENTRIES { + auth.audit_log + .record( + 1, + BoundaryKind::Api, + repo.clone(), + "untrusted".into(), + Capability::Propose, + "wire_submit", + AuditOutcome::Refused { + code: RefusalCode::AuthChainBroken, + reason: "invalid".into(), + }, + ) + .unwrap(); + } + let head = auth.audit_log.verify_chain().unwrap(); + assert_eq!( + auth.authorize( + BoundaryKind::Api, + "submit", + &repo, + Capability::Propose, + &req, + 2_000, + ) + .unwrap_err() + .code, + RefusalCode::BackpressureOverCapacity + ); + assert!(auth.consumed_nonces.is_empty()); + assert!(auth.rate_windows.is_empty()); + assert_eq!(auth.audit_log.verify_chain().unwrap(), head); + } + #[test] fn test_capability_matrix_adversarial_refusals_and_audit_chain() { let authority_key = SigningKey::from_bytes(&[1u8; 32]); diff --git a/crates/synapse-auth/tests/f1_capability_chains.rs b/crates/synapse-auth/tests/f1_capability_chains.rs index 9e0ecbb..b1171cb 100644 --- a/crates/synapse-auth/tests/f1_capability_chains.rs +++ b/crates/synapse-auth/tests/f1_capability_chains.rs @@ -234,6 +234,35 @@ fn paths(p: &[&str]) -> RequestScope { fn in_scope() -> RequestScope { paths(&["crates/synapse-auth/src/lib.rs"]) } +#[test] +fn chained_authorization_backpressures_before_evaluating_request() { + use synapse_auth::{AuditOutcome, MAX_AUDIT_ENTRIES}; + let mut fx = Fx::new(); + let req = fx.request(&fx.s, fx.full_chain(), Capability::Propose, in_scope(), 42); + for _ in 0..MAX_AUDIT_ENTRIES { + fx.enforcer + .audit_log_mut() + .record( + NOW, + BoundaryKind::Api, + repo(), + "untrusted".into(), + Capability::Propose, + "wire_submit", + AuditOutcome::Refused { + code: RefusalCode::AuthChainBroken, + reason: "invalid".into(), + }, + ) + .unwrap(); + } + let head = fx.enforcer.audit_log().verify_chain().unwrap(); + assert_eq!( + fx.authorize(&req, 4), + Err(RefusalCode::BackpressureOverCapacity) + ); + assert_eq!(fx.enforcer.audit_log().verify_chain().unwrap(), head); +} // --------------------------------------------------------------------------------------------- // Property: no derived grant exceeds its parent. diff --git a/crates/synapse-git/src/lib.rs b/crates/synapse-git/src/lib.rs index 6e53959..cc97f10 100644 --- a/crates/synapse-git/src/lib.rs +++ b/crates/synapse-git/src/lib.rs @@ -508,9 +508,10 @@ pub fn project_github_pull_request( let repo_id = service.repo_id().clone(); service.authorize_export("project_github_pr", req, now_ms)?; - let stored = service - .store() - .get_changeset(req.payload.changeset_id, None, None)?; + let stored = + service + .store() + .get_changeset_in_repo(&repo_id, req.payload.changeset_id, None, None)?; let head_git_oid = index .commit_for_changeset(req.payload.changeset_id) .ok_or_else(|| { @@ -891,6 +892,44 @@ mod tests { ) .unwrap(); + // A shared store must not expose another repository through PR projection. + let mut foreign = svc + .store() + .get_changeset(cs2_id, None, None) + .unwrap() + .envelope; + foreign.repo_id = RepoId::new("example/foreign").unwrap(); + foreign.parent_changesets.clear(); + foreign.idempotency_key = IdempotencyKey::from_label("foreign-projection"); + foreign.sign(&agent_key); + let foreign_id = match svc.store_mut().ingest_changeset(foreign, &[]).unwrap() { + IngestOutcome::Committed { changeset_id, .. } => changeset_id, + _ => unreachable!(), + }; + let foreign_req = AuthenticatedRequest::sign_new( + &agent_key, + grant.clone(), + repo_id.clone(), + Capability::Publish, + Sha256::digest(b"foreign-projection-request").into(), + 2_900, + 0, + GitHubPrProjectionRequest { + pr_number: 43, + changeset_id: foreign_id, + pr_branch_ref: "refs/synapse/agent-head/foreign".to_string(), + title: "must not expose foreign evidence".to_string(), + github_merged: false, + }, + ) + .unwrap(); + assert_eq!( + project_github_pull_request(&mut svc, &index, &foreign_req, 2_900) + .unwrap_err() + .code, + RefusalCode::ChangesetNotFound + ); + let pr_req = AuthenticatedRequest::sign_new( &agent_key, grant, diff --git a/crates/synapse-store/src/lib.rs b/crates/synapse-store/src/lib.rs index ea3db2b..1d3cb83 100644 --- a/crates/synapse-store/src/lib.rs +++ b/crates/synapse-store/src/lib.rs @@ -1696,6 +1696,38 @@ impl SynapseStore { Ok(stored.clone()) } + /// Read a changeset for a repository-scoped caller. A foreign ID is indistinguishable + /// from an unknown ID, even when the shared store contains it. + pub fn get_changeset_in_repo( + &self, + repo_id: &RepoId, + changeset_id: ChangesetId, + min_visible_seq: Option, + snapshot_seq: Option, + ) -> SynapseResult { + let effective_snapshot = self.resolve_snapshot_seq(min_visible_seq, snapshot_seq)?; + let stored = self + .changesets + .get(&changeset_id) + .filter(|cs| &cs.envelope.repo_id == repo_id) + .ok_or_else(|| { + SynapseError::new( + RefusalCode::ChangesetNotFound, + format!("changeset {changeset_id} not found"), + ) + })?; + if stored.committed_seq > effective_snapshot { + return Err(SynapseError::new( + RefusalCode::ChangesetNotFound, + format!( + "changeset {changeset_id} committed at {} is not visible at snapshot {}", + stored.committed_seq, effective_snapshot + ), + )); + } + Ok(stored.clone()) + } + /// Read an immutable blob ONLY if it is associated with a WAL commit at `<= snapshot_seq`, /// recomputing its `BlobDigest` on read to detect disk corruption. pub fn get_blob_visible( diff --git a/crates/synapse-wire/src/session.rs b/crates/synapse-wire/src/session.rs index 4596756..40cb238 100644 --- a/crates/synapse-wire/src/session.rs +++ b/crates/synapse-wire/src/session.rs @@ -49,6 +49,8 @@ use synapse_store::{IngestOutcome, SharedStore}; /// Default cap on chunk bytes buffered for one negotiation (64 MiB). pub const DEFAULT_MAX_PENDING_CHUNK_BYTES: u64 = 64 << 20; +/// Refusals close a connection before one peer can consume the shared audit capacity. +const MAX_SESSION_REFUSALS: u32 = 64; fn violation(msg: impl Into) -> SynapseError { SynapseError::new(RefusalCode::WireProtocolViolation, msg) @@ -150,6 +152,7 @@ impl WireServer { Session { server: self, hello_done: false, + refusals: 0, neg: Negotiation::default(), } } @@ -234,12 +237,16 @@ pub struct Reply { pub struct Session<'a> { server: &'a WireServer, hello_done: bool, + refusals: u32, neg: Negotiation, } impl Session<'_> { fn refuse(&mut self, request_id: u32, e: SynapseError) -> Reply { self.neg = Negotiation::default(); + self.refusals += 1; + let close = self.refusals >= MAX_SESSION_REFUSALS + || e.code == RefusalCode::BackpressureOverCapacity; Reply { frames: vec![Frame { request_id, @@ -248,7 +255,7 @@ impl Session<'_> { message: e.message, }), }], - close: false, + close, } } @@ -368,6 +375,9 @@ impl Session<'_> { } fn submit(&mut self, req: ChainedRequest) -> SynapseResult { + // Before envelope decoding or signature verification, refuse if the shared audit + // cannot retain the next decision. The enforcer rechecks under its mutex at admission. + lock(&self.server.auth).audit_log().ensure_capacity()?; let srv = self.server; if let Some(missing) = self .neg diff --git a/crates/synapse-wire/tests/g1_wire.rs b/crates/synapse-wire/tests/g1_wire.rs index 9aaf2cb..6cf4320 100644 --- a/crates/synapse-wire/tests/g1_wire.rs +++ b/crates/synapse-wire/tests/g1_wire.rs @@ -337,6 +337,111 @@ fn frame(id: u32, body: FrameBody) -> Frame { } } +#[test] +fn repeated_unauthenticated_submissions_close_session_before_unbounded_audit() { + let dir = temp_dir("audit-budget"); + let fx = ChainFixture::new(1, None); + let srv = server(&fx, open_store(&dir)); + let (env, _) = envelope(&fx.agent, "audit-budget", &[], &["x"], 5); + let mut req = submit_request(&fx.agent, fx.chain.clone(), &env, [9; 32], 5).unwrap(); + req.chain.links[0].signature[0] ^= 1; // Well-encoded, but not signed by the authority. + let submitted = frame(2, FrameBody::Submit(Box::new(req))); + let encoded = encode_frame(&submitted).unwrap(); + let (submitted, consumed) = decode_frame(&encoded, &WireLimits::default()).unwrap(); + assert_eq!(consumed, encoded.len()); + let mut session = srv.session(); + assert!( + !session + .handle(frame( + 0, + FrameBody::Hello(Hello { + max_frame_body: 65_536, + max_pending_chunk_bytes: 0, + }) + )) + .close + ); + let mut closed = false; + for _ in 0..65 { + session.handle(frame( + 1, + FrameBody::Have(Have { + repo_id: repo(), + digests: vec![], + }), + )); + let reply = session.handle(submitted.clone()); + assert_eq!( + refused_code(reply.frames.first().cloned()), + RefusalCode::AuthGrantSignatureInvalid + ); + if reply.close { + closed = true; + break; + } + } + assert!( + closed, + "a connection must stop spending audit capacity on untrusted submissions" + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn full_shared_audit_refuses_before_submit_payload_decode_and_closes() { + use synapse_auth::{AuditOutcome, BoundaryKind, MAX_AUDIT_ENTRIES}; + use synapse_wire::{ServerConfig, WireServer}; + let dir = temp_dir("audit-full"); + let fx = ChainFixture::new(1, None); + let store = open_store(&dir); + let mut auth = fx.enforcer(); + for _ in 0..MAX_AUDIT_ENTRIES { + auth.audit_log_mut() + .record( + 1, + BoundaryKind::Api, + repo(), + "untrusted".into(), + Capability::Propose, + "wire_submit", + AuditOutcome::Refused { + code: RefusalCode::AuthChainBroken, + reason: "untrusted".into(), + }, + ) + .unwrap(); + } + let srv = WireServer::new( + ServerConfig { + repo_id: repo(), + read_limits: WireLimits::default(), + max_pending_chunk_bytes: 64, + }, + store, + auth, + std::sync::Arc::new(|| 5), + ) + .unwrap(); + let (env, _) = envelope(&fx.agent, "full-audit", &[], &["x"], 5); + let mut req = submit_request(&fx.agent, fx.chain.clone(), &env, [9; 32], 5).unwrap(); + req.payload.clear(); // Negative control: decoder would return WireFrameMalformed without preflight. + let mut session = srv.session(); + session.handle(frame( + 0, + FrameBody::Hello(Hello { + max_frame_body: 65_536, + max_pending_chunk_bytes: 0, + }), + )); + let reply = session.handle(frame(1, FrameBody::Submit(Box::new(req)))); + assert_eq!( + refused_code(reply.frames.into_iter().next()), + RefusalCode::BackpressureOverCapacity + ); + assert!(reply.close); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn server_refuses_protocol_violations_with_typed_refusals() { let dir = temp_dir("refusals"); diff --git a/docs/F1_CAPABILITY_CHAINS.md b/docs/F1_CAPABILITY_CHAINS.md index 4c717d9..15921f2 100644 --- a/docs/F1_CAPABILITY_CHAINS.md +++ b/docs/F1_CAPABILITY_CHAINS.md @@ -21,7 +21,7 @@ Code: `crates/synapse-auth/src/chain.rs`, `crates/synapse-core/src/lib.rs` (`Cha * **Granting (`PolicyEnforcer::grant_link`)**: records the leaf link of a verified chain as granted at `seq`. Every ancestor link must already be granted at or before `seq` and unrevoked at `seq`. If the leaf has a parent link, its `max_ops_per_window`, `storage_bytes`, and `footprint_paths` are added to the parent's allocated totals; if any dimension would exceed the parent's quota, granting fails with `RefusalCode::QuotaPartitionExceeded` and state is unchanged. * **Revocation (`PolicyEnforcer::revoke_link`)**: marks a granted link revoked at `seq` (keeping the earliest revocation sequence if called more than once). Revocation is monotone and cannot be undone. Any chain containing a link with `revoked_at <= request_seq` is refused with `RefusalCode::AuthGrantRevoked`. * **Unallocated quota (`PolicyEnforcer::unallocated_quota`)**: returns a granted link's remaining `max_ops_per_window`, `storage_bytes`, and `footprint_paths` after subtracting what it has partitioned to granted children. -* **Request authorization (`PolicyEnforcer::authorize_chain`)**: verifies the chain, checks that every link was granted at or before `request_seq` and unrevoked at `request_seq`, verifies that the presenting agent is the leaf link's audience and signed the canonical request transcript (`DOMAIN_CHAIN_REQUEST_V1`), checks required capability, path scope (segment boundary), reference scope, change scope, payload size, and the leaf's unallocated request rate window, checks mutation nonce replay, and appends an entry to the hash-chained audit log. +* **Request authorization (`PolicyEnforcer::authorize_chain`)**: checks audit capacity first; if full, refuses with `BackpressureOverCapacity` before signature work or nonce/rate state changes. Otherwise verifies the chain, grant sequence/revocation, leaf audience and canonical request signature (`DOMAIN_CHAIN_REQUEST_V1`), capability and scope, payload size, rate window and nonce replay, then appends the admitted or refused decision to the hash-chained audit log. See `THREAT_MODEL.md` section 4 for the bounded in-memory log and its lack of automatic persistence. ## 3. Canonical binary layout (principle P5) diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index cb55463..7a72036 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -19,7 +19,7 @@ Each `AgentCapabilityGrant` is signed by a repository authority Ed25519 key and | Operation | Required Capability | Additional Invariants Checked | Refusal Code on Violation | | :--- | :--- | :--- | :--- | -| `get`, `tree`, `diff`, `history`, `provenance`, `conflicts`, `status`, `watch` | `Read` | `grant.repo_id == request.repo_id`, `now_ms <= grant.expires_at_ms`, `page_size <= max_page_size` | `AuthCapabilityDenied`, `AuthCrossRepoDenied`, `AuthGrantExpired`, `PaginationLimitExceeded` | +| `get`, `tree`, `diff`, `history`, `provenance`, `conflicts`, `status`, `watch` | `Read` | `grant.repo_id == request.repo_id`, `now_ms <= grant.expires_at_ms`, changeset lookups (including ancestors and explicit conflict IDs) belong to the authorized repository, `page_size <= max_page_size` | `AuthCapabilityDenied`, `AuthCrossRepoDenied`, `ChangesetNotFound`, `AuthGrantExpired`, `PaginationLimitExceeded` | | `submit` (propose changeset) | `Propose` | Valid agent Ed25519 signature over `ChangesetEnvelope`, payload within `max_bytes_per_op`, rate quota available, base tree known | `AuthSignatureInvalid`, `AuthCapabilityDenied`, `QuotaPayloadTooLarge`, `QuotaRateLimitExceeded` | | `attach_evaluation` (test/benchmark receipt) | `Evaluate` | Target `ChangesetId` exists in `repo_id`, signed evaluation receipt digest valid, single-use request nonce not replayed | `AuthCapabilityDenied`, `AuthReplayDetected`, `ChangesetNotFound` | | `record_review` (review verdict) | `Review` | Reviewer `agent_did != changeset.author_did` (no self-approval), valid signature, non-replayed nonce | `AuthCapabilityDenied`, `AuthSelfReviewRefused`, `AuthReplayDetected` | @@ -30,22 +30,12 @@ Each `AgentCapabilityGrant` is signed by a repository authority Ed25519 key and | Threat ID | Adversarial Scenario | Mitigation and Deterministic Refusal | | :--- | :--- | :--- | | **T1: Forged Agent Identity** | Attacker submits a changeset claiming `author_did = did:key:z6Mk...` with a forged or mismatched Ed25519 signature. | `synapse-auth` extracts the 32-byte Ed25519 public key directly from `did:key:z6Mk...` (multicodec `0xed01`) and verifies the 64-byte signature over the domain-separated canonical envelope before any store admission. Refuses with `AuthSignatureInvalid`. | -| **T2: Cross-Repository Token Reuse** | Agent holding a valid `Propose` grant for repository `repo-A` attempts to read or submit a changeset to `repo-B`. | Every grant and request is domain-bound to `RepoId`. Mismatched `repo_id` fails closed with `AuthCrossRepoDenied`. | +| **T2: Cross-Repository Token or Object Reuse** | Agent holding a valid `Read`, `Propose` or `Publish` grant for `repo-A` requests a known `repo-B` changeset ID from a mixed store, or targets `repo-B` directly. | Grants and requests are bound to `RepoId` (`AuthCrossRepoDenied`), and repository-scoped lookups refuse a foreign changeset as `ChangesetNotFound`, including provenance, virtual-tree ancestry, explicit conflict IDs and GitHub PR projection. Own-repository snapshot visibility errors retain their established diagnostic; foreign IDs never reveal their commit sequence. | | **T3: Expired or Replayed Mutation** | Attacker replays a captured mutation token or uses an expired `AgentCapabilityGrant`. | Grants enforce `now_ms <= expires_at_ms` (`AuthGrantExpired`). Mutation requests carry a monotonic or unique `request_nonce` tracked per `(repo_id, agent_did)` window; duplicates fail closed with `AuthReplayDetected`. | -| **T4: Unbounded Memory / Disk Exhaustion** | Flood of large blobs, deep directory trees, or uncollected speculative changesets. | Enforces `max_blob_bytes`, `max_paths_per_changeset`, `max_pending_ops`, `max_pending_bytes`, and `max_page_size` prior to allocation (`QuotaPayloadTooLarge`, `BackpressureOverCapacity`), plus TTL/root-based garbage collection during compaction. | +| **T4: Unbounded Memory / Disk Exhaustion** | Flood of large blobs, deep directory trees, uncollected speculative changesets, or unauthenticated wire submissions. | Blob, path, pending-operation and page limits apply. The audit log retains at most 4,096 decisions; when full, new authorization fails with `BackpressureOverCapacity` before signature verification or nonce/quota mutation. The wire server closes a connection after 64 refusals or on audit exhaustion. This is backpressure, not an audit drain: without an operator-provided durable export/restart, the enforcer remains closed to new decisions. | | **T5: Split-Brain CanonicalSpoofing** | GitHub PR is merged or fast ingest `WalSeq` advances, and an agent attempts to mark the changeset canonical without Radicle delegate quorum. | `synapse-radicle` refuses canonical promotion unless `refs/rad/id` and `refs/namespaces//refs/rad/sigrefs` verify at least `threshold` distinct delegate signatures on the target `GitOid` (`RadicleQuorumInsufficient`, `SplitBrainDivergence`). | | **T6: Crash or Torn Write Corruption** | Process dies mid-blob write or mid-WAL frame append. | Blobs are written and synced before the WAL frame is appended. Recovery verifies `SWAL` header magic, sequence continuity, CRC32C, and `prev_frame_sha256` hash chain; uncommitted blobs remain unindexed and torn tail frames are refused/quarantined without phantom visibility. | ## 4. Tamper-Evident Security Audit Log -Every authentication, authorization, submission, evaluation, review, projection, and promotion decision (both `Admitted` and `Refused`) appends a signed/hash-chained `AuditEntry` containing: -* `audit_seq: u64` -* `timestamp_ms: u64` -* `repo_id: RepoId` -* `actor_did: String` -* `operation: String` -* `outcome: AuditOutcome` (`Admitted { wal_seq }` or `Refused { code }`) -* `prev_entry_sha256: [u8; 32]` -* `entry_sha256: [u8; 32]` - -Any tampering with historical audit entries breaks the `prev_entry_sha256` chain and is detected by `AuditLog::verify_chain()`. +Every decision actually evaluated by `PolicyEnforcer::authorize` or `authorize_chain` appends a hash-chained `AuditEntry` (admitted or refused) containing sequence, time, repository, actor, capability, operation, outcome and previous/current SHA-256 hashes. The log is in memory; it is not independently signed or automatically persisted. `AuditLog::verify_chain()` detects a changed entry or broken chain within the retained log. Entries retain at most 1,024 bytes per attacker-controlled string field; longer fields are replaced explicitly by their original byte length and SHA-256 digest, not silently truncated. At the fixed 4,096-entry capacity, authorization refuses *before* evaluating the next request; that backpressure refusal cannot itself be appended without exceeding the cap. Operators must durably preserve and inspect the full log before starting a fresh enforcer; discarding it is audit loss, not successful draining.