//! Battery 6: Signature Tamper Detection. //! //! Tests signature verification and tamper detection in the ingestion pipeline. //! //! # Test Coverage //! //! | Test | Scenario | Validates | //! |------|----------|-----------| //! | `test_valid_signature_accepted` | Valid sig (v1) | Accepted and stored | //! | `test_tampered_confidence_not_detected_v1` | v1 design limit | Confidence not covered by v1 sig | //! | `test_tampered_confidence_rejected_v2` | v2 enterprise | Confidence tampering detected | //! | `test_tampered_subject_rejected` | Subject tamper | Rejected | //! | `test_wrong_agent_id_rejected` | Agent ID mismatch | Rejected | //! //! See also: Battery 10 for multi-sig, v2 signatures, and unknown version rejection. #![allow(clippy::expect_used)] // Test code uses expect() for clear failure messages use super::helpers::*; /// Test 6.1: Valid signature is accepted. /// /// Agent A signs an assertion correctly. Ingest through IngestWorker. /// Assert: assertion is stored, index entries exist. #[tokio::test] async fn test_valid_signature_accepted() { let dir = tempdir().expect("create temp dir"); let wal_dir = dir.path().join("wal"); let db_dir = dir.path().join("db"); let base_ts: u64 = 1_000_000; // Create a properly signed assertion let assertion = create_signed_assertion_with_source( "Subject_A", "predicate_test", ObjectValue::Text("value".to_string()), SourceClass::Clinical, 0.8, base_ts, ); // Write to WAL let mut journal = Journal::open(&wal_dir).expect("open journal"); journal.append(serialize_assertion(&assertion).expect("ser")).expect("append"); // Ingest via IngestWorker let journal = Arc::new(Mutex::new(journal)); let store = Arc::new(HybridStore::open(&db_dir).expect("open store")); let mut worker = IngestWorker::new(journal.clone(), store.clone()).await.expect("create worker"); let bytes = worker.step().await.expect("ingest step"); assert!(bytes > 0, "should process data from WAL"); // Verify assertion is stored (H: key exists) let h_prefix = key_codec::assertion_key("Subject_A", ""); let h_entries = store.scan_prefix(&h_prefix).await.expect("scan H:"); assert_eq!(h_entries.len(), 1, "should have 1 assertion stored"); // Verify SP: index exists let sp_prefix = key_codec::subject_predicate_scan_prefix("Subject_A"); let sp_entries = store.scan_prefix(&sp_prefix).await.expect("scan SP:"); assert_eq!(sp_entries.len(), 1, "should have 1 SP: index entry"); } /// Test 6.2: Tampered confidence is NOT detected (v1 design limitation). /// /// Agent A signs assertion with confidence=0.8 using v1 signature. The v1 signature /// only covers `{subject}:{predicate}`, not the confidence field. Modifying confidence /// after signing does NOT invalidate the signature. /// /// This test documents the v1 behavior: changing confidence won't fail verification /// because it's not part of the signed message. Use v2 signatures for full coverage. #[tokio::test] async fn test_tampered_confidence_not_detected_v1() { let dir = tempdir().expect("create temp dir"); let wal_dir = dir.path().join("wal"); let db_dir = dir.path().join("db"); let base_ts: u64 = 1_000_000; // Create a signed assertion with confidence 0.8 let mut csprng = OsRng; let signing_key = SigningKey::generate(&mut csprng); let verifying_key = signing_key.verifying_key(); // Sign for "Subject_B:predicate_test" (v1 format) let message = format!("{}:{}", "Subject_B", "predicate_test"); let signature = signing_key.sign(message.as_bytes()); // Create assertion with original confidence 0.8 let mut assertion = AssertionBuilder::new() .subject("Subject_B") .predicate("predicate_test") .object_text("value") .source_class(SourceClass::Clinical) .confidence(0.8) .lifecycle(LifecycleStage::Proposed) .timestamp(base_ts) .signatures(vec![SignatureEntry { agent_id: verifying_key.to_bytes(), signature: signature.to_bytes(), timestamp: base_ts, version: 1, }]) .build(); // Tamper: Change confidence to 1.0 after signing assertion.confidence = 1.0; // Write tampered assertion to WAL let mut journal = Journal::open(&wal_dir).expect("open journal"); journal.append(serialize_assertion(&assertion).expect("ser")).expect("append"); // Ingest via IngestWorker let journal = Arc::new(Mutex::new(journal)); let store = Arc::new(HybridStore::open(&db_dir).expect("open store")); let mut worker = IngestWorker::new(journal.clone(), store.clone()).await.expect("create worker"); let bytes = worker.step().await.expect("ingest step"); // v1 DESIGN LIMITATION: The tampered assertion is accepted because the v1 signature // only covers {subject}:{predicate}, not the confidence field. assert!( bytes > 0, "v1: tampered confidence is accepted (signature only covers subject:predicate)" ); // Verify assertion is stored let h_prefix = key_codec::assertion_key("Subject_B", ""); let h_entries = store.scan_prefix(&h_prefix).await.expect("scan H:"); assert_eq!( h_entries.len(), 1, "v1: tampered assertion is stored (confidence not covered by signature)" ); // Verify the stored assertion has the tampered confidence let (_key, value) = &h_entries[0]; let stored: Assertion = stemedb_core::serde::deserialize(value).expect("deserialize"); assert_eq!(stored.confidence, 1.0, "stored assertion should have tampered confidence 1.0"); } /// Test 6.2b: Tampered confidence IS detected with v2 signatures (enterprise security). /// /// Agent A signs assertion with confidence=0.8 using v2 signature. The v2 signature /// covers the BLAKE3 hash of the full serialized assertion. Modifying confidence /// after signing DOES invalidate the signature. /// /// This is the security improvement v2 provides over v1. #[tokio::test] async fn test_tampered_confidence_rejected_v2() { let dir = tempdir().expect("create temp dir"); let wal_dir = dir.path().join("wal"); let db_dir = dir.path().join("db"); let base_ts: u64 = 1_000_000; // Create a signed assertion with confidence 0.8 using v2 signing let mut csprng = OsRng; let signing_key = SigningKey::generate(&mut csprng); let verifying_key = signing_key.verifying_key(); // Build assertion WITHOUT signatures first (for hash computation) let mut assertion = AssertionBuilder::new() .subject("Subject_V2") .predicate("predicate_test") .object_text("value") .source_class(SourceClass::Clinical) .confidence(0.8) // Original confidence .lifecycle(LifecycleStage::Proposed) .timestamp(base_ts) .signatures(vec![]) .build(); // Serialize to get content hash (with confidence=0.8) let bytes_for_hash = serialize(&assertion).expect("serialize for hash"); let content_hash = blake3::hash(&bytes_for_hash); // Sign the content hash (v2 format) let signature = signing_key.sign(content_hash.as_bytes()); // Add v2 signature assertion.signatures = vec![SignatureEntry { agent_id: verifying_key.to_bytes(), signature: signature.to_bytes(), timestamp: base_ts, version: 2, }]; // TAMPER: Change confidence to 1.0 after signing // This changes the content hash, invalidating the v2 signature assertion.confidence = 1.0; // Write tampered assertion to WAL let mut journal = Journal::open(&wal_dir).expect("open journal"); journal.append(serialize_assertion(&assertion).expect("ser")).expect("append"); // Ingest via IngestWorker let journal = Arc::new(Mutex::new(journal)); let store = Arc::new(HybridStore::open(&db_dir).expect("open store")); let mut worker = IngestWorker::new(journal.clone(), store.clone()).await.expect("create worker"); // v2 SECURITY: Tampered assertion should be REJECTED let result = worker.step().await; assert!(result.is_err(), "v2: tampered confidence should be rejected"); // Verify the error is an invalid signature error let err = result.unwrap_err(); let err_str = err.to_string(); assert!( err_str.contains("Signature") || err_str.contains("verification"), "error should be related to signature verification, got: {}", err_str ); // Verify no assertion was stored let h_prefix = key_codec::assertion_key("Subject_V2", ""); let h_entries = store.scan_prefix(&h_prefix).await.expect("scan H:"); assert_eq!(h_entries.len(), 0, "v2: tampered assertion should NOT be stored"); } /// Test 6.3: Tampered subject is rejected. /// /// Agent A signs assertion with subject="Subject_C". Clone the assertion, /// change subject to "Subject_D", keep original signature. /// Assert: ingestion fails with invalid signature. #[tokio::test] async fn test_tampered_subject_rejected() { let dir = tempdir().expect("create temp dir"); let wal_dir = dir.path().join("wal"); let db_dir = dir.path().join("db"); let base_ts: u64 = 1_000_000; // Create a signed assertion with subject "Subject_C" let mut csprng = OsRng; let signing_key = SigningKey::generate(&mut csprng); let verifying_key = signing_key.verifying_key(); // Sign for "Subject_C:predicate_test" let message = format!("{}:{}", "Subject_C", "predicate_test"); let signature = signing_key.sign(message.as_bytes()); // Create assertion with original subject "Subject_C" let mut assertion = AssertionBuilder::new() .subject("Subject_C") .predicate("predicate_test") .object_text("value") .source_class(SourceClass::Clinical) .confidence(0.8) .lifecycle(LifecycleStage::Proposed) .timestamp(base_ts) .signatures(vec![SignatureEntry { agent_id: verifying_key.to_bytes(), signature: signature.to_bytes(), timestamp: base_ts, version: 1, }]) .build(); // Tamper: Change subject to "Subject_D" (but keep signature for "Subject_C") assertion.subject = "Subject_D".to_string(); // Write tampered assertion to WAL let mut journal = Journal::open(&wal_dir).expect("open journal"); journal.append(serialize_assertion(&assertion).expect("ser")).expect("append"); // Ingest via IngestWorker let journal = Arc::new(Mutex::new(journal)); let store = Arc::new(HybridStore::open(&db_dir).expect("open store")); let mut worker = IngestWorker::new(journal.clone(), store.clone()).await.expect("create worker"); // Attempt to ingest - should fail due to invalid signature let result = worker.step().await; assert!(result.is_err(), "tampered subject should fail signature verification"); // Verify the error is an invalid signature error let err = result.unwrap_err(); let err_str = err.to_string(); assert!( err_str.contains("Signature") || err_str.contains("verification"), "error should be related to signature verification, got: {}", err_str ); // Verify no assertion was stored let h_prefix = key_codec::assertion_key("Subject_D", ""); let h_entries = store.scan_prefix(&h_prefix).await.expect("scan H:"); assert_eq!(h_entries.len(), 0, "tampered assertion should NOT be stored"); } /// Test 6.4: Wrong agent_id is rejected. /// /// Agent A signs assertion. Replace `agent_id` in the `SignatureEntry` with /// Agent B's public key (but keep Agent A's signature bytes). /// Assert: ingestion fails - the signature was made by A's private key but /// claims to be from B's public key. #[tokio::test] async fn test_wrong_agent_id_rejected() { let dir = tempdir().expect("create temp dir"); let wal_dir = dir.path().join("wal"); let db_dir = dir.path().join("db"); let base_ts: u64 = 1_000_000; // Create Agent A's key pair and sign the assertion let mut csprng = OsRng; let signing_key_a = SigningKey::generate(&mut csprng); // Create Agent B's key pair (we'll use B's public key to tamper) let signing_key_b = SigningKey::generate(&mut csprng); let verifying_key_b = signing_key_b.verifying_key(); // Agent A signs for "Subject_E:predicate_test" let message = format!("{}:{}", "Subject_E", "predicate_test"); let signature_a = signing_key_a.sign(message.as_bytes()); // Create assertion with Agent A's signature but Agent B's public key let assertion = AssertionBuilder::new() .subject("Subject_E") .predicate("predicate_test") .object_text("value") .source_class(SourceClass::Clinical) .confidence(0.8) .lifecycle(LifecycleStage::Proposed) .timestamp(base_ts) .signatures(vec![SignatureEntry { agent_id: verifying_key_b.to_bytes(), signature: signature_a.to_bytes(), timestamp: base_ts, version: 1, }]) .build(); // Write tampered assertion to WAL let mut journal = Journal::open(&wal_dir).expect("open journal"); journal.append(serialize_assertion(&assertion).expect("ser")).expect("append"); // Ingest via IngestWorker let journal = Arc::new(Mutex::new(journal)); let store = Arc::new(HybridStore::open(&db_dir).expect("open store")); let mut worker = IngestWorker::new(journal.clone(), store.clone()).await.expect("create worker"); // Attempt to ingest - should fail because signature was made by A but claims to be from B let result = worker.step().await; assert!( result.is_err(), "wrong agent_id should fail signature verification (signature made by A, claims to be from B)" ); // Verify the error is an invalid signature error let err = result.unwrap_err(); let err_str = err.to_string(); assert!( err_str.contains("Signature") || err_str.contains("verification"), "error should be related to signature verification, got: {}", err_str ); // Verify no assertion was stored let h_prefix = key_codec::assertion_key("Subject_E", ""); let h_entries = store.scan_prefix(&h_prefix).await.expect("scan H:"); assert_eq!(h_entries.len(), 0, "tampered assertion should NOT be stored"); }