2364 lines
85 KiB
Rust
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", ®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);
|
|
}
|
|
}
|