Files
Trusty/crates/adapters/trusty-sqlite/src/store.rs

2364 lines
85 KiB
Rust

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<Box<dyn Condition>>,
/// 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<String>,
}
impl Trusty {
pub fn open(path: impl AsRef<Path>, key: SecretKey) -> SqliteResult<Self> {
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<Path>,
key: SecretKey,
conditions: Vec<Box<dyn Condition>>,
) -> SqliteResult<Self> {
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<Self> {
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<Box<dyn Condition>>,
) -> SqliteResult<Self> {
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<AppendResult> {
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<AppendResult> {
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<AppendResult> {
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<AppendResult> {
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<AppendResult> {
// 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<String>,
break_reason: Option<String>,
signer: Option<&dyn Signer>,
) -> SqliteResult<AppendResult> {
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<AppendResult> {
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<AppendResult> {
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<VerifyReport> {
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<String> = r.get(4)?;
let anchor_hash: Option<String> = r.get(5)?;
let raw_payload: Option<String> = 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<ChainLink> = 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<Option<AnchorInfo>> {
let row: Option<(String, Option<String>)> = 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<Vec<AppendResult>> {
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<Vec<BreakEvent>> {
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<String>>(1)?.unwrap_or_default(),
break_reason: r.get::<_, Option<String>>(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<Vec<SilentEntity>> {
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<Vec<AnchorEntry>> {
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<i64>,
until: Option<i64>,
) -> SqliteResult<ProofBundle> {
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<String> = r.get(4)?;
let anchor_hash: Option<String> = r.get(5)?;
let raw_payload: Option<String> = r.get(6)?;
let detected_at: Option<String> = 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<String> = 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<BundleRow> = Vec::new();
let mut anchors: Vec<AnchorEntry> = 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<Vec<HistoryRow>> {
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<IntegrityReport> {
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<String>>(1)?,
r.get::<_, Option<String>>(2)?,
))
})?;
let mut mismatches: Vec<IntegrityMismatch> = 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<String> = 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<Option<ProducerIdentity>> {
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<String> = r.get(5)?;
let metadata = metadata_str
.and_then(|s| serde_json::from_str::<serde_json::Value>(&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<RevocationRecord> {
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<Option<RevocationRecord>> {
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<CheckpointRow> {
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<Option<CheckpointRow>> {
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<VerifyReport> {
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<ChainLink> = 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<VerifyReport> {
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<String>>(4)?, // row_hash
r.get::<_, Option<String>>(5)?, // anchor_hash
r.get::<_, Option<String>>(6)?, // raw_payload
r.get::<_, String>(7)?, // detected_at
))
})?;
let mut links: Vec<ChainLink> = Vec::new();
let mut auth_errors: Vec<VerifyError> = 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<Option<TipRow>> {
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<String>>(0)?.unwrap_or_default(),
current_tip_hash: r.get(1)?,
})
},
)
.optional()?;
Ok(row)
}
fn read_last(&self, entity_id: &str, chain_id: &str) -> SqliteResult<Option<LastRow>> {
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<i64> {
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<Vec<String>> {
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<String> = 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<u32> {
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:<rule>`), 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<RowSpec> = 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<RowSpec> = 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", &registered.public_key_bytes(), b"sip", None)
.unwrap();
t.append_signed("dev6", &json!({"i": 0}), "src", &registered)
.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);
}
}