Phase 5C (Index Persistence) implementation: - PersistentVectorIndex with hot/cold architecture - Hot: in-memory HNSW for recent vectors - Cold: memory-mapped HNSW loaded from disk - Background builder for WAL replay and atomic swap - BLAKE3 integrity verification - PersistentVisualIndex with checkpoint persistence - BkTreeSnapshot with rkyv serialization - CRC32C corruption detection - Atomic write pattern (temp → fsync → rename) - Key codec additions for vector index metadata - Split large files into modules (<500 lines each) - battery_pre_sentinel.rs → battery/ directory - visual_index.rs → visual_index/ directory - persistent.rs → persistent/ directory - Refactored ingest worker tests for clarity - Updated roadmap to mark Phase 5 complete Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
449 lines
17 KiB
Rust
449 lines
17 KiB
Rust
//! 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 | Accepted and stored |
|
|
//! | `test_tampered_confidence_not_detected` | Design limit | Confidence not covered by sig |
|
|
//! | `test_tampered_subject_rejected` | Subject tamper | Rejected |
|
|
//! | `test_wrong_agent_id_rejected` | Agent ID mismatch | Rejected |
|
|
//! | `test_multi_sig_all_valid_accepted` | Multi-sig valid | Accepted |
|
|
//! | `test_multi_sig_one_invalid_rejected` | Multi-sig partial | Rejected |
|
|
|
|
#![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 (design limitation).
|
|
///
|
|
/// Agent A signs assertion with confidence=0.8. The signature only covers
|
|
/// `{subject}:{predicate}`, not the confidence field. Modifying confidence
|
|
/// after signing does NOT invalidate the signature.
|
|
///
|
|
/// This test documents the current behavior: changing confidence won't fail
|
|
/// verification because it's not part of the signed message. This is a known
|
|
/// design limitation - the signature scheme should be extended to cover the
|
|
/// full assertion content hash if tamper detection is required.
|
|
#[tokio::test]
|
|
async fn test_tampered_confidence_not_detected() {
|
|
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"
|
|
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,
|
|
}])
|
|
.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");
|
|
|
|
// DESIGN LIMITATION: The tampered assertion is accepted because the signature
|
|
// only covers {subject}:{predicate}, not the confidence field.
|
|
assert!(bytes > 0, "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,
|
|
"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.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,
|
|
}])
|
|
.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(), // TAMPERED: Using Agent B's public key
|
|
signature: signature_a.to_bytes(), // But Agent A's signature
|
|
timestamp: base_ts,
|
|
}])
|
|
.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");
|
|
}
|
|
|
|
/// Test 6.5: Multi-sig with all valid signatures is accepted.
|
|
///
|
|
/// Agent A and Agent B both sign the same assertion (two valid SignatureEntries).
|
|
/// Assert: ingestion succeeds.
|
|
#[tokio::test]
|
|
async fn test_multi_sig_all_valid_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 Agent A's key pair
|
|
let mut csprng = OsRng;
|
|
let signing_key_a = SigningKey::generate(&mut csprng);
|
|
let verifying_key_a = signing_key_a.verifying_key();
|
|
|
|
// Create Agent B's key pair
|
|
let signing_key_b = SigningKey::generate(&mut csprng);
|
|
let verifying_key_b = signing_key_b.verifying_key();
|
|
|
|
// Both agents sign the same message "Subject_F:predicate_test"
|
|
let message = format!("{}:{}", "Subject_F", "predicate_test");
|
|
let signature_a = signing_key_a.sign(message.as_bytes());
|
|
let signature_b = signing_key_b.sign(message.as_bytes());
|
|
|
|
// Create assertion with two valid signatures
|
|
let assertion = AssertionBuilder::new()
|
|
.subject("Subject_F")
|
|
.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_a.to_bytes(),
|
|
signature: signature_a.to_bytes(),
|
|
timestamp: base_ts,
|
|
},
|
|
SignatureEntry {
|
|
agent_id: verifying_key_b.to_bytes(),
|
|
signature: signature_b.to_bytes(),
|
|
timestamp: base_ts,
|
|
},
|
|
])
|
|
.build();
|
|
|
|
// 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("multi-sig should be accepted");
|
|
assert!(bytes > 0, "should process data from WAL");
|
|
|
|
// Verify assertion is stored
|
|
let h_prefix = key_codec::assertion_key("Subject_F", "");
|
|
let h_entries = store.scan_prefix(&h_prefix).await.expect("scan H:");
|
|
assert_eq!(h_entries.len(), 1, "multi-sig assertion should be stored");
|
|
|
|
// Verify the stored assertion has both signatures
|
|
let (_key, value) = &h_entries[0];
|
|
let stored: Assertion = stemedb_core::serde::deserialize(value).expect("deserialize");
|
|
assert_eq!(stored.signatures.len(), 2, "stored assertion should have 2 signatures");
|
|
}
|
|
|
|
/// Test 6.6: Multi-sig with one invalid signature is rejected.
|
|
///
|
|
/// Agent A signs validly, Agent B's signature is invalid (tampered).
|
|
/// Assert: ingestion fails. ALL signatures must be valid.
|
|
#[tokio::test]
|
|
async fn test_multi_sig_one_invalid_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
|
|
let mut csprng = OsRng;
|
|
let signing_key_a = SigningKey::generate(&mut csprng);
|
|
let verifying_key_a = signing_key_a.verifying_key();
|
|
|
|
// Create Agent B's key pair
|
|
let signing_key_b = SigningKey::generate(&mut csprng);
|
|
let verifying_key_b = signing_key_b.verifying_key();
|
|
|
|
// Agent A signs correctly for "Subject_G:predicate_test"
|
|
let message = format!("{}:{}", "Subject_G", "predicate_test");
|
|
let signature_a = signing_key_a.sign(message.as_bytes());
|
|
|
|
// Agent B signs a DIFFERENT message (tampered)
|
|
let wrong_message = format!("{}:{}", "Wrong_Subject", "predicate_test");
|
|
let signature_b_wrong = signing_key_b.sign(wrong_message.as_bytes());
|
|
|
|
// Create assertion with one valid and one invalid signature
|
|
let assertion = AssertionBuilder::new()
|
|
.subject("Subject_G")
|
|
.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_a.to_bytes(),
|
|
signature: signature_a.to_bytes(), // Valid
|
|
timestamp: base_ts,
|
|
},
|
|
SignatureEntry {
|
|
agent_id: verifying_key_b.to_bytes(),
|
|
signature: signature_b_wrong.to_bytes(), // Invalid (signed wrong message)
|
|
timestamp: base_ts,
|
|
},
|
|
])
|
|
.build();
|
|
|
|
// 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");
|
|
|
|
// Attempt to ingest - should fail because one signature is invalid
|
|
let result = worker.step().await;
|
|
assert!(
|
|
result.is_err(),
|
|
"multi-sig with one invalid signature should fail (ALL signatures must be valid)"
|
|
);
|
|
|
|
// 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_G", "");
|
|
let h_entries = store.scan_prefix(&h_prefix).await.expect("scan H:");
|
|
assert_eq!(h_entries.len(), 0, "multi-sig with invalid signature should NOT be stored");
|
|
}
|