use chrono::SecondsFormat; use rusqlite::{params, Connection, OptionalExtension}; use std::path::Path; use trusty_core::{ checkpoint_signing_bytes, compute_record, compute_record_signed, default_conditions, evaluate, make_chain_id, parse_generation as core_parse_generation, verify_links, verify_links_from, AnchorEntry, BundleRow, ChainLink, Condition, ConditionContext, ConditionOutcome, Ed25519Verifier, EventType, PayloadFields, ProofBundle, SecretKey, Signer, Verifier, VerifyError, VerifyErrorType, VerifyReport, GENESIS_SENTINEL, }; // Higher-level result types live in trusty-store (S16, promoted from // this crate so trusty-postgres and any future backend reuse them). // Re-exported here so existing trusty-sqlite consumers keep working. pub use trusty_store::{ AnchorInfo, AppendResult, BreakEvent, CheckpointRow, HistoryRow, IntegrityMismatch, IntegrityReport, ProducerIdentity, RevocationRecord, RowSpec, SilentEntity, }; use crate::error::{SqliteError, SqliteResult}; use crate::schema; pub struct Trusty { conn: Connection, key: SecretKey, conditions: Vec>, /// Refuse appends that carry no signature. /// /// An unsigned row records content without recording WHO wrote it. Its /// hashes still verify, so it looks fine to anyone reading the chain -- but /// nothing attributes it, and an attacker able to write to the store can /// produce one with perfectly correct hashes. Integrity without /// authenticity is close to worthless against a hostile writer. /// /// Off by default so existing callers are unaffected; turn it on for any /// chain whose rows are evidence. signed_only: bool, } struct LastRow { row_hash: String, sequence_int: i64, } struct TipRow { chain_id: String, current_tip_hash: Option, } impl Trusty { pub fn open(path: impl AsRef, key: SecretKey) -> SqliteResult { let conn = Connection::open(path)?; conn.execute_batch(schema::SCHEMA_SQL)?; Ok(Self { conn, key, conditions: default_conditions(), signed_only: false, }) } pub fn open_with_conditions( path: impl AsRef, key: SecretKey, conditions: Vec>, ) -> SqliteResult { let conn = Connection::open(path)?; conn.execute_batch(schema::SCHEMA_SQL)?; Ok(Self { conn, key, conditions, signed_only: false, }) } pub fn open_in_memory(key: SecretKey) -> SqliteResult { let conn = Connection::open_in_memory()?; conn.execute_batch(schema::SCHEMA_SQL)?; Ok(Self { conn, key, conditions: default_conditions(), signed_only: false, }) } /// In-memory store with custom conditions (the integrity defaults PLUS any /// runtime monitors). The in-memory twin of `open_with_conditions`, so a /// monitor can be exercised without a file. pub fn open_in_memory_with_conditions( key: SecretKey, conditions: Vec>, ) -> SqliteResult { let conn = Connection::open_in_memory()?; conn.execute_batch(schema::SCHEMA_SQL)?; Ok(Self { conn, key, conditions, signed_only: false, }) } /// Refuse unsigned appends from now on. /// /// Existing unsigned rows are untouched -- rewriting history is exactly /// what this crate exists to prevent. `PublicVerifyReport::fully_signed` /// reports whether a chain already contains any. pub fn set_signed_only(&mut self, signed_only: bool) { self.signed_only = signed_only; } /// Whether unsigned appends are currently refused. pub fn is_signed_only(&self) -> bool { self.signed_only } pub fn append( &mut self, entity_id: &str, data: &serde_json::Value, source_url: &str, ) -> SqliteResult { self.append_with_method(entity_id, data, source_url, "trusty") } /// v2-payload append: attach an Ed25519 signature (or any `Signer` /// impl). Row content and chain semantics are otherwise identical /// to `append`. Defaults `extraction_method` to "trusty". pub fn append_signed( &mut self, entity_id: &str, data: &serde_json::Value, source_url: &str, signer: &dyn Signer, ) -> SqliteResult { self.append_with_method_inner(entity_id, data, source_url, "trusty", Some(signer)) } pub fn append_with_method( &mut self, entity_id: &str, data: &serde_json::Value, source_url: &str, extraction_method: &str, ) -> SqliteResult { self.append_with_method_inner(entity_id, data, source_url, extraction_method, None) } /// v2-payload variant of `append_with_method`. See `append_signed`. pub fn append_with_method_signed( &mut self, entity_id: &str, data: &serde_json::Value, source_url: &str, extraction_method: &str, signer: &dyn Signer, ) -> SqliteResult { self.append_with_method_inner(entity_id, data, source_url, extraction_method, Some(signer)) } fn append_with_method_inner( &mut self, entity_id: &str, data: &serde_json::Value, source_url: &str, extraction_method: &str, signer: Option<&dyn Signer>, ) -> SqliteResult { // Single choke point: every append path -- genesis, mutation, and the // method-carrying variants -- funnels through here, so the check // cannot be bypassed by picking a different entry point. if self.signed_only && signer.is_none() { return Err(SqliteError::UnsignedAppendRefused(entity_id.to_string())); } let detected_at = now_iso(); let received_at = now_iso_micros(); let tip = self.read_tip(entity_id)?; let tip = match tip { None => { return self.create_genesis( entity_id, 1, data, source_url, extraction_method, &detected_at, &received_at, None, None, signer, ); } Some(t) => t, }; let chain_id = tip.chain_id.clone(); let last = self.read_last(entity_id, &chain_id)?; let (sequence, last_sequence, computed_hash) = match &last { Some(l) => ( l.sequence_int + 1, Some(l.sequence_int), Some(l.row_hash.clone()), ), None => (0, None, None), }; let ctx = ConditionContext { entity_id: entity_id.to_string(), chain_id: chain_id.clone(), sequence, stored_tip_hash: tip.current_tip_hash.clone(), computed_hash: computed_hash.clone(), prev_hash: tip.current_tip_hash.clone(), last_sequence, source_url: source_url.to_string(), extraction_method: extraction_method.to_string(), // The condition can now read the measured payload (gap B). data: data.clone(), }; match evaluate(&self.conditions, &ctx) { ConditionOutcome::Pass => { let prev_hash = last .as_ref() .map(|l| l.row_hash.clone()) .unwrap_or_else(|| GENESIS_SENTINEL.to_string()); self.write_mutation( entity_id, &chain_id, data, source_url, extraction_method, &detected_at, &received_at, &prev_hash, sequence, signer, ) } ConditionOutcome::Fail(reason) => { let last_valid_hash = last .as_ref() .map(|l| l.row_hash.clone()) .unwrap_or_else(|| GENESIS_SENTINEL.to_string()); self.handle_break( entity_id, &chain_id, &reason_code(&reason), &last_valid_hash, data, source_url, extraction_method, &detected_at, &received_at, signer, ) } } } #[allow(clippy::too_many_arguments)] fn create_genesis( &mut self, entity_id: &str, generation: u32, data: &serde_json::Value, source_url: &str, extraction_method: &str, detected_at: &str, received_at: &str, sealed_chain: Option, break_reason: Option, signer: Option<&dyn Signer>, ) -> SqliteResult { let chain_id = make_chain_id(entity_id, generation); let prev_hash = GENESIS_SENTINEL.to_string(); let sequence: i64 = 0; let fields = PayloadFields::new_v1( entity_id, chain_id.clone(), data.clone(), source_url, detected_at, received_at, extraction_method, prev_hash.clone(), sequence, ); let computed = match signer { Some(s) => compute_record_signed(&fields, &self.key, s)?, None => compute_record(&fields, &self.key)?, }; self.write_envelope( &chain_id, entity_id, EventType::Genesis.as_str(), None, None, None, source_url, detected_at, extraction_method, &prev_hash, Some(&computed.row_hash), Some(&computed.anchor_hash), sequence, Some(&computed.raw_payload), )?; self.append_hash_index(&computed.row_hash, &chain_id, sequence, entity_id)?; self.set_tip(entity_id, &chain_id, Some(&computed.row_hash))?; let chain_break = sealed_chain.is_some(); Ok(AppendResult { entity_id: entity_id.to_string(), chain_id, event_type: EventType::Genesis.as_str().to_string(), chain_break, break_reason, sealed_chain, row_hash: computed.row_hash, anchor_hash: computed.anchor_hash, sequence, is_genesis: !chain_break, }) } #[allow(clippy::too_many_arguments)] fn write_mutation( &mut self, entity_id: &str, chain_id: &str, data: &serde_json::Value, source_url: &str, extraction_method: &str, detected_at: &str, received_at: &str, prev_hash: &str, sequence: i64, signer: Option<&dyn Signer>, ) -> SqliteResult { let fields = PayloadFields::new_v1( entity_id, chain_id, data.clone(), source_url, detected_at, received_at, extraction_method, prev_hash, sequence, ); let computed = match signer { Some(s) => compute_record_signed(&fields, &self.key, s)?, None => compute_record(&fields, &self.key)?, }; self.write_envelope( chain_id, entity_id, EventType::Mutation.as_str(), None, None, None, source_url, detected_at, extraction_method, prev_hash, Some(&computed.row_hash), Some(&computed.anchor_hash), sequence, Some(&computed.raw_payload), )?; self.append_hash_index(&computed.row_hash, chain_id, sequence, entity_id)?; self.set_tip(entity_id, chain_id, Some(&computed.row_hash))?; Ok(AppendResult { entity_id: entity_id.to_string(), chain_id: chain_id.to_string(), event_type: EventType::Mutation.as_str().to_string(), chain_break: false, break_reason: None, sealed_chain: None, row_hash: computed.row_hash, anchor_hash: computed.anchor_hash, sequence, is_genesis: false, }) } #[allow(clippy::too_many_arguments)] fn handle_break( &mut self, entity_id: &str, old_chain_id: &str, break_reason: &str, last_valid_hash: &str, data: &serde_json::Value, source_url: &str, extraction_method: &str, detected_at: &str, received_at: &str, signer: Option<&dyn Signer>, ) -> SqliteResult { self.write_envelope( old_chain_id, entity_id, EventType::Break.as_str(), Some("__chain_break__"), Some(last_valid_hash), Some(break_reason), source_url, detected_at, extraction_method, last_valid_hash, None, None, -1, None, )?; let current_gen = parse_generation(old_chain_id)?; let next_gen = current_gen + 1; let mut result = self.create_genesis( entity_id, next_gen, data, source_url, extraction_method, detected_at, received_at, Some(old_chain_id.to_string()), Some(break_reason.to_string()), signer, )?; result.event_type = "BREAK".to_string(); result.is_genesis = false; Ok(result) } pub fn verify(&self, entity_id: &str) -> SqliteResult { let mut stmt = self.conn.prepare( "SELECT chain_id, event_type, sequence_int, prev_hash, row_hash, anchor_hash, raw_payload FROM provenance_envelope WHERE entity_id = ? ORDER BY id ASC", )?; let rows = stmt.query_map(params![entity_id], |r| { let chain_id: String = r.get(0)?; let event_type: String = r.get(1)?; let sequence: i64 = r.get(2)?; let prev_hash: String = r.get(3)?; let row_hash: Option = r.get(4)?; let anchor_hash: Option = r.get(5)?; let raw_payload: Option = r.get(6)?; Ok(ChainLink { entity_id: entity_id.to_string(), chain_id, event_type, sequence, prev_hash, row_hash, anchor_hash, raw_payload, }) })?; let mut links: Vec = Vec::new(); for link in rows { links.push(link?); } Ok(verify_links(entity_id, &links, &self.key)) } pub fn anchor(&self, entity_id: &str) -> SqliteResult> { let row: Option<(String, Option)> = self .conn .query_row( "SELECT chain_id, current_tip_hash FROM chain_tips WHERE entity_id = ?", params![entity_id], |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; Ok(row.map(|(chain_id, tip_hash)| AnchorInfo { entity_id: entity_id.to_string(), chain_id, tip_hash, anchored_at: now_iso(), })) } /// Append many rows inside a single transaction. Amortizes fsync /// (WAL sync) cost across the whole batch; expected large /// speedup on file-backed databases over a loop of single /// `append` calls. Semantics match `append` per-row: a chain /// break during the batch returns `Ok(AppendResult { /// chain_break: true, .. })` for that row and continues -- it is /// not a batch-level error. Only genuine errors (SQL failure, /// crypto failure) roll the whole batch back. pub fn append_batch(&mut self, specs: &[RowSpec]) -> SqliteResult> { if specs.is_empty() { return Ok(Vec::new()); } self.conn.execute_batch("BEGIN IMMEDIATE")?; let mut out = Vec::with_capacity(specs.len()); for spec in specs { match self.append_with_method(spec.entity_id, spec.data, spec.source_url, "trusty") { Ok(r) => out.push(r), Err(e) => { let _ = self.conn.execute_batch("ROLLBACK"); return Err(e); } } } self.conn.execute_batch("COMMIT")?; Ok(out) } /// Return chain-break events for `entity_id` whose `detected_at` /// is strictly greater than `since` (or all of them when `since` /// is `None`). Events are in insertion order (id ASC). Pairs /// with `anchors_since` for a two-feed model: anchors are the /// happy-path commitments, breaks are the structural incidents. /// Apps building alerting / incident dashboards over trusty /// should subscribe to both. pub fn breaks_since( &self, entity_id: &str, since: Option<&str>, ) -> SqliteResult> { let mut stmt = self.conn.prepare( "SELECT chain_id, old_value, new_value, detected_at FROM provenance_envelope WHERE entity_id = ?1 AND event_type = 'BREAK' AND (?2 IS NULL OR detected_at > ?2) ORDER BY id ASC", )?; let rows = stmt.query_map(params![entity_id, since], |r| { Ok(BreakEvent { entity_id: entity_id.to_string(), chain_id: r.get(0)?, sealed_at_hash: r.get::<_, Option>(1)?.unwrap_or_default(), break_reason: r.get::<_, Option>(2)?.unwrap_or_default(), detected_at: r.get(3)?, }) })?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } /// Poll for entities that have gone silent. Returns every entity /// whose most recent non-BREAK row was appended more than /// `max_gap_secs` seconds ago. Pass `entity_id = Some(id)` to /// check a single agent; pass `None` to sweep all known entities. /// /// Complement to `breaks_since`: breaks fire on tamper, liveness /// fires on silence. Together they cover the full agent-health surface. pub fn liveness_check( &self, entity_id: Option<&str>, max_gap_secs: i64, ) -> SqliteResult> { let mut stmt = self.conn.prepare( "SELECT entity_id, MAX(detected_at) AS last_seen, CAST((julianday('now') - julianday(MAX(detected_at))) * 86400 AS REAL) AS gap_secs FROM provenance_envelope WHERE event_type != 'BREAK' AND (?1 IS NULL OR entity_id = ?1) GROUP BY entity_id HAVING gap_secs > ?2 ORDER BY gap_secs DESC", )?; let rows = stmt.query_map(params![entity_id, max_gap_secs], |r| { Ok(SilentEntity { entity_id: r.get(0)?, last_seen: r.get(1)?, gap_secs: r.get(2)?, }) })?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } /// Return every per-row anchor commitment for `entity_id` whose /// `detected_at` is strictly greater than `since` (or all of them /// when `since` is `None`). Rows are returned in insertion order /// (id ASC), which matches chain order for the rows that carry an /// anchor_hash. BREAK rows (no row_hash, no anchor_hash) are /// excluded; they are chain structural events, not commitments. /// Intended use: periodic external publication of anchor hashes /// so a third party can later spot-check a ProofBundle without /// the database. pub fn anchors_since( &self, entity_id: &str, since: Option<&str>, ) -> SqliteResult> { let mut stmt = self.conn.prepare( "SELECT sequence_int, anchor_hash, detected_at FROM provenance_envelope WHERE entity_id = ?1 AND row_hash IS NOT NULL AND anchor_hash IS NOT NULL AND (?2 IS NULL OR detected_at > ?2) ORDER BY id ASC", )?; let rows = stmt.query_map(params![entity_id, since], |r| { let anchor_hash: String = r.get(1)?; Ok(AnchorEntry { sequence: r.get(0)?, anchor_hash, detected_at: r.get(2)?, }) })?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } /// Package a slice of the chain into a self-contained /// `ProofBundle`. Non-BREAK rows are filtered by `sequence` in /// `[since, until]` (inclusive; unbounded on either side when /// `None`). BREAK rows for the entity are always included so /// chain-break-as-event proof survives export. Anchors for the /// same slice are attached. The resulting bundle can be handed /// to `ProofBundle::verify(key)` without database access. pub fn export_bundle( &self, entity_id: &str, since: Option, until: Option, ) -> SqliteResult { let lo = since.unwrap_or(i64::MIN); let hi = until.unwrap_or(i64::MAX); let mut stmt = self.conn.prepare( "SELECT chain_id, event_type, sequence_int, prev_hash, row_hash, anchor_hash, raw_payload, detected_at, new_value FROM provenance_envelope WHERE entity_id = ?1 AND ( (event_type != 'BREAK' AND sequence_int >= ?2 AND sequence_int <= ?3) OR event_type = 'BREAK' ) ORDER BY id ASC", )?; let mapped = stmt.query_map(params![entity_id, lo, hi], |r| { let chain_id: String = r.get(0)?; let event_type: String = r.get(1)?; let sequence: i64 = r.get(2)?; let prev_hash: String = r.get(3)?; let row_hash: Option = r.get(4)?; let anchor_hash: Option = r.get(5)?; let raw_payload: Option = r.get(6)?; let detected_at: Option = r.get(7)?; // A BREAK row keeps its reason in new_value. Carrying it out // is what lets a remote party learn WHICH rule broke rather // than only that something did. let break_reason: Option = if event_type == "BREAK" { r.get(8)? } else { None }; Ok(( BundleRow::new( entity_id, chain_id, event_type.clone(), sequence, prev_hash, ) .with_hashes(row_hash.clone(), anchor_hash.clone()) .with_payload(raw_payload) .with_break_reason(break_reason), event_type, sequence, anchor_hash, detected_at.unwrap_or_default(), )) })?; let mut rows: Vec = Vec::new(); let mut anchors: Vec = Vec::new(); for m in mapped { let (row, event_type, seq, anchor_hash, detected_at) = m?; if event_type != "BREAK" { if let Some(ah) = anchor_hash { anchors.push(AnchorEntry { sequence: seq, anchor_hash: ah, detected_at, }); } } rows.push(row); } Ok(ProofBundle::new( entity_id.to_string(), now_iso(), rows, anchors, )) } pub fn history(&self, entity_id: &str, limit: i64) -> SqliteResult> { let mut stmt = self.conn.prepare( "SELECT id, chain_id, entity_id, event_type, sequence_int, detected_at, source_url, row_hash, anchor_hash, raw_payload FROM provenance_envelope WHERE entity_id = ? AND event_type != 'BREAK' AND row_hash IS NOT NULL ORDER BY id ASC LIMIT ?", )?; let rows = stmt.query_map(params![entity_id, limit], |r| { Ok(HistoryRow { id: r.get(0)?, chain_id: r.get(1)?, entity_id: r.get(2)?, event_type: r.get(3)?, sequence: r.get(4)?, detected_at: r.get(5)?, source_url: r.get(6)?, row_hash: r.get(7)?, anchor_hash: r.get(8)?, raw_payload: r.get(9)?, }) })?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } pub fn integrity(&self) -> SqliteResult { let kv1_total_hashes: i64 = self.conn .query_row("SELECT COUNT(*) FROM hash_index", [], |r| r.get(0))?; let mut tips_stmt = self .conn .prepare("SELECT entity_id, current_tip_hash, chain_id FROM chain_tips")?; let tip_rows = tips_stmt.query_map([], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, Option>(1)?, r.get::<_, Option>(2)?, )) })?; let mut mismatches: Vec = Vec::new(); let mut entities_checked: usize = 0; let mut kv2_total_entities: usize = 0; for row in tip_rows { let (entity_id, tip_hash, chain_id) = row?; kv2_total_entities += 1; let tip_hash = match tip_hash { Some(h) => h, None => continue, }; let located: Option = self .conn .query_row( "SELECT chain_id FROM hash_index WHERE row_hash = ?", params![tip_hash], |r| r.get(0), ) .optional()?; match located { None => mismatches.push(IntegrityMismatch { entity_id: entity_id.clone(), reason: "tip_hash_not_in_index".to_string(), detail: Some(tip_hash.clone()), }), Some(idx_chain) => { if Some(idx_chain.as_str()) != chain_id.as_deref() { mismatches.push(IntegrityMismatch { entity_id: entity_id.clone(), reason: "chain_id_mismatch".to_string(), detail: Some(idx_chain), }); } } } entities_checked += 1; } Ok(IntegrityReport { ok: mismatches.is_empty(), entities_checked, kv1_total_hashes: kv1_total_hashes as usize, kv2_total_entities, mismatches, }) } // ---------------------------------------------------------------- // Federation primitives (S17 cycle 4) -- mirror of trusty-postgres. // producer_identity (OID -> ed25519 pubkey + siphash key), // producer_revocation (forward-only, CT stance), // chain_checkpoint (verification-layer compaction primitive). // Implementations + auth-gating semantics match trusty-postgres // verbatim; SQL dialect is the only difference. Tested in the // same #[cfg(test)] mod below. // ---------------------------------------------------------------- pub fn register_producer( &mut self, producer_oid: &str, ed25519_pubkey: &[u8], siphash_key: &[u8], display_name: Option<&str>, ) -> SqliteResult<()> { self.register_producer_with_format( producer_oid, ed25519_pubkey, siphash_key, display_name, trusty_core::KeyFormat::Ed25519Raw, ) } /// Register a producer, declaring how its key material is encoded. /// /// A user enrolling through WebAuthn registers a COSE credential and /// must say so; verification then refuses the bare-signature path /// for that producer rather than failing in a way that reads like a /// wrong key. Mirrors `trusty-postgres` -- a guarantee that holds on /// one backend and not the other is worse than one that holds on /// neither, because it works in testing. pub fn register_producer_with_format( &mut self, producer_oid: &str, ed25519_pubkey: &[u8], siphash_key: &[u8], display_name: Option<&str>, key_format: trusty_core::KeyFormat, ) -> SqliteResult<()> { let registered_at = now_iso(); self.conn.execute( "INSERT INTO producer_identity (producer_oid, ed25519_pubkey, siphash_key, registered_at, display_name, key_format) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(producer_oid) DO UPDATE SET ed25519_pubkey = excluded.ed25519_pubkey, siphash_key = excluded.siphash_key, display_name = excluded.display_name, key_format = excluded.key_format", params![ producer_oid, ed25519_pubkey, siphash_key, registered_at, display_name, key_format.as_str() ], )?; Ok(()) } pub fn lookup_producer(&self, producer_oid: &str) -> SqliteResult> { let row = self .conn .query_row( "SELECT producer_oid, ed25519_pubkey, siphash_key, registered_at, display_name, metadata, key_format FROM producer_identity WHERE producer_oid = ?", params![producer_oid], |r| { let metadata_str: Option = r.get(5)?; let metadata = metadata_str .and_then(|s| serde_json::from_str::(&s).ok()); let format_str: String = r.get(6)?; // An unparseable format is a refusal, never a // fallback to raw: guessing wrong here means // checking a WebAuthn credential as a bare // signature. let key_format = trusty_core::KeyFormat::parse(&format_str) .map_err(|e| rusqlite::Error::FromSqlConversionFailure( 6, rusqlite::types::Type::Text, Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), ))?; Ok(ProducerIdentity { producer_oid: r.get(0)?, ed25519_pubkey: r.get(1)?, siphash_key: r.get(2)?, key_format, registered_at: r.get(3)?, display_name: r.get(4)?, metadata, }) }, ) .optional()?; Ok(row) } pub fn revoke_producer( &mut self, producer_oid: &str, reason: &str, attestor: &dyn Signer, ) -> SqliteResult { let identity = self.lookup_producer(producer_oid)?.ok_or_else(|| { SqliteError::NotFound(format!( "revoke_producer: {} not in producer_identity", producer_oid )) })?; let attested_by_pubkey = attestor.public_key_bytes(); // 1. Append a REVOCATION envelope on the per-user "revocations" // chain. Same pattern as trusty-postgres. let revocation_data = serde_json::json!({ "kind": "REVOCATION", "producer_oid": producer_oid, "revoked_pubkey_hex": hex::encode(&identity.ed25519_pubkey), "reason": reason, "attested_by_pubkey_hex": hex::encode(&attested_by_pubkey), }); let envelope = self.append_signed("revocations", &revocation_data, "src", attestor)?; // 2. UPSERT producer_revocation index row. let revoked_at = now_iso(); self.conn.execute( "INSERT INTO producer_revocation (producer_oid, revoked_at, revoked_pubkey, reason, attested_by_pubkey, revocation_row_hash) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(producer_oid) DO UPDATE SET revoked_at = excluded.revoked_at, revoked_pubkey = excluded.revoked_pubkey, reason = excluded.reason, attested_by_pubkey = excluded.attested_by_pubkey, revocation_row_hash = excluded.revocation_row_hash", params![ producer_oid, revoked_at, identity.ed25519_pubkey, reason, attested_by_pubkey, envelope.row_hash, ], )?; Ok(RevocationRecord { producer_oid: producer_oid.to_string(), revoked_at, revoked_pubkey: identity.ed25519_pubkey, reason: reason.to_string(), attested_by_pubkey, revocation_row_hash: envelope.row_hash, }) } pub fn lookup_revocation(&self, producer_oid: &str) -> SqliteResult> { let row = self .conn .query_row( "SELECT producer_oid, revoked_at, revoked_pubkey, reason, attested_by_pubkey, revocation_row_hash FROM producer_revocation WHERE producer_oid = ?", params![producer_oid], |r| { Ok(RevocationRecord { producer_oid: r.get(0)?, revoked_at: r.get(1)?, revoked_pubkey: r.get(2)?, reason: r.get(3)?, attested_by_pubkey: r.get(4)?, revocation_row_hash: r.get(5)?, }) }, ) .optional()?; Ok(row) } pub fn checkpoint( &mut self, entity_id: &str, attested_by: &str, signer: &dyn Signer, ) -> SqliteResult { let tip = self.read_tip(entity_id)?.ok_or_else(|| { SqliteError::NotFound(format!("checkpoint: no tip for entity {}", entity_id)) })?; let last = self.read_last(entity_id, &tip.chain_id)?.ok_or_else(|| { SqliteError::NotFound(format!("checkpoint: chain_id {} has no rows", tip.chain_id)) })?; let row_hash = last.row_hash.clone(); let sequence_int = last.sequence_int; let to_sign = checkpoint_signing_bytes(&tip.chain_id, sequence_int, &row_hash); let sig = signer.sign(&to_sign); let checkpoint_at = now_iso(); // SQLite supports RETURNING since 3.35; rusqlite handles it. let id: i64 = self.conn.query_row( "INSERT INTO chain_checkpoint (chain_id, sequence_int, row_hash, checkpoint_at, attested_by, attestation_sig) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", params![ tip.chain_id, sequence_int, row_hash, checkpoint_at, attested_by, sig, ], |r| r.get(0), )?; Ok(CheckpointRow { id, chain_id: tip.chain_id, sequence_int, row_hash, checkpoint_at, attested_by: attested_by.to_string(), attestation_sig: sig, }) } pub fn latest_checkpoint(&self, chain_id: &str) -> SqliteResult> { let row = self .conn .query_row( "SELECT id, chain_id, sequence_int, row_hash, checkpoint_at, attested_by, attestation_sig FROM chain_checkpoint WHERE chain_id = ? ORDER BY id DESC LIMIT 1", params![chain_id], |r| { Ok(CheckpointRow { id: r.get(0)?, chain_id: r.get(1)?, sequence_int: r.get(2)?, row_hash: r.get(3)?, checkpoint_at: r.get(4)?, attested_by: r.get(5)?, attestation_sig: r.get(6)?, }) }, ) .optional()?; Ok(row) } /// Window-pruned verify with checkpoint authentication. See the /// trusty-postgres twin for full semantics. Same authentication /// rules: checkpoint's attested_by must be in producer_identity; /// attestation_sig must verify; if revoked AND checkpoint_at >= /// revoked_at, reject. pub fn verify_since_checkpoint(&self, entity_id: &str) -> SqliteResult { let tip = match self.read_tip(entity_id)? { Some(t) => t, None => return self.verify(entity_id), }; let ckpt = match self.latest_checkpoint(&tip.chain_id)? { Some(c) => c, None => return self.verify(entity_id), }; let identity = match self.lookup_producer(&ckpt.attested_by)? { Some(id) => id, None => { return Ok(VerifyReport { entity_id: entity_id.to_string(), rows_valid: false, // The checkpoint was refused, so no rows were walked // and continuity is UNESTABLISHED rather than proved. // False, with no breaks listed: fail closed, because // absent evidence is not evidence of absence. continuous: false, // Nothing was walked, so no ceiling was reached either. limit_reached: false, breaks: Vec::new(), row_count: 0, errors: vec![VerifyError { sequence: ckpt.sequence_int, chain_id: ckpt.chain_id.clone(), kind: VerifyErrorType::SignatureInvalid { detail: format!( "checkpoint attested_by={} not in producer_identity", ckpt.attested_by ), }, }], }); } }; let signing_view = checkpoint_signing_bytes(&ckpt.chain_id, ckpt.sequence_int, &ckpt.row_hash); if Ed25519Verifier::verify( &identity.ed25519_pubkey, &signing_view, &ckpt.attestation_sig, ) .is_err() { return Ok(VerifyReport { entity_id: entity_id.to_string(), rows_valid: false, // Refused, not walked -- see the note on the sibling // early return above. Continuity is unestablished. continuous: false, // Nothing was walked, so no ceiling was reached either. limit_reached: false, breaks: Vec::new(), row_count: 0, errors: vec![VerifyError { sequence: ckpt.sequence_int, chain_id: ckpt.chain_id.clone(), kind: VerifyErrorType::SignatureInvalid { detail: format!( "checkpoint attestation_sig invalid for attested_by={}", ckpt.attested_by ), }, }], }); } if let Some(rev) = self.lookup_revocation(&ckpt.attested_by)? { if ckpt.checkpoint_at.as_str() >= rev.revoked_at.as_str() { return Ok(VerifyReport { entity_id: entity_id.to_string(), rows_valid: false, // The checkpoint was refused, so no rows were walked // and continuity is UNESTABLISHED rather than proved. // False, with no breaks listed: fail closed, because // absent evidence is not evidence of absence. continuous: false, // Nothing was walked, so no ceiling was reached either. limit_reached: false, breaks: Vec::new(), row_count: 0, errors: vec![VerifyError { sequence: ckpt.sequence_int, chain_id: ckpt.chain_id.clone(), kind: VerifyErrorType::SignatureInvalid { detail: format!( "checkpoint attested_by={} revoked at {} (checkpoint_at={})", ckpt.attested_by, rev.revoked_at, ckpt.checkpoint_at ), }, }], }); } } // The checkpoint's signature vouches for the head at ckpt.sequence_int, // so only the rows STRICTLY AFTER it need re-walking -- and that window // begins chaining from the checkpoint's row_hash, not from genesis. (Was // `>= ckpt.sequence_int` seeded at genesis, which made every checkpoint // past sequence 0 fail on a prev-hash mismatch. S126 fix.) let mut stmt = self.conn.prepare( "SELECT chain_id, event_type, sequence_int, prev_hash, row_hash, anchor_hash, raw_payload FROM provenance_envelope WHERE entity_id = ? AND sequence_int > ? ORDER BY id ASC", )?; let rows = stmt.query_map(params![entity_id, ckpt.sequence_int], |r| { Ok(ChainLink { entity_id: entity_id.to_string(), chain_id: r.get(0)?, event_type: r.get(1)?, sequence: r.get(2)?, prev_hash: r.get(3)?, row_hash: r.get(4)?, anchor_hash: r.get(5)?, raw_payload: r.get(6)?, }) })?; let mut links: Vec = Vec::new(); for link in rows { links.push(link?); } Ok(verify_links_from( entity_id, &links, &self.key, &ckpt.row_hash, )) } /// Row-level auth-gated verify. See the trusty-postgres twin. pub fn verify_with_producer( &self, entity_id: &str, expected_oid: &str, ) -> SqliteResult { let identity = self.lookup_producer(expected_oid)?; // Refuse before doing any signature work. A COSE credential // signs `authenticatorData || SHA-256(clientDataJSON)`, not the // message, so running it through the bare-signature path // produces a failure that looks like a wrong key -- and invites // someone to loosen the check until it passes. if let Some(id) = &identity { if let Err(e) = trusty_core::require_bare_signature(id.key_format, expected_oid) { return Err(SqliteError::UnsupportedCredential(e.to_string())); } } let revocation = self.lookup_revocation(expected_oid)?; let mut stmt = self.conn.prepare( "SELECT chain_id, event_type, sequence_int, prev_hash, row_hash, anchor_hash, raw_payload, detected_at FROM provenance_envelope WHERE entity_id = ? ORDER BY id ASC", )?; let rows = stmt.query_map(params![entity_id], |r| { Ok(( r.get::<_, String>(0)?, // chain_id r.get::<_, String>(1)?, // event_type r.get::<_, i64>(2)?, // sequence_int r.get::<_, String>(3)?, // prev_hash r.get::<_, Option>(4)?, // row_hash r.get::<_, Option>(5)?, // anchor_hash r.get::<_, Option>(6)?, // raw_payload r.get::<_, String>(7)?, // detected_at )) })?; let mut links: Vec = Vec::new(); let mut auth_errors: Vec = Vec::new(); for row in rows { let ( chain_id, event_type, sequence, prev_hash, row_hash, anchor_hash, raw_payload, detected_at, ) = row?; if let Some(raw) = raw_payload.as_deref() { if let Some(sig_pks) = parse_signature_pubkeys(raw) { for pk_hex in sig_pks { let pk_bytes = match hex::decode(&pk_hex) { Ok(b) => b, Err(e) => { auth_errors.push(VerifyError { sequence, chain_id: chain_id.clone(), kind: VerifyErrorType::SignatureInvalid { detail: format!("row signature pk hex malformed: {}", e), }, }); continue; } }; match &identity { None => auth_errors.push(VerifyError { sequence, chain_id: chain_id.clone(), kind: VerifyErrorType::SignatureInvalid { detail: format!( "expected_oid={} not in producer_identity", expected_oid ), }, }), Some(id) if pk_bytes != id.ed25519_pubkey => { auth_errors.push(VerifyError { sequence, chain_id: chain_id.clone(), kind: VerifyErrorType::SignatureInvalid { detail: format!( "row signed by unauthorized pubkey (expected_oid={}); pk_hex={}", expected_oid, pk_hex ), }, }); } _ => {} } if let Some(rev) = &revocation { if detected_at.as_str() >= rev.revoked_at.as_str() { auth_errors.push(VerifyError { sequence, chain_id: chain_id.clone(), kind: VerifyErrorType::SignatureInvalid { detail: format!( "row signed AT or AFTER revocation (expected_oid={} revoked_at={} detected_at={})", expected_oid, rev.revoked_at, detected_at ), }, }); } } } } } links.push(ChainLink { entity_id: entity_id.to_string(), chain_id, event_type, sequence, prev_hash, row_hash, anchor_hash, raw_payload, }); } let mut report = verify_links(entity_id, &links, &self.key); if !auth_errors.is_empty() { report.rows_valid = false; report.errors.extend(auth_errors); } Ok(report) } fn read_tip(&self, entity_id: &str) -> SqliteResult> { let row = self .conn .query_row( "SELECT chain_id, current_tip_hash FROM chain_tips WHERE entity_id = ?", params![entity_id], |r| { Ok(TipRow { chain_id: r.get::<_, Option>(0)?.unwrap_or_default(), current_tip_hash: r.get(1)?, }) }, ) .optional()?; Ok(row) } fn read_last(&self, entity_id: &str, chain_id: &str) -> SqliteResult> { let row = self .conn .query_row( "SELECT row_hash, sequence_int FROM provenance_envelope WHERE entity_id = ? AND chain_id = ? AND event_type != 'BREAK' AND row_hash IS NOT NULL ORDER BY id DESC LIMIT 1", params![entity_id, chain_id], |r| { Ok(LastRow { row_hash: r.get(0)?, sequence_int: r.get(1)?, }) }, ) .optional()?; Ok(row) } #[allow(clippy::too_many_arguments)] fn write_envelope( &self, chain_id: &str, entity_id: &str, event_type: &str, field_name: Option<&str>, old_value: Option<&str>, new_value: Option<&str>, source_url: &str, detected_at: &str, extraction_method: &str, prev_hash: &str, row_hash: Option<&str>, anchor_hash: Option<&str>, sequence_int: i64, raw_payload: Option<&str>, ) -> SqliteResult { self.conn.execute( "INSERT INTO provenance_envelope ( chain_id, entity_id, event_type, field_name, old_value, new_value, source_url, detected_at, extraction_method, prev_hash, row_hash, anchor_hash, sequence_int, raw_payload ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", params![ chain_id, entity_id, event_type, field_name, old_value, new_value, source_url, detected_at, extraction_method, prev_hash, row_hash, anchor_hash, sequence_int, raw_payload, ], )?; Ok(self.conn.last_insert_rowid()) } fn append_hash_index( &self, row_hash: &str, chain_id: &str, sequence_int: i64, entity_id: &str, ) -> SqliteResult<()> { let recorded_at = now_iso(); self.conn.execute( "INSERT OR IGNORE INTO hash_index (row_hash, chain_id, sequence_int, entity_id, recorded_at) VALUES (?, ?, ?, ?, ?)", params![row_hash, chain_id, sequence_int, entity_id, recorded_at], )?; Ok(()) } fn set_tip(&self, entity_id: &str, chain_id: &str, tip_hash: Option<&str>) -> SqliteResult<()> { let updated_at = now_iso(); self.conn.execute( "INSERT INTO chain_tips (entity_id, current_tip_hash, chain_id, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(entity_id) DO UPDATE SET current_tip_hash = excluded.current_tip_hash, chain_id = excluded.chain_id, updated_at = excluded.updated_at", params![entity_id, tip_hash, chain_id, updated_at], )?; Ok(()) } } fn now_iso() -> String { chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } /// Pull the `signatures` array out of a v2/v3 raw_payload and return /// the hex-encoded pubkey from each entry. Returns None for v1 rows /// or unparseable payloads. Mirrors the helper in trusty-postgres. fn parse_signature_pubkeys(raw_payload: &str) -> Option> { let v: serde_json::Value = serde_json::from_str(raw_payload).ok()?; let arr = v.get("signatures")?.as_array()?; if arr.is_empty() { return None; } let pks: Vec = arr .iter() .filter_map(|s| s.get("pk")?.as_str().map(|s| s.to_string())) .collect(); if pks.is_empty() { None } else { Some(pks) } } fn now_iso_micros() -> String { chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true) } fn parse_generation(chain_id: &str) -> SqliteResult { core_parse_generation(chain_id) .ok_or_else(|| SqliteError::MalformedChainId(chain_id.to_string())) } fn reason_code(reason: &trusty_core::BreakReason) -> String { // `code()` (not `as_code()`) so a PropertyViolated break records WHICH rule // broke (`PROPERTY_VIOLATED:`), not just the category. reason.code() } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn test_key() -> SecretKey { SecretKey::new(*b"0123456789abcdef") } fn open() -> Trusty { Trusty::open_in_memory(test_key()).expect("open in-memory db") } #[test] fn append_first_is_genesis() { let mut t = open(); let r = t .append("acme", &json!({"name": "first"}), "src://a") .expect("append ok"); assert!(r.is_genesis); assert_eq!(r.event_type, "GENESIS"); assert_eq!(r.chain_id, "chain_acme_000001"); assert_eq!(r.sequence, 0); assert!(!r.chain_break); } #[test] fn second_append_is_mutation() { let mut t = open(); let first = t.append("acme", &json!({"v": 1}), "s").expect("ok1"); let second = t.append("acme", &json!({"v": 2}), "s").expect("ok2"); assert!(!second.is_genesis); assert_eq!(second.event_type, "MUTATION"); assert_eq!(second.sequence, 1); assert_eq!(second.chain_id, "chain_acme_000001"); assert_ne!(first.row_hash, second.row_hash); } #[test] fn verify_fresh_chain_ok() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("ok"); t.append("acme", &json!({"v": 2}), "s").expect("ok"); t.append("acme", &json!({"v": 3}), "s").expect("ok"); let report = t.verify("acme").expect("verify"); assert!(report.rows_valid, "fresh chain must verify: {:?}", report); assert_eq!(report.row_count, 3); } #[test] fn verify_detects_tampered_row_hash() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("ok"); t.conn .execute( "UPDATE provenance_envelope SET row_hash = 'deadbeefdeadbeef' \ WHERE entity_id = 'acme' AND event_type = 'GENESIS'", [], ) .expect("tamper update"); let report = t.verify("acme").expect("verify"); assert!(!report.rows_valid, "tampered chain must not verify"); let has_mismatch = report .errors .iter() .any(|e| matches!(e.kind, trusty_core::VerifyErrorType::RowHashMismatch { .. })); assert!( has_mismatch, "expected RowHashMismatch in {:?}", report.errors ); } #[test] fn anchor_returns_tip() { let mut t = open(); let r = t.append("acme", &json!({"v": 1}), "s").expect("ok"); let a = t.anchor("acme").expect("anchor").expect("some"); assert_eq!(a.tip_hash.as_deref(), Some(r.row_hash.as_str())); assert_eq!(a.chain_id, r.chain_id); } #[test] fn history_excludes_break_rows() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("ok"); t.conn .execute( "INSERT INTO provenance_envelope ( chain_id, entity_id, event_type, field_name, old_value, new_value, source_url, detected_at, extraction_method, prev_hash, row_hash, anchor_hash, sequence_int, raw_payload ) VALUES ('chain_acme_000001','acme','BREAK','__chain_break__','x','y','','now','trusty','x',NULL,NULL,-1,NULL)", [], ) .expect("manual break"); let h = t.history("acme", 100).expect("history"); assert_eq!(h.len(), 1); assert_eq!(h[0].event_type, "GENESIS"); } #[test] fn integrity_ok_after_writes() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("ok"); t.append("acme", &json!({"v": 2}), "s").expect("ok"); let r = t.integrity().expect("integrity"); assert!(r.ok, "integrity must be ok: {:?}", r); assert_eq!(r.entities_checked, 1); } #[test] fn integrity_detects_kv_mismatch() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("ok"); t.conn .execute( "UPDATE chain_tips SET current_tip_hash = 'fakehashfakehash' WHERE entity_id = 'acme'", [], ) .expect("tamper"); let r = t.integrity().expect("integrity"); assert!(!r.ok); assert!(!r.mismatches.is_empty()); } #[test] fn break_seals_and_creates_new_genesis() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("ok"); t.conn .execute( "UPDATE chain_tips SET current_tip_hash = 'tamperedhashvalue' WHERE entity_id = 'acme'", [], ) .expect("tamper tip"); let r = t .append("acme", &json!({"v": 2}), "s") .expect("break append"); assert!(r.chain_break); assert_eq!(r.sealed_chain.as_deref(), Some("chain_acme_000001")); assert_eq!(r.chain_id, "chain_acme_000002"); assert_eq!(r.event_type, "BREAK"); } #[test] fn two_entities_independent_chains() { let mut t = open(); let a = t.append("ent_a", &json!({"v": 1}), "s").expect("ok"); let b = t.append("ent_b", &json!({"v": 1}), "s").expect("ok"); assert_eq!(a.chain_id, "chain_ent_a_000001"); assert_eq!(b.chain_id, "chain_ent_b_000001"); let a2 = t.append("ent_a", &json!({"v": 2}), "s").expect("ok"); assert_eq!(a2.sequence, 1); let b_hist = t.history("ent_b", 100).expect("hist"); assert_eq!(b_hist.len(), 1); } fn test_signer() -> trusty_core::Ed25519Signer { trusty_core::Ed25519Signer::from_secret_bytes(&[13u8; 32]).unwrap() } #[test] fn append_signed_writes_v2_payload() { let mut t = open(); let s = test_signer(); let r = t .append_signed("acme", &json!({"v": 1}), "s", &s) .expect("append_signed ok"); assert_eq!(r.event_type, "GENESIS"); let hist = t.history("acme", 10).expect("hist"); let raw = hist[0].raw_payload.as_ref().expect("raw"); assert!(raw.contains("\"payload_version\":2"), "got {}", raw); assert!(raw.contains("\"alg\":\"ed25519\""), "got {}", raw); } #[test] fn verify_passes_on_mixed_v1_and_v2_chain() { // Genesis unsigned (v1), mutation signed (v2), mutation unsigned // (v1 again). verify_links must accept the mix -- signature check // only activates per-row for v2 rows. let mut t = open(); let s = test_signer(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.append_signed("acme", &json!({"v": 2}), "s", &s) .expect("m-signed"); t.append("acme", &json!({"v": 3}), "s").expect("m"); let report = t.verify("acme").expect("verify"); assert!(report.rows_valid, "mixed chain must verify: {:?}", report); assert_eq!(report.row_count, 3); } #[test] fn append_batch_empty_is_ok() { let mut t = open(); let r = t.append_batch(&[]).expect("empty ok"); assert!(r.is_empty()); } #[test] fn append_batch_matches_loop_structure() { // Append the same sequence via loop and via batch into two // separate stores. Row hashes WILL differ (timestamps are // part of the keyed payload, and the two runs happen at // different moments), but structural properties -- event // type, sequence, chain_id, break/genesis flags -- must be // identical. let mut loop_store = open(); let mut batch_store = open(); let rows = [json!({"v": 1}), json!({"v": 2}), json!({"v": 3})]; let mut loop_results = Vec::new(); for r in &rows { loop_results.push(loop_store.append("acme", r, "s").expect("loop append ok")); } let specs: Vec = rows .iter() .map(|r| RowSpec { entity_id: "acme", data: r, source_url: "s", }) .collect(); let batch_results = batch_store.append_batch(&specs).expect("batch ok"); assert_eq!(loop_results.len(), batch_results.len()); for (a, b) in loop_results.iter().zip(batch_results.iter()) { assert_eq!(a.event_type, b.event_type); assert_eq!(a.sequence, b.sequence); assert_eq!(a.chain_id, b.chain_id); assert_eq!(a.is_genesis, b.is_genesis); assert_eq!(a.chain_break, b.chain_break); } } #[test] fn append_batch_verifies_as_chain() { let mut t = open(); let rows = [json!({"v": 1}), json!({"v": 2}), json!({"v": 3})]; let specs: Vec = rows .iter() .map(|r| RowSpec { entity_id: "acme", data: r, source_url: "s", }) .collect(); t.append_batch(&specs).expect("batch ok"); let report = t.verify("acme").expect("verify"); assert!( report.rows_valid, "batch-appended chain must verify: {:?}", report ); assert_eq!(report.row_count, 3); } #[test] fn append_batch_multi_entity() { let mut t = open(); let a_data = json!({"src": "a"}); let b_data = json!({"src": "b"}); let specs = [ RowSpec { entity_id: "ent_a", data: &a_data, source_url: "s", }, RowSpec { entity_id: "ent_b", data: &b_data, source_url: "s", }, RowSpec { entity_id: "ent_a", data: &a_data, source_url: "s", }, ]; let results = t.append_batch(&specs).expect("batch ok"); assert_eq!(results[0].entity_id, "ent_a"); assert_eq!(results[0].sequence, 0); assert_eq!(results[1].entity_id, "ent_b"); assert_eq!(results[1].sequence, 0); assert_eq!(results[2].entity_id, "ent_a"); assert_eq!(results[2].sequence, 1); } #[test] fn breaks_since_none_when_clean() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.append("acme", &json!({"v": 2}), "s").expect("m"); let events = t.breaks_since("acme", None).expect("breaks"); assert!(events.is_empty()); } #[test] fn breaks_since_surfaces_tamper_break() { let mut t = open(); let r1 = t.append("acme", &json!({"v": 1}), "s").expect("g"); t.conn .execute( "UPDATE chain_tips SET current_tip_hash = 'tamperedhashvalue' \ WHERE entity_id = 'acme'", [], ) .expect("tamper tip"); t.append("acme", &json!({"v": 2}), "s") .expect("break append"); let events = t.breaks_since("acme", None).expect("breaks"); assert_eq!(events.len(), 1); let e = &events[0]; assert_eq!(e.entity_id, "acme"); assert_eq!(e.chain_id, "chain_acme_000001"); assert_eq!(e.sealed_at_hash, r1.row_hash); assert!(!e.break_reason.is_empty(), "reason code must be populated"); } #[test] fn export_bundle_roundtrip_verifies() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.append("acme", &json!({"v": 2}), "s").expect("m1"); t.append("acme", &json!({"v": 3}), "s").expect("m2"); let bundle = t.export_bundle("acme", None, None).expect("bundle"); assert_eq!(bundle.entity_id, "acme"); assert_eq!(bundle.row_count, 3); assert_eq!(bundle.rows.len(), 3); assert_eq!(bundle.anchors.len(), 3); assert_eq!(bundle.sequence_range, (Some(0), Some(2))); let report = bundle.verify(&test_key()); assert!(report.rows_valid, "bundle verify must pass: {:?}", report); assert_eq!(report.row_count, 3); } #[test] fn export_bundle_json_roundtrips_and_verifies() { // Serialize to JSON on the producer side, deserialize on the // verifier side, then verify. Exercises the wire path. let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.append("acme", &json!({"v": 2}), "s").expect("m"); let bundle = t.export_bundle("acme", None, None).expect("bundle"); let wire = serde_json::to_string(&bundle).expect("serialize"); let decoded: trusty_core::ProofBundle = serde_json::from_str(&wire).expect("deserialize"); assert_eq!(decoded.row_count, 2); let report = decoded.verify(&test_key()); assert!( report.rows_valid, "decoded bundle must verify: {:?}", report ); } #[test] fn export_bundle_tampered_row_hash_is_rejected() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.append("acme", &json!({"v": 2}), "s").expect("m"); let mut bundle = t.export_bundle("acme", None, None).expect("bundle"); // Flip one char of the genesis row_hash inside the bundle. // Since the verifier recomputes SipHash from raw_payload, the // mutated row_hash must fail RowHashMismatch. let original = bundle.rows[0] .row_hash .as_ref() .expect("row_hash present") .clone(); let (head, tail) = original.split_at(original.len() - 1); let flipped = if tail == "0" { "1" } else { "0" }; bundle.rows[0].row_hash = Some(format!("{}{}", head, flipped)); let report = bundle.verify(&test_key()); assert!(!report.rows_valid, "tampered bundle must not verify"); assert!( report .errors .iter() .any(|e| matches!(e.kind, trusty_core::VerifyErrorType::RowHashMismatch { .. })), "expected RowHashMismatch, got {:?}", report.errors ); } #[test] fn anchors_since_none_returns_every_anchor() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.append("acme", &json!({"v": 2}), "s").expect("m1"); t.append("acme", &json!({"v": 3}), "s").expect("m2"); let anchors = t.anchors_since("acme", None).expect("anchors"); assert_eq!(anchors.len(), 3); assert_eq!(anchors[0].sequence, 0); assert_eq!(anchors[1].sequence, 1); assert_eq!(anchors[2].sequence, 2); // anchor_hash is a hex string, non-empty, distinct per row. assert!(!anchors[0].anchor_hash.is_empty()); assert_ne!(anchors[0].anchor_hash, anchors[1].anchor_hash); } #[test] fn anchors_since_skips_break_rows() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.conn .execute( "UPDATE chain_tips SET current_tip_hash = 'tamperedhashvalue' \ WHERE entity_id = 'acme'", [], ) .expect("tamper tip"); let _break = t .append("acme", &json!({"v": 2}), "s") .expect("break append"); let anchors = t.anchors_since("acme", None).expect("anchors"); // Genesis of chain 1 + new genesis of chain 2; the BREAK row // between them carries no anchor and must be excluded. assert_eq!(anchors.len(), 2, "got: {:?}", anchors); } #[test] fn export_bundle_includes_break_rows_for_chain_break_proof() { let mut t = open(); t.append("acme", &json!({"v": 1}), "s").expect("g"); t.conn .execute( "UPDATE chain_tips SET current_tip_hash = 'tamperedhashvalue' \ WHERE entity_id = 'acme'", [], ) .expect("tamper tip"); t.append("acme", &json!({"v": 2}), "s").expect("break"); let bundle = t.export_bundle("acme", None, None).expect("bundle"); let has_break = bundle.rows.iter().any(|r| r.event_type == "BREAK"); assert!( has_break, "bundle must carry BREAK rows for chain-break-as-event proof" ); let report = bundle.verify(&test_key()); assert!( report.rows_valid, "bundle with break must still verify: {:?}", report ); } #[test] fn verify_detects_tampered_v2_signature() { let mut t = open(); let s = test_signer(); t.append_signed("acme", &json!({"v": 1}), "s", &s) .expect("g"); // Flip the last hex char of the stored signature, then recompute // row_hash+anchor_hash over the mutated bytes so the row_hash // check passes and the signature check is what fails. let hist = t.history("acme", 10).expect("hist"); let raw = hist[0].raw_payload.clone().expect("raw"); let mut fields = trusty_core::chain::parse_raw_payload(&raw).unwrap(); let sig_obj = fields.signatures[0].as_object_mut().unwrap(); let sig_hex = sig_obj["sig"].as_str().unwrap().to_string(); let (head, tail) = sig_hex.split_at(sig_hex.len() - 1); let flipped_last = if tail == "0" { "1" } else { "0" }; sig_obj.insert("sig".into(), json!(format!("{}{}", head, flipped_last))); let mutated = trusty_core::build_payload(&fields).unwrap(); let new_rh = trusty_core::row_hash(&test_key(), mutated.as_bytes()); let new_ah = trusty_core::anchor_hash(&new_rh, "acme", "chain_acme_000001"); t.conn .execute( "UPDATE provenance_envelope SET raw_payload = ?, row_hash = ?, anchor_hash = ? \ WHERE entity_id = 'acme' AND event_type = 'GENESIS'", params![mutated, new_rh, new_ah], ) .expect("tamper"); let report = t.verify("acme").expect("verify"); assert!(!report.rows_valid); assert!(report.errors.iter().any(|e| matches!( e.kind, trusty_core::VerifyErrorType::SignatureInvalid { .. } ))); } #[test] fn liveness_check_clean_is_empty_for_zero_gap() { let mut t = open(); t.append("sensor-a", &json!({"v": 1}), "s").expect("append"); // Just appended -- gap is ~0 seconds, threshold is 9999. Must be silent. let silent = t.liveness_check(None, 9999).expect("liveness"); assert!( silent.is_empty(), "freshly-appended entity must not be silent" ); } #[test] fn liveness_check_surfaces_stale_entity() { let mut t = open(); t.append("sensor-a", &json!({"v": 1}), "s").expect("append"); // Force detected_at to a timestamp 100 seconds in the past via direct SQL. t.conn .execute( "UPDATE provenance_envelope SET detected_at = datetime('now', '-100 seconds') \ WHERE entity_id = 'sensor-a'", [], ) .expect("backdate"); let silent = t.liveness_check(None, 10).expect("liveness"); assert_eq!(silent.len(), 1); assert_eq!(silent[0].entity_id, "sensor-a"); assert!(silent[0].gap_secs > 10.0); } #[test] fn liveness_check_filtered_by_entity_id() { let mut t = open(); t.append("sensor-a", &json!({"v": 1}), "s").expect("a"); t.append("sensor-b", &json!({"v": 1}), "s").expect("b"); t.conn .execute( "UPDATE provenance_envelope SET detected_at = datetime('now', '-100 seconds') \ WHERE entity_id = 'sensor-a'", [], ) .expect("backdate a"); // Only check sensor-b -- must be empty even though sensor-a is stale. let silent = t.liveness_check(Some("sensor-b"), 10).expect("liveness"); assert!(silent.is_empty()); // Check sensor-a directly -- must surface. let silent = t.liveness_check(Some("sensor-a"), 10).expect("liveness"); assert_eq!(silent.len(), 1); } #[test] fn liveness_check_ignores_break_rows() { let mut t = open(); t.append("sensor-a", &json!({"v": 1}), "s").expect("append"); // Tamper chain to produce a BREAK row, then backdate all rows. t.conn .execute( "UPDATE chain_tips SET current_tip_hash = 'fakehash' WHERE entity_id = 'sensor-a'", [], ) .expect("tamper"); t.append("sensor-a", &json!({"v": 2}), "s") .expect("break append"); // Backdate only the BREAK row -- the real row stays fresh. t.conn .execute( "UPDATE provenance_envelope SET detected_at = datetime('now', '-100 seconds') \ WHERE event_type = 'BREAK'", [], ) .expect("backdate break"); // BREAK rows must not count toward liveness -- entity has a fresh non-BREAK row. let silent = t.liveness_check(None, 10).expect("liveness"); assert!( silent.is_empty(), "BREAK rows must not affect liveness check" ); } // ================================================================ // Federation primitives (S17 cycle 4) -- producer_identity, // producer_revocation, chain_checkpoint, verify_since_checkpoint, // verify_with_producer. Mirrors trusty-postgres integration tests // but in-process so no env-var gating needed. // ================================================================ fn fed_signer() -> trusty_core::Ed25519Signer { trusty_core::Ed25519Signer::from_secret_bytes(&[31u8; 32]).unwrap() } #[test] fn fed_register_lookup_producer_round_trip() { let mut t = open(); let s = fed_signer(); t.register_producer("oid:1.2.3", &s.public_key_bytes(), b"sip-1", Some("a")) .unwrap(); let got = t.lookup_producer("oid:1.2.3").unwrap().unwrap(); assert_eq!(got.producer_oid, "oid:1.2.3"); assert_eq!(got.ed25519_pubkey, s.public_key_bytes()); assert_eq!(got.siphash_key, b"sip-1"); assert_eq!(got.display_name.as_deref(), Some("a")); // UPSERT. let s2 = trusty_core::Ed25519Signer::from_secret_bytes(&[5u8; 32]).unwrap(); t.register_producer("oid:1.2.3", &s2.public_key_bytes(), b"sip-2", None) .unwrap(); let updated = t.lookup_producer("oid:1.2.3").unwrap().unwrap(); assert_eq!(updated.ed25519_pubkey, s2.public_key_bytes()); assert_eq!(updated.siphash_key, b"sip-2"); assert!(updated.display_name.is_none()); assert!(t.lookup_producer("oid:nope").unwrap().is_none()); } #[test] fn fed_revoke_producer_round_trip_and_lookup() { let mut t = open(); let s = fed_signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip", None) .unwrap(); assert!(t.lookup_revocation("oid:test").unwrap().is_none()); let rec = t.revoke_producer("oid:test", "key compromise", &s).unwrap(); assert_eq!(rec.producer_oid, "oid:test"); assert_eq!(rec.reason, "key compromise"); assert!(!rec.revocation_row_hash.is_empty()); let got = t.lookup_revocation("oid:test").unwrap().unwrap(); assert_eq!(got.revocation_row_hash, rec.revocation_row_hash); // The REVOCATION envelope verifies on the 'revocations' chain. let report = t.verify("revocations").unwrap(); assert!(report.rows_valid, "{:?}", report.errors); assert_eq!(report.row_count, 1); // Re-revoke -> two envelopes, one index row. t.revoke_producer("oid:test", "second", &s).unwrap(); let report2 = t.verify("revocations").unwrap(); assert_eq!(report2.row_count, 2); } #[test] fn fed_revoke_producer_unknown_oid_errors() { let mut t = open(); let s = fed_signer(); let err = t.revoke_producer("oid:ghost", "missing", &s); assert!(matches!(err, Err(SqliteError::NotFound(_)))); } #[test] fn fed_checkpoint_round_trip() { let mut t = open(); let s = fed_signer(); for i in 0..3 { t.append("dev1", &json!({"i": i}), "src").unwrap(); } let ckpt = t.checkpoint("dev1", "oid:test", &s).unwrap(); assert_eq!(ckpt.attested_by, "oid:test"); assert_eq!(ckpt.sequence_int, 2); assert!(!ckpt.attestation_sig.is_empty()); let latest = t.latest_checkpoint(&ckpt.chain_id).unwrap().unwrap(); assert_eq!(latest.id, ckpt.id); assert_eq!(latest.row_hash, ckpt.row_hash); } #[test] fn fed_verify_since_checkpoint_falls_back_when_no_checkpoint() { let mut t = open(); for i in 0..2 { t.append("dev2", &json!({"i": i}), "src").unwrap(); } let full = t.verify("dev2").unwrap(); let pruned = t.verify_since_checkpoint("dev2").unwrap(); assert_eq!(full.rows_valid, pruned.rows_valid); assert_eq!(full.row_count, pruned.row_count); } #[test] fn fed_verify_since_checkpoint_authenticates_and_rejects_unknown_attestor() { let mut t = open(); let s = fed_signer(); for i in 0..2 { t.append("dev3", &json!({"i": i}), "src").unwrap(); } // checkpoint by unregistered attestor -> verify rejects. t.checkpoint("dev3", "oid:ghost", &s).unwrap(); let report = t.verify_since_checkpoint("dev3").unwrap(); assert!(!report.rows_valid); let detail = match &report.errors[0].kind { VerifyErrorType::SignatureInvalid { detail } => detail.clone(), other => panic!("expected SignatureInvalid, got {:?}", other), }; assert!( detail.contains("not in producer_identity"), "detail={}", detail ); } #[test] fn fed_verify_since_checkpoint_rejects_post_revocation() { let mut t = open(); let s = fed_signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip", None) .unwrap(); for i in 0..3 { t.append("dev4", &json!({"i": i}), "src").unwrap(); } // revoke first, then checkpoint -> rejected t.revoke_producer("oid:test", "compromise", &s).unwrap(); std::thread::sleep(std::time::Duration::from_millis(1100)); // RFC3339 secs precision t.checkpoint("dev4", "oid:test", &s).unwrap(); let report = t.verify_since_checkpoint("dev4").unwrap(); assert!(!report.rows_valid); let detail = match &report.errors[0].kind { VerifyErrorType::SignatureInvalid { detail } => detail.clone(), other => panic!("expected SignatureInvalid, got {:?}", other), }; assert!(detail.contains("revoked at"), "detail={}", detail); } #[test] fn fed_verify_with_producer_accepts_authorized_rows() { let mut t = open(); let s = fed_signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip", None) .unwrap(); for i in 0..3 { t.append_signed("dev5", &json!({"i": i}), "src", &s) .unwrap(); } let report = t.verify_with_producer("dev5", "oid:test").unwrap(); assert!(report.rows_valid, "{:?}", report.errors); assert_eq!(report.row_count, 3); } #[test] fn fed_verify_with_producer_rejects_unauthorized_pubkey() { let mut t = open(); let registered = fed_signer(); let imposter = trusty_core::Ed25519Signer::from_secret_bytes(&[77u8; 32]).unwrap(); t.register_producer("oid:test", ®istered.public_key_bytes(), b"sip", None) .unwrap(); t.append_signed("dev6", &json!({"i": 0}), "src", ®istered) .unwrap(); t.append_signed("dev6", &json!({"i": 1}), "src", &imposter) .unwrap(); let report = t.verify_with_producer("dev6", "oid:test").unwrap(); assert!(!report.rows_valid); let bad = report.errors.iter().find(|e| match &e.kind { VerifyErrorType::SignatureInvalid { detail } => detail.contains("unauthorized pubkey"), _ => false, }); assert!( bad.is_some(), "expected unauthorized pubkey error, got {:?}", report.errors ); } #[test] fn fed_verify_with_producer_pre_revoke_rows_remain_valid() { let mut t = open(); let s = fed_signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip", None) .unwrap(); for i in 0..2 { t.append_signed("dev7", &json!({"i": i}), "src", &s) .unwrap(); } std::thread::sleep(std::time::Duration::from_millis(1100)); t.revoke_producer("oid:test", "later", &s).unwrap(); let report = t.verify_with_producer("dev7", "oid:test").unwrap(); assert!( report.rows_valid, "expected pre-revocation rows to remain valid; errors: {:?}", report.errors ); assert_eq!(report.row_count, 2); } } #[cfg(test)] mod signed_only_tests { use super::*; use trusty_core::Ed25519Signer; fn store() -> Trusty { Trusty::open_in_memory(SecretKey::new([7u8; 16])).unwrap() } #[test] fn off_by_default_so_existing_callers_are_unaffected() { let mut t = store(); assert!(!t.is_signed_only()); t.append("acme", &serde_json::json!({"v": 1}), "s").unwrap(); } #[test] fn signed_only_refuses_an_unsigned_append() { let mut t = store(); t.set_signed_only(true); let err = t .append("acme", &serde_json::json!({"v": 1}), "s") .unwrap_err(); assert!( matches!(err, SqliteError::UnsignedAppendRefused(ref e) if e == "acme"), "got {err:?}" ); } #[test] fn signed_only_still_accepts_a_signed_append() { let mut t = store(); t.set_signed_only(true); let signer = Ed25519Signer::generate(); t.append_signed("acme", &serde_json::json!({"v": 1}), "s", &signer) .unwrap(); } #[test] fn genesis_is_covered_too() { // The first row of a chain takes a different internal path; the check // sits at the shared entry point precisely so genesis cannot slip past. let mut t = store(); t.set_signed_only(true); assert!(t .append("brand-new-entity", &serde_json::json!({}), "s") .is_err()); } #[test] fn append_with_method_is_covered_too() { let mut t = store(); t.set_signed_only(true); assert!( t.append_with_method("acme", &serde_json::json!({"v": 1}), "s", "manual") .is_err(), "every unsigned entry point must be refused, not just append()" ); } #[test] fn existing_unsigned_rows_are_left_alone() { // Turning the flag on must not rewrite history -- that is the one thing // this crate exists to prevent. The chain keeps its unsigned rows and // fully_signed reports them. let mut t = store(); t.append("acme", &serde_json::json!({"v": 1}), "s").unwrap(); t.set_signed_only(true); let signer = Ed25519Signer::generate(); t.append_signed("acme", &serde_json::json!({"v": 2}), "s", &signer) .unwrap(); let bundle = t.export_bundle("acme", None, None).unwrap(); let report = bundle.verify_public(); assert_eq!(report.row_count, 2); assert!( !report.fully_signed, "the pre-existing unsigned row remains" ); assert!(report.rows_valid, "{:?}", report.errors); } }