Trusty: provenance library workspace (initial import)
This commit is contained in:
39
crates/core/trusty-core/Cargo.toml
Normal file
39
crates/core/trusty-core/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "trusty-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Tamper-evident data provenance chain primitives. Dual hash (SipHash-2-4 + SHA-256), chain-break-as-event. Framework neutral, no storage dependency."
|
||||
|
||||
[features]
|
||||
# On by default, so every existing consumer is unaffected and `rand` resolves
|
||||
# exactly as before. Verified across MITCH-stack, AI-Playbooks, kirbo and
|
||||
# good-ol-googz: nothing sets `default-features = false` on trusty-core, so
|
||||
# this gate is invisible to all of them.
|
||||
default = ["keygen"]
|
||||
|
||||
# Generating a fresh Ed25519 key pair. This is the ONLY item in trusty-core
|
||||
# that needs an operating-system RNG, and therefore the only thing standing
|
||||
# between this crate and a `wasm32-unknown-unknown` build -- the rest of the
|
||||
# crate uses no filesystem, clock, thread, socket, or process API at all.
|
||||
#
|
||||
# Verification needs no RNG, which is exactly what a browser peer wants. Build
|
||||
# with `default-features = false` to get hashing, canonicalisation, chain
|
||||
# construction, signature verification, `from_secret_bytes`, and ProofBundle,
|
||||
# leaving key generation to the host (Web Crypto, a hardware token, or a key
|
||||
# supplied from elsewhere).
|
||||
keygen = ["dep:rand"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
siphasher = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
rand = { workspace = true, optional = true }
|
||||
696
crates/core/trusty-core/src/annotation.rs
Normal file
696
crates/core/trusty-core/src/annotation.rs
Normal file
@@ -0,0 +1,696 @@
|
||||
//! Signed claims a node makes about a piece of data.
|
||||
//!
|
||||
//! # What this is for
|
||||
//!
|
||||
//! Encryption in Distant Star is per hop, not end to end -- a relaying
|
||||
//! peer decrypts a chunk and re-encrypts it onward, so it holds
|
||||
//! plaintext. Data crosses machines nobody controls, and without a
|
||||
//! record there is no answer to "which systems touched this, and what
|
||||
//! did they find?". That is the first question after a spill, and the
|
||||
//! one DFARS 252.204-7012 and NIST SP 800-171 require an answer to for
|
||||
//! CUI.
|
||||
//!
|
||||
//! # Kinds
|
||||
//!
|
||||
//! A node may attest **several different properties** about the same
|
||||
//! datum, each checkable on its own:
|
||||
//!
|
||||
//! - [`AnnotationKind::Custody`] -- I held a copy, and had it from there.
|
||||
//! - [`AnnotationKind::Integrity`] -- I ran a check; here is the result.
|
||||
//! - [`AnnotationKind::Derivation`] -- I produced this from that.
|
||||
//! - [`AnnotationKind::Source`] -- I attest where this originated.
|
||||
//!
|
||||
//! The shape follows Project Alvarium's annotation model (`kind`,
|
||||
//! `is_satisfied`, `layer`). Alvarium's confidence **score** is
|
||||
//! deliberately not followed: collapsing heterogeneous evidence into one
|
||||
//! number says how much to worry but not *what failed*, and weighting a
|
||||
//! TPM attestation against a checksum is a judgement dressed as
|
||||
//! arithmetic. A consumer wanting a routing signal can score these; the
|
||||
//! core reports per-property results.
|
||||
//!
|
||||
//! What is kept that Alvarium does not have: a **backward link**.
|
||||
//! Alvarium annotations are independent facts whose ordering comes from
|
||||
//! a ledger underneath. These carry [`SourceRef`], and trusty's
|
||||
//! signature covers `prev_hash`, `sequence`, `entity_id` and `chain_id`
|
||||
//! -- which is why a **gap** is detectable here, and why an annotation
|
||||
//! cannot be replayed at a different position.
|
||||
//!
|
||||
//! # Annotation, not ledger
|
||||
//!
|
||||
//! Each node annotates its own copy on a chain of its own,
|
||||
//! `entity_id = "<tag>:<node>"`. Nobody writes to anybody else's, so a
|
||||
//! node cannot forge an annotation it did not write. Recovering the
|
||||
//! picture is a merge across bundles.
|
||||
//!
|
||||
//! A custody *chain* in the evidentiary sense presumes exclusivity --
|
||||
//! the bag is in exactly one place. Copies destroy that: when a node
|
||||
//! passes data on, the sender still has it. So fan-out is the ordinary
|
||||
//! case and the shape is a tree rooted at the origin. An annotation is a
|
||||
//! claim by one party about itself, which fans out without
|
||||
//! contradiction. (Unrelated to CUSTODY the containment framework, which
|
||||
//! is not used here.)
|
||||
//!
|
||||
//! # Evidence, not enforcement
|
||||
//!
|
||||
//! Nothing here stops a node copying data, and a node that annotates
|
||||
//! nothing is invisible -- no append-only log can record what was never
|
||||
//! written. What an outside party holding only bundles does get:
|
||||
//! annotations cannot be **forged**, gaps are **visible**, and history
|
||||
//! cannot be **rewritten**.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::bundle::{BundleRow, ProofBundle, SourceRef};
|
||||
|
||||
/// Discriminator written into `data.kind_of`.
|
||||
///
|
||||
/// Versioned in the string: a reader must be able to tell which shape it
|
||||
/// is holding before it trusts the fields.
|
||||
pub const ANNOTATION_KIND: &str = "annotation.v1";
|
||||
|
||||
/// What property an annotation is about.
|
||||
///
|
||||
/// `Other` exists rather than a parse failure so an annotation of a kind
|
||||
/// this build does not know is **visible** instead of silently dropped.
|
||||
/// A future kind carrying a failed check must not vanish because an old
|
||||
/// verifier did not recognise the word.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AnnotationKind {
|
||||
/// This node held a copy, and had it from `received_from`.
|
||||
Custody,
|
||||
/// This node ran an integrity check; `satisfied` carries the result.
|
||||
Integrity,
|
||||
/// This node produced the datum from the cited source.
|
||||
Derivation,
|
||||
/// This node attests where the datum originated.
|
||||
Source,
|
||||
/// A kind this build does not know. Reported, never assumed benign.
|
||||
#[serde(untagged)]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl AnnotationKind {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Custody => "custody",
|
||||
Self::Integrity => "integrity",
|
||||
Self::Derivation => "derivation",
|
||||
Self::Source => "source",
|
||||
Self::Other(s) => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this build understands the kind well enough to act on it.
|
||||
pub fn is_known(&self) -> bool {
|
||||
!matches!(self, Self::Other(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where in the stack the claim was inserted.
|
||||
///
|
||||
/// Tells a verifier what an annotation is worth: a check run in the
|
||||
/// application is a weaker statement than the same check run in
|
||||
/// hardware, and the two should not read alike.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Layer {
|
||||
Device,
|
||||
Host,
|
||||
Os,
|
||||
App,
|
||||
Network,
|
||||
#[serde(untagged)]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Device => "device",
|
||||
Self::Host => "host",
|
||||
Self::Os => "os",
|
||||
Self::App => "app",
|
||||
Self::Network => "network",
|
||||
Self::Other(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Layer {
|
||||
fn default() -> Self {
|
||||
Self::App
|
||||
}
|
||||
}
|
||||
|
||||
/// One node's signed claim about one datum.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
/// What property this claims. See [`AnnotationKind`].
|
||||
pub kind: AnnotationKind,
|
||||
/// What the claim is about. Opaque; a content hash is the obvious
|
||||
/// choice.
|
||||
pub content_tag: String,
|
||||
/// Who is claiming. The owner of the chain this row is on.
|
||||
pub node: String,
|
||||
/// Where in the stack the claim was inserted.
|
||||
#[serde(default)]
|
||||
pub layer: Layer,
|
||||
/// Whether a check ran, and what it concluded.
|
||||
///
|
||||
/// `None` means **no check was performed**, which is not the same as
|
||||
/// a check that failed. A custody claim asserts holding, not
|
||||
/// correctness, and must not read as a passed check.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub satisfied: Option<bool>,
|
||||
/// The upstream row this claim refers to. `None` at an origin.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub received_from: Option<SourceRef>,
|
||||
/// Free text, in the `_why` tradition.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl Annotation {
|
||||
/// A custody claim: this node held a copy.
|
||||
pub fn custody(content_tag: impl Into<String>, node: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: AnnotationKind::Custody,
|
||||
content_tag: content_tag.into(),
|
||||
node: node.into(),
|
||||
layer: Layer::default(),
|
||||
satisfied: None,
|
||||
received_from: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An integrity claim: this node ran a check and it came out `ok`.
|
||||
pub fn integrity(
|
||||
content_tag: impl Into<String>,
|
||||
node: impl Into<String>,
|
||||
ok: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind: AnnotationKind::Integrity,
|
||||
satisfied: Some(ok),
|
||||
..Self::custody(content_tag, node)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from(mut self, source: SourceRef) -> Self {
|
||||
self.received_from = Some(source);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn at_layer(mut self, layer: Layer) -> Self {
|
||||
self.layer = layer;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn because(mut self, note: impl Into<String>) -> Self {
|
||||
self.note = Some(note.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The value to write into the row's `data` field.
|
||||
pub fn to_data(&self) -> Value {
|
||||
let mut v = json!({
|
||||
"kind_of": ANNOTATION_KIND,
|
||||
"kind": self.kind.as_str(),
|
||||
"content_tag": self.content_tag,
|
||||
"node": self.node,
|
||||
"layer": self.layer.as_str(),
|
||||
});
|
||||
if let Some(satisfied) = self.satisfied {
|
||||
v["satisfied"] = Value::Bool(satisfied);
|
||||
}
|
||||
if let Some(from) = &self.received_from {
|
||||
v["received_from"] = serde_json::to_value(from).unwrap_or(Value::Null);
|
||||
}
|
||||
if let Some(note) = &self.note {
|
||||
v["note"] = Value::String(note.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Recover an annotation from a bundle row, if it is one.
|
||||
///
|
||||
/// `None` for any row that is not a well-formed annotation of a
|
||||
/// version this code knows. A row that is *nearly* an annotation is
|
||||
/// not treated as one: a partially understood claim is worse than an
|
||||
/// unread one, because it looks like evidence.
|
||||
///
|
||||
/// An unrecognised **kind** is different, and is kept as
|
||||
/// [`AnnotationKind::Other`] so it can be reported.
|
||||
pub fn from_row(row: &BundleRow) -> Option<Self> {
|
||||
let payload: Value = serde_json::from_str(row.raw_payload.as_deref()?).ok()?;
|
||||
let data = payload.get("data")?;
|
||||
if data.get("kind_of").and_then(Value::as_str)? != ANNOTATION_KIND {
|
||||
return None;
|
||||
}
|
||||
let kind = match data.get("kind").and_then(Value::as_str)? {
|
||||
"custody" => AnnotationKind::Custody,
|
||||
"integrity" => AnnotationKind::Integrity,
|
||||
"derivation" => AnnotationKind::Derivation,
|
||||
"source" => AnnotationKind::Source,
|
||||
other => AnnotationKind::Other(other.to_string()),
|
||||
};
|
||||
let layer = match data.get("layer").and_then(Value::as_str).unwrap_or("app") {
|
||||
"device" => Layer::Device,
|
||||
"host" => Layer::Host,
|
||||
"os" => Layer::Os,
|
||||
"app" => Layer::App,
|
||||
"network" => Layer::Network,
|
||||
other => Layer::Other(other.to_string()),
|
||||
};
|
||||
Some(Self {
|
||||
kind,
|
||||
content_tag: data.get("content_tag")?.as_str()?.to_string(),
|
||||
node: data.get("node")?.as_str()?.to_string(),
|
||||
layer,
|
||||
satisfied: data.get("satisfied").and_then(Value::as_bool),
|
||||
received_from: data
|
||||
.get("received_from")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok()),
|
||||
note: data.get("note").and_then(Value::as_str).map(str::to_string),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The `entity_id` a node uses for its own claims about one datum.
|
||||
///
|
||||
/// Both halves matter: without the tag a node would keep one chain for
|
||||
/// everything it ever handled, and a bundle exported for one recipient
|
||||
/// would carry every other recipient's traffic with it.
|
||||
pub fn entity_id_for(content_tag: &str, node: &str) -> String {
|
||||
format!("{content_tag}:{node}")
|
||||
}
|
||||
|
||||
/// One custody hop on a reconstructed path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustodyHop {
|
||||
pub node: String,
|
||||
pub chain_id: String,
|
||||
pub sequence: i64,
|
||||
pub row_hash: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub received_from: Option<SourceRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Something wrong, named precisely enough to act on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "problem", rename_all = "snake_case")]
|
||||
pub enum AnnotationProblem {
|
||||
/// A bundle failed its own verification, so nothing it says counts.
|
||||
BundleUnverified { entity_id: String, detail: String },
|
||||
/// **A node ran a check and it did not pass.** The actionable
|
||||
/// marker: whoever reads this can refuse, quarantine, or inspect.
|
||||
CheckFailed {
|
||||
node: String,
|
||||
kind: String,
|
||||
layer: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
note: Option<String>,
|
||||
},
|
||||
/// An annotation of a kind this build does not understand. Reported
|
||||
/// rather than ignored: silence would let a future kind carrying a
|
||||
/// failed check pass an old verifier unnoticed.
|
||||
UnrecognisedKind { node: String, kind: String },
|
||||
/// A claim naming an upstream row absent from every bundle given, or
|
||||
/// present with a different `row_hash`.
|
||||
DanglingPredecessor { node: String, source_ref: SourceRef },
|
||||
/// Two nodes claim to have received the datum from the same row. Not
|
||||
/// wrong -- data fans out -- but the path is a tree, not a line.
|
||||
Fork {
|
||||
source_ref: SourceRef,
|
||||
claimants: Vec<String>,
|
||||
},
|
||||
/// No custody claim is an origin: a cycle, or a missing first claim.
|
||||
NoOrigin,
|
||||
/// More than one node claims to be the origin of the same datum.
|
||||
MultipleOrigins { nodes: Vec<String> },
|
||||
/// A hop not reachable from the origin by following links.
|
||||
Unreachable { node: String },
|
||||
}
|
||||
|
||||
/// What the bundles, taken together, say about a datum.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnnotationReport {
|
||||
pub content_tag: String,
|
||||
/// Custody hops reachable from the origin, in order. Empty when
|
||||
/// there is no single origin to order them from.
|
||||
pub hops: Vec<CustodyHop>,
|
||||
/// Every annotation found, of every kind.
|
||||
pub found: usize,
|
||||
pub problems: Vec<AnnotationProblem>,
|
||||
}
|
||||
|
||||
impl AnnotationReport {
|
||||
/// One origin, every link resolving, nothing unreachable, no fork,
|
||||
/// no failed check, and every bundle verifying on its own.
|
||||
///
|
||||
/// A method rather than a stored field: a caller who accepts forks
|
||||
/// can read `problems` and decide, and a stored boolean invites
|
||||
/// reading the verdict without the reasons.
|
||||
pub fn intact(&self) -> bool {
|
||||
self.problems.is_empty() && !self.hops.is_empty()
|
||||
}
|
||||
|
||||
/// The nodes that held the datum, in order.
|
||||
pub fn custody_line(&self) -> Vec<&str> {
|
||||
self.hops.iter().map(|h| h.node.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Every check that a node reported as failed.
|
||||
///
|
||||
/// The marker another node acts on: refuse to serve, quarantine, or
|
||||
/// route for inspection.
|
||||
pub fn failed_checks(&self) -> Vec<&AnnotationProblem> {
|
||||
self.problems
|
||||
.iter()
|
||||
.filter(|p| matches!(p, AnnotationProblem::CheckFailed { .. }))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read every annotation about a datum, and check what they say together.
|
||||
///
|
||||
/// **Verifies each bundle first.** A structural finding drawn from rows
|
||||
/// nobody checked is not a finding, and a tidy report over forged rows
|
||||
/// is worse than no report. A bundle that fails is named and its rows
|
||||
/// take no further part -- but one party lying does not erase another's
|
||||
/// honest record.
|
||||
pub fn verify_annotations(bundles: &[ProofBundle], content_tag: &str) -> AnnotationReport {
|
||||
let mut problems = Vec::new();
|
||||
let mut hops: Vec<CustodyHop> = Vec::new();
|
||||
let mut rows_by_key: HashMap<(String, i64), String> = HashMap::new();
|
||||
let mut found = 0usize;
|
||||
|
||||
for bundle in bundles {
|
||||
let report = bundle.verify_public();
|
||||
if !report.intact() {
|
||||
problems.push(AnnotationProblem::BundleUnverified {
|
||||
entity_id: bundle.entity_id.clone(),
|
||||
detail: format!(
|
||||
"rows_valid={}, fully_signed={}, continuous={}",
|
||||
report.rows_valid, report.fully_signed, report.continuous
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for row in &bundle.rows {
|
||||
if let Some(hash) = &row.row_hash {
|
||||
rows_by_key.insert((row.chain_id.clone(), row.sequence), hash.clone());
|
||||
}
|
||||
let Some(annotation) = Annotation::from_row(row) else {
|
||||
continue;
|
||||
};
|
||||
if annotation.content_tag != content_tag {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
|
||||
if annotation.satisfied == Some(false) {
|
||||
problems.push(AnnotationProblem::CheckFailed {
|
||||
node: annotation.node.clone(),
|
||||
kind: annotation.kind.as_str().to_string(),
|
||||
layer: annotation.layer.as_str().to_string(),
|
||||
note: annotation.note.clone(),
|
||||
});
|
||||
}
|
||||
if !annotation.kind.is_known() {
|
||||
problems.push(AnnotationProblem::UnrecognisedKind {
|
||||
node: annotation.node.clone(),
|
||||
kind: annotation.kind.as_str().to_string(),
|
||||
});
|
||||
}
|
||||
if annotation.kind == AnnotationKind::Custody {
|
||||
hops.push(CustodyHop {
|
||||
node: annotation.node,
|
||||
chain_id: row.chain_id.clone(),
|
||||
sequence: row.sequence,
|
||||
row_hash: row.row_hash.clone(),
|
||||
received_from: annotation.received_from,
|
||||
note: annotation.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A claimed predecessor must exist, at that position, with that hash.
|
||||
for hop in &hops {
|
||||
if let Some(sr) = &hop.received_from {
|
||||
match rows_by_key.get(&(sr.chain_id.clone(), sr.sequence)) {
|
||||
Some(hash) if *hash == sr.row_hash => {}
|
||||
_ => problems.push(AnnotationProblem::DanglingPredecessor {
|
||||
node: hop.node.clone(),
|
||||
source_ref: sr.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan-out: two hops naming the same upstream row.
|
||||
let mut by_predecessor: HashMap<(String, i64), Vec<String>> = HashMap::new();
|
||||
for hop in &hops {
|
||||
if let Some(sr) = &hop.received_from {
|
||||
by_predecessor
|
||||
.entry((sr.chain_id.clone(), sr.sequence))
|
||||
.or_default()
|
||||
.push(hop.node.clone());
|
||||
}
|
||||
}
|
||||
for ((chain_id, sequence), claimants) in &by_predecessor {
|
||||
if claimants.len() > 1 {
|
||||
problems.push(AnnotationProblem::Fork {
|
||||
source_ref: SourceRef {
|
||||
chain_id: chain_id.clone(),
|
||||
sequence: *sequence,
|
||||
row_hash: rows_by_key
|
||||
.get(&(chain_id.clone(), *sequence))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
claimants: claimants.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let origins: Vec<&CustodyHop> = hops.iter().filter(|h| h.received_from.is_none()).collect();
|
||||
match origins.len() {
|
||||
0 => {
|
||||
if !hops.is_empty() {
|
||||
problems.push(AnnotationProblem::NoOrigin);
|
||||
}
|
||||
}
|
||||
1 => {}
|
||||
_ => problems.push(AnnotationProblem::MultipleOrigins {
|
||||
nodes: origins.iter().map(|h| h.node.clone()).collect(),
|
||||
}),
|
||||
}
|
||||
|
||||
let ordered = if origins.len() == 1 {
|
||||
let mut successors: HashMap<(String, i64), Vec<CustodyHop>> = HashMap::new();
|
||||
for hop in &hops {
|
||||
if let Some(sr) = &hop.received_from {
|
||||
successors
|
||||
.entry((sr.chain_id.clone(), sr.sequence))
|
||||
.or_default()
|
||||
.push(hop.clone());
|
||||
}
|
||||
}
|
||||
let mut ordered = vec![origins[0].clone()];
|
||||
let mut seen: HashSet<(String, i64)> = HashSet::new();
|
||||
let mut cursor = (origins[0].chain_id.clone(), origins[0].sequence);
|
||||
// Follows one line. A fork is already reported; taking the first
|
||||
// branch leaves the caller a readable prefix rather than nothing.
|
||||
while seen.insert(cursor.clone()) {
|
||||
let Some(next) = successors.get(&cursor).and_then(|v| v.first()) else {
|
||||
break;
|
||||
};
|
||||
ordered.push(next.clone());
|
||||
cursor = (next.chain_id.clone(), next.sequence);
|
||||
}
|
||||
ordered
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let reached: HashSet<(String, i64)> = ordered
|
||||
.iter()
|
||||
.map(|h| (h.chain_id.clone(), h.sequence))
|
||||
.collect();
|
||||
if !ordered.is_empty() {
|
||||
for hop in &hops {
|
||||
if !reached.contains(&(hop.chain_id.clone(), hop.sequence)) {
|
||||
problems.push(AnnotationProblem::Unreachable {
|
||||
node: hop.node.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnnotationReport {
|
||||
content_tag: content_tag.to_string(),
|
||||
hops: ordered,
|
||||
found,
|
||||
problems,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(chain: &str, seq: i64, hash: &str, a: &Annotation) -> BundleRow {
|
||||
BundleRow {
|
||||
entity_id: entity_id_for(&a.content_tag, &a.node),
|
||||
chain_id: chain.to_string(),
|
||||
event_type: "MUTATION".to_string(),
|
||||
sequence: seq,
|
||||
prev_hash: String::new(),
|
||||
row_hash: Some(hash.to_string()),
|
||||
anchor_hash: None,
|
||||
raw_payload: Some(
|
||||
serde_json::to_string(&json!({ "data": a.to_data() })).expect("payload"),
|
||||
),
|
||||
break_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_id_carries_both_the_datum_and_the_node() {
|
||||
assert_eq!(entity_id_for("sha256:abc", "node-1"), "sha256:abc:node-1");
|
||||
assert_ne!(
|
||||
entity_id_for("sha256:abc", "node-1"),
|
||||
entity_id_for("sha256:abc", "node-2"),
|
||||
"one chain per (data, node) is what lets a node sign only its own \
|
||||
claims; sharing a chain would need a writer they both trust"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_round_trips_through_the_data_field() {
|
||||
let a = Annotation::integrity("sha256:abc", "node-2", false)
|
||||
.at_layer(Layer::Device)
|
||||
.because("checksum mismatch on receipt")
|
||||
.from(SourceRef {
|
||||
chain_id: "c1".into(),
|
||||
sequence: 4,
|
||||
row_hash: "aa".into(),
|
||||
});
|
||||
let parsed = Annotation::from_row(&row("c2", 1, "bb", &a)).expect("parse");
|
||||
assert_eq!(parsed, a, "what is written must be what is read back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_check_is_not_the_same_as_a_passed_check() {
|
||||
let custody = Annotation::custody("sha256:abc", "n");
|
||||
assert_eq!(
|
||||
custody.satisfied, None,
|
||||
"a custody claim asserts holding, not correctness, and must not \
|
||||
read as a check that passed"
|
||||
);
|
||||
assert_eq!(Annotation::integrity("sha256:abc", "n", true).satisfied, Some(true));
|
||||
assert_eq!(Annotation::integrity("sha256:abc", "n", false).satisfied, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kind_survives_parsing_so_it_can_be_reported() {
|
||||
let mut a = Annotation::custody("sha256:abc", "n");
|
||||
a.kind = AnnotationKind::Other("future.kind".into());
|
||||
let parsed = Annotation::from_row(&row("c", 0, "aa", &a)).expect("parse");
|
||||
assert_eq!(parsed.kind, AnnotationKind::Other("future.kind".into()));
|
||||
assert!(
|
||||
!parsed.kind.is_known(),
|
||||
"a kind an old verifier does not recognise must be visible, or a \
|
||||
future kind carrying a failed check would pass unnoticed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_layer_is_kept_rather_than_flattened_to_the_default() {
|
||||
let a = Annotation::custody("sha256:abc", "n").at_layer(Layer::Other("enclave".into()));
|
||||
let parsed = Annotation::from_row(&row("c", 0, "aa", &a)).expect("parse");
|
||||
assert_eq!(
|
||||
parsed.layer,
|
||||
Layer::Other("enclave".into()),
|
||||
"silently reporting an unknown layer as `app` would understate \
|
||||
where the claim came from, and layer exists precisely to say \
|
||||
what a claim is worth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_that_is_not_an_annotation_is_not_read_as_one() {
|
||||
let a = Annotation::custody("sha256:abc", "n");
|
||||
for payload in [
|
||||
r#"{"data":{"kind_of":"something.else"}}"#,
|
||||
r#"{"data":{"kind_of":"annotation.v1"}}"#,
|
||||
"not json at all",
|
||||
] {
|
||||
let mut r = row("c", 0, "aa", &a);
|
||||
r.raw_payload = Some(payload.to_string());
|
||||
assert!(
|
||||
Annotation::from_row(&r).is_none(),
|
||||
"a partially understood claim is worse than an unread one, \
|
||||
because it looks like evidence: {payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_version_is_inside_the_discriminator() {
|
||||
assert!(
|
||||
ANNOTATION_KIND.ends_with(".v1"),
|
||||
"a reader must be able to tell which shape it is holding before \
|
||||
it trusts the fields"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_report_is_not_an_intact_one() {
|
||||
let report = AnnotationReport {
|
||||
content_tag: "sha256:abc".into(),
|
||||
hops: vec![],
|
||||
found: 0,
|
||||
problems: vec![],
|
||||
};
|
||||
assert!(
|
||||
!report.intact(),
|
||||
"no records at all is not the same as a clean history, and the \
|
||||
difference matters most exactly when someone is asking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_checks_are_singled_out_for_acting_on() {
|
||||
let report = AnnotationReport {
|
||||
content_tag: "t".into(),
|
||||
hops: vec![],
|
||||
found: 2,
|
||||
problems: vec![
|
||||
AnnotationProblem::NoOrigin,
|
||||
AnnotationProblem::CheckFailed {
|
||||
node: "relay".into(),
|
||||
kind: "integrity".into(),
|
||||
layer: "device".into(),
|
||||
note: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
report.failed_checks().len(),
|
||||
1,
|
||||
"a caller deciding whether to refuse, quarantine or inspect needs \
|
||||
the failed checks separated from the structural findings"
|
||||
);
|
||||
}
|
||||
}
|
||||
799
crates/core/trusty-core/src/bundle.rs
Normal file
799
crates/core/trusty-core/src/bundle.rs
Normal file
@@ -0,0 +1,799 @@
|
||||
// ============================================================
|
||||
// MODULE: bundle
|
||||
// PURPOSE: Self-contained proof packages for external verification.
|
||||
// A ProofBundle lets a party without database access
|
||||
// (auditor, insurer, carbon-credit buyer) reverify a
|
||||
// slice of a chain, given the operator's SipHash key
|
||||
// (shared out-of-band with trusted verifiers) and/or
|
||||
// the Ed25519 public keys embedded inline in v2 rows.
|
||||
//
|
||||
// DESIGN NOTES:
|
||||
// - Wire format is JSON via serde. The bundle itself is NOT
|
||||
// hashed as a whole; integrity comes from the row_hash +
|
||||
// anchor_hash + signature fields of each contained row.
|
||||
// - ChainLink stays internal (no serde derives) to keep the
|
||||
// chain primitive untouched. BundleRow mirrors its shape and
|
||||
// carries the serde derives. Conversion is free.
|
||||
// - SipHash key is NEVER embedded. Key distribution is the
|
||||
// operator's concern.
|
||||
// ============================================================
|
||||
//
|
||||
// v1 SHAPE (this module is additive, does not alter payload bytes):
|
||||
// AnchorEntry { sequence, anchor_hash, detected_at }
|
||||
// BundleRow { entity_id, chain_id, event_type, sequence,
|
||||
// prev_hash, row_hash, anchor_hash, raw_payload }
|
||||
// ProofBundle { entity_id, generated_at, row_count,
|
||||
// sequence_range, rows, anchors }
|
||||
//
|
||||
// ============================================================
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::chain::{
|
||||
verify_links, verify_links_public, verify_row_signatures, ChainLink, PublicVerifyReport,
|
||||
VerifyReport,
|
||||
};
|
||||
use crate::hash::SecretKey;
|
||||
|
||||
/// One per-row anchor commitment, as emitted by
|
||||
/// `Trusty::anchors_since`. `anchor_hash` is the SHA-256 commitment
|
||||
/// written at append time; `detected_at` is the ISO-8601 timestamp
|
||||
/// the operator recorded. Anchor entries are what an operator
|
||||
/// publishes (or pushes to a buyer / insurer) for external spot
|
||||
/// checks without exposing the row contents themselves.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AnchorEntry {
|
||||
pub sequence: i64,
|
||||
pub anchor_hash: String,
|
||||
pub detected_at: String,
|
||||
}
|
||||
|
||||
/// Wire-format row carried inside a `ProofBundle`. Mirrors the
|
||||
/// in-memory `ChainLink` exactly; the only reason for a separate
|
||||
/// type is to carry serde derives without touching the chain
|
||||
/// primitive. Round-trip: `From<&ChainLink>` to serialize, then
|
||||
/// `to_chain_link` on the verifier side to feed `verify_links`.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BundleRow {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub event_type: String,
|
||||
pub sequence: i64,
|
||||
pub prev_hash: String,
|
||||
pub row_hash: Option<String>,
|
||||
pub anchor_hash: Option<String>,
|
||||
pub raw_payload: Option<String>,
|
||||
/// Why a BREAK row exists, for BREAK rows only.
|
||||
///
|
||||
/// Additive, and `None` everywhere else. Without it a reader holding
|
||||
/// only a bundle learns THAT a chain broke and not WHICH rule broke
|
||||
/// -- the reason lives in a column the bundle did not carry, so it
|
||||
/// stopped at the machine that recorded it. A marker nobody else can
|
||||
/// read is not a marker anybody can act on.
|
||||
///
|
||||
/// Safe to add: BREAK rows are skipped in hash verification, so this
|
||||
/// was never part of any hashed payload, and `skip_serializing_if`
|
||||
/// keeps bundles without one byte-identical to before.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub break_reason: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
impl BundleRow {
|
||||
/// Build a row from the fields every backend has.
|
||||
///
|
||||
/// `BundleRow` is `#[non_exhaustive]`, so it cannot be built with a
|
||||
/// struct literal outside this crate. That is deliberate and it is
|
||||
/// the lesson of 2026-08-25: adding `break_reason` broke a consumer
|
||||
/// that constructs one, while this workspace's own tests stayed
|
||||
/// green, because the consumer depends on trusty by PATH and nothing
|
||||
/// pins a version to warn anybody.
|
||||
///
|
||||
/// Adding a field is additive on the wire -- serde skips what is
|
||||
/// absent -- and NOT additive for struct literals. Those are
|
||||
/// different compatibility questions, and conflating them is what
|
||||
/// made the break silent. A constructor plus setters means the next
|
||||
/// field costs downstream nothing.
|
||||
pub fn new(
|
||||
entity_id: impl Into<String>,
|
||||
chain_id: impl Into<String>,
|
||||
event_type: impl Into<String>,
|
||||
sequence: i64,
|
||||
prev_hash: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
entity_id: entity_id.into(),
|
||||
chain_id: chain_id.into(),
|
||||
event_type: event_type.into(),
|
||||
sequence,
|
||||
prev_hash: prev_hash.into(),
|
||||
row_hash: None,
|
||||
anchor_hash: None,
|
||||
raw_payload: None,
|
||||
break_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_hashes(mut self, row_hash: Option<String>, anchor_hash: Option<String>) -> Self {
|
||||
self.row_hash = row_hash;
|
||||
self.anchor_hash = anchor_hash;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_payload(mut self, raw_payload: Option<String>) -> Self {
|
||||
self.raw_payload = raw_payload;
|
||||
self
|
||||
}
|
||||
|
||||
/// Why a BREAK row exists. Ignored for any other event type, so a
|
||||
/// caller threading it through generically cannot mislabel a row.
|
||||
pub fn with_break_reason(mut self, reason: Option<String>) -> Self {
|
||||
if self.event_type == "BREAK" {
|
||||
self.break_reason = reason;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl BundleRow {
|
||||
pub fn to_chain_link(&self) -> ChainLink {
|
||||
ChainLink {
|
||||
entity_id: self.entity_id.clone(),
|
||||
chain_id: self.chain_id.clone(),
|
||||
event_type: self.event_type.clone(),
|
||||
sequence: self.sequence,
|
||||
prev_hash: self.prev_hash.clone(),
|
||||
row_hash: self.row_hash.clone(),
|
||||
anchor_hash: self.anchor_hash.clone(),
|
||||
raw_payload: self.raw_payload.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ChainLink> for BundleRow {
|
||||
fn from(link: &ChainLink) -> Self {
|
||||
Self {
|
||||
entity_id: link.entity_id.clone(),
|
||||
chain_id: link.chain_id.clone(),
|
||||
event_type: link.event_type.clone(),
|
||||
sequence: link.sequence,
|
||||
prev_hash: link.prev_hash.clone(),
|
||||
row_hash: link.row_hash.clone(),
|
||||
anchor_hash: link.anchor_hash.clone(),
|
||||
raw_payload: link.raw_payload.clone(),
|
||||
// A ChainLink has no break reason to give; only a store that
|
||||
// read the row from its columns can supply one.
|
||||
break_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-contained proof package. Packages a slice of a chain plus
|
||||
/// its anchor commitments into a single serializable blob. The
|
||||
/// bundle alone is not cryptographically self-verifying -- the
|
||||
/// caller must supply the operator's SipHash key to reproduce the
|
||||
/// keyed row_hash values. v2 rows additionally carry their own
|
||||
/// Ed25519 public keys inline, so their signatures are verifiable
|
||||
/// with no external input.
|
||||
///
|
||||
/// `sequence_range` reports the min/max `sequence` seen across the
|
||||
/// non-BREAK rows in the bundle. BREAK rows carry sequence -1 and
|
||||
/// are excluded from the range but included in the bundle so
|
||||
/// chain-break-as-event proof survives export.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProofBundle {
|
||||
pub entity_id: String,
|
||||
pub generated_at: String,
|
||||
pub row_count: usize,
|
||||
pub sequence_range: (Option<i64>, Option<i64>),
|
||||
pub rows: Vec<BundleRow>,
|
||||
pub anchors: Vec<AnchorEntry>,
|
||||
}
|
||||
|
||||
/// One `source_refs` entry embedded in a twin computation row's data field.
|
||||
/// Carries enough info to locate and validate the referenced sensor row
|
||||
/// in a separate `ProofBundle` without database access.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SourceRef {
|
||||
pub chain_id: String,
|
||||
pub sequence: i64,
|
||||
pub row_hash: String,
|
||||
}
|
||||
|
||||
/// One failed cross-chain reference found by `ProofBundle::verify_source_refs`.
|
||||
/// `reason` is either `"not_found"` (no sensor row at that sequence) or
|
||||
/// `"hash_mismatch"` (row found but row_hash differs).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SourceRefMismatch {
|
||||
pub twin_sequence: i64,
|
||||
pub source_ref: SourceRef,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Result of `ProofBundle::verify_source_refs`. `ok` is `true` when every
|
||||
/// source_ref in the twin bundle resolves to a matching row in the sensor bundle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SourceRefReport {
|
||||
pub checked: usize,
|
||||
pub matched: usize,
|
||||
pub mismatches: Vec<SourceRefMismatch>,
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Proof that one producer signed two different rows at the same position.
|
||||
///
|
||||
/// Both rows carry a valid signature from the same key over the same
|
||||
/// `(chain_id, sequence)`, and their `row_hash` values differ. That cannot
|
||||
/// happen by accident: the signature commits to `prev_hash`, `sequence`,
|
||||
/// `entity_id` and `chain_id`, so producing two is a deliberate act of showing
|
||||
/// different histories to different parties.
|
||||
///
|
||||
/// This is a stronger result than "the chain looks wrong". A broken chain might
|
||||
/// be corruption; an equivocation is attributable to a public key, and the
|
||||
/// evidence is self-contained -- a third party can re-verify both signatures
|
||||
/// without trusting whoever reported it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct EquivocationProof {
|
||||
pub chain_id: String,
|
||||
pub sequence: i64,
|
||||
/// The public key, hex-encoded, that signed both rows.
|
||||
pub producer_pk: String,
|
||||
pub row_a: BundleRow,
|
||||
pub row_b: BundleRow,
|
||||
}
|
||||
|
||||
/// Compare two bundles for the same entity and return proof of every position
|
||||
/// where the producer signed two different rows.
|
||||
///
|
||||
/// **Signature validity is required on both sides.** An unsigned or
|
||||
/// invalidly-signed row proves nothing about its author, and admitting one
|
||||
/// would let anyone manufacture a "proof" against a producer by fabricating the
|
||||
/// second half. Rows that do not verify are skipped rather than reported.
|
||||
///
|
||||
/// Detects nothing when the two bundles are the same history, which is the
|
||||
/// common case; the cost is one signature verification per row per side.
|
||||
pub fn detect_equivocation(a: &ProofBundle, b: &ProofBundle) -> Vec<EquivocationProof> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Index one side by position. BREAK rows carry no row_hash and sequence -1,
|
||||
// so they cannot equivocate and are excluded.
|
||||
let mut left: HashMap<(&str, i64), &BundleRow> = HashMap::new();
|
||||
for row in &a.rows {
|
||||
if row.event_type == "BREAK" || row.row_hash.is_none() {
|
||||
continue;
|
||||
}
|
||||
left.insert((row.chain_id.as_str(), row.sequence), row);
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for right in &b.rows {
|
||||
if right.event_type == "BREAK" || right.row_hash.is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(&leftr) = left.get(&(right.chain_id.as_str(), right.sequence)) else {
|
||||
continue;
|
||||
};
|
||||
if leftr.row_hash == right.row_hash {
|
||||
continue; // same row; nothing to see
|
||||
}
|
||||
|
||||
// Both must be validly signed BY THE SAME KEY, or this proves nothing.
|
||||
let (Some(raw_a), Some(raw_b)) = (&leftr.raw_payload, &right.raw_payload) else {
|
||||
continue;
|
||||
};
|
||||
if !verify_row_signatures(raw_a).is_empty() || !verify_row_signatures(raw_b).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (Some(pk_a), Some(pk_b)) = (first_signature_pk(raw_a), first_signature_pk(raw_b))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if pk_a != pk_b {
|
||||
continue; // two producers disagreeing is not one producer equivocating
|
||||
}
|
||||
|
||||
out.push(EquivocationProof {
|
||||
chain_id: right.chain_id.clone(),
|
||||
sequence: right.sequence,
|
||||
producer_pk: pk_a,
|
||||
row_a: (*leftr).clone(),
|
||||
row_b: right.clone(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Hex public key of the first signature entry in a raw payload, if any.
|
||||
fn first_signature_pk(raw: &str) -> Option<String> {
|
||||
let parsed = crate::chain::parse_raw_payload(raw).ok()?;
|
||||
parsed
|
||||
.signatures
|
||||
.first()?
|
||||
.as_object()?
|
||||
.get("pk")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
impl ProofBundle {
|
||||
/// Reverify the bundle's chain slice. Runs the same
|
||||
/// `verify_links` pipeline that `Trusty::verify` runs on a live
|
||||
/// database.
|
||||
///
|
||||
/// CHECK TWO FIELDS, NOT ONE. `report.rows_valid` says the rows hashed and
|
||||
/// signed correctly; `report.continuous` says no break was recorded
|
||||
/// in the range, with `report.breaks` giving the positions. A bundle
|
||||
/// can be `ok` and NOT continuous -- the segments either side of a
|
||||
/// break are well-formed, and a break is written precisely because a
|
||||
/// monitored condition failed at append time.
|
||||
///
|
||||
/// This doc comment used to say "inspect `report.rows_valid` for the
|
||||
/// summary", which is how a third party ended up able to export a
|
||||
/// bundle, verify it, read `ok: true` and conclude a chain was
|
||||
/// unbroken when it carried the system's own record of detected
|
||||
/// tampering. `report.errors` still holds the detail when `ok` is
|
||||
/// false.
|
||||
pub fn verify(&self, key: &SecretKey) -> VerifyReport {
|
||||
let links: Vec<ChainLink> = self.rows.iter().map(BundleRow::to_chain_link).collect();
|
||||
verify_links(&self.entity_id, &links, key)
|
||||
}
|
||||
|
||||
/// Reverify using only what travels inside the bundle -- no secret key.
|
||||
///
|
||||
/// This is the path this module header promises to a party holding a
|
||||
/// bundle and nothing else: Ed25519 signatures (public keys are inline in
|
||||
/// v2+ rows), plus linkage and anchor consistency. It previously had no
|
||||
/// public entry point, so a recipient without the operator SipHash key
|
||||
/// could not verify a bundle at all -- which is the audience a bundle
|
||||
/// exists for.
|
||||
///
|
||||
/// Read `ok` for whether everything is accounted for and `continuous`
|
||||
/// for whether a break was recorded, exactly as with [`Self::verify`].
|
||||
/// Note `ok` is false when any row is unsigned: without the key such a
|
||||
/// row is unattributable.
|
||||
pub fn verify_public(&self) -> PublicVerifyReport {
|
||||
let links: Vec<ChainLink> = self.rows.iter().map(BundleRow::to_chain_link).collect();
|
||||
verify_links_public(&self.entity_id, &links)
|
||||
}
|
||||
|
||||
/// Cross-chain reference verification. `self` is the twin bundle;
|
||||
/// `sensor_bundle` is the bundle that should contain the referenced rows.
|
||||
///
|
||||
/// Walks every twin row whose `raw_payload` contains a `data.source_refs`
|
||||
/// array (the twin coupling convention from docs/twin-coupling.md). For each
|
||||
/// `{chain_id, sequence, row_hash}` ref, confirms a matching sensor row
|
||||
/// exists in `sensor_bundle` at that sequence with an identical `row_hash`.
|
||||
///
|
||||
/// Returns a `SourceRefReport`. `ok` is `true` when all refs resolve.
|
||||
/// Unresolved refs appear in `mismatches` with reason `"not_found"` or
|
||||
/// `"hash_mismatch"`.
|
||||
pub fn verify_source_refs(&self, sensor_bundle: &ProofBundle) -> SourceRefReport {
|
||||
// Index sensor rows by sequence for O(1) lookup.
|
||||
let sensor_index: std::collections::HashMap<i64, &BundleRow> = sensor_bundle
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.sequence >= 0)
|
||||
.map(|r| (r.sequence, r))
|
||||
.collect();
|
||||
|
||||
let mut checked = 0usize;
|
||||
let mut matched = 0usize;
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
for twin_row in self.rows.iter().filter(|r| r.sequence >= 0) {
|
||||
let payload: Value = match twin_row
|
||||
.raw_payload
|
||||
.as_deref()
|
||||
.and_then(|p| serde_json::from_str(p).ok())
|
||||
{
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
let refs = match payload
|
||||
.get("data")
|
||||
.and_then(|d| d.get("source_refs"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
for sr in refs {
|
||||
let chain_id = match sr.get("chain_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let sequence = match sr.get("sequence").and_then(|v| v.as_i64()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let row_hash = match sr.get("row_hash").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
checked += 1;
|
||||
let source_ref = SourceRef {
|
||||
chain_id,
|
||||
sequence,
|
||||
row_hash: row_hash.clone(),
|
||||
};
|
||||
match sensor_index.get(&sequence) {
|
||||
None => mismatches.push(SourceRefMismatch {
|
||||
twin_sequence: twin_row.sequence,
|
||||
source_ref,
|
||||
reason: "not_found".into(),
|
||||
}),
|
||||
Some(sensor_row) => {
|
||||
if sensor_row.row_hash.as_deref() == Some(&row_hash) {
|
||||
matched += 1;
|
||||
} else {
|
||||
mismatches.push(SourceRefMismatch {
|
||||
twin_sequence: twin_row.sequence,
|
||||
source_ref,
|
||||
reason: "hash_mismatch".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ok = mismatches.is_empty();
|
||||
SourceRefReport {
|
||||
checked,
|
||||
matched,
|
||||
mismatches,
|
||||
ok,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor. Computes `row_count` and
|
||||
/// `sequence_range` from `rows`; callers do not need to
|
||||
/// pre-compute them. Non-BREAK rows (sequence >= 0) contribute
|
||||
/// to the range.
|
||||
pub fn new(
|
||||
entity_id: String,
|
||||
generated_at: String,
|
||||
rows: Vec<BundleRow>,
|
||||
anchors: Vec<AnchorEntry>,
|
||||
) -> Self {
|
||||
let row_count = rows.len();
|
||||
let (mut lo, mut hi): (Option<i64>, Option<i64>) = (None, None);
|
||||
for r in rows.iter().filter(|r| r.sequence >= 0) {
|
||||
lo = Some(lo.map_or(r.sequence, |x| x.min(r.sequence)));
|
||||
hi = Some(hi.map_or(r.sequence, |x| x.max(r.sequence)));
|
||||
}
|
||||
Self {
|
||||
entity_id,
|
||||
generated_at,
|
||||
row_count,
|
||||
sequence_range: (lo, hi),
|
||||
rows,
|
||||
anchors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_row(sequence: i64, row_hash: &str, raw_payload: Option<&str>) -> BundleRow {
|
||||
BundleRow {
|
||||
entity_id: "test".into(),
|
||||
chain_id: "chain_test_000001".into(),
|
||||
event_type: if sequence == 0 {
|
||||
"GENESIS".into()
|
||||
} else {
|
||||
"MUTATION".into()
|
||||
},
|
||||
sequence,
|
||||
prev_hash: "0".into(),
|
||||
row_hash: Some(row_hash.into()),
|
||||
anchor_hash: None,
|
||||
raw_payload: raw_payload.map(|s| s.into()),
|
||||
break_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sensor_bundle(rows: Vec<BundleRow>) -> ProofBundle {
|
||||
ProofBundle::new("sensor".into(), "2026-01-01T00:00:00Z".into(), rows, vec![])
|
||||
}
|
||||
|
||||
fn twin_bundle(rows: Vec<BundleRow>) -> ProofBundle {
|
||||
ProofBundle::new("twin".into(), "2026-01-01T00:00:00Z".into(), rows, vec![])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_source_refs_all_match() {
|
||||
let s0 = make_row(0, "hash_aaa", None);
|
||||
let s1 = make_row(1, "hash_bbb", None);
|
||||
let sensor = sensor_bundle(vec![s0.clone(), s1.clone()]);
|
||||
|
||||
let twin_payload = serde_json::json!({
|
||||
"data": {
|
||||
"source_refs": [
|
||||
{"chain_id": "chain_test_000001", "sequence": 0, "row_hash": "hash_aaa"},
|
||||
{"chain_id": "chain_test_000001", "sequence": 1, "row_hash": "hash_bbb"},
|
||||
]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let twin_row = make_row(0, "hash_twin", Some(&twin_payload));
|
||||
let twin = twin_bundle(vec![twin_row]);
|
||||
|
||||
let report = twin.verify_source_refs(&sensor);
|
||||
assert!(report.ok);
|
||||
assert_eq!(report.checked, 2);
|
||||
assert_eq!(report.matched, 2);
|
||||
assert!(report.mismatches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_source_refs_missing_row() {
|
||||
let sensor = sensor_bundle(vec![make_row(0, "hash_aaa", None)]);
|
||||
|
||||
let twin_payload = serde_json::json!({
|
||||
"data": {
|
||||
"source_refs": [
|
||||
{"chain_id": "chain_test_000001", "sequence": 99, "row_hash": "hash_zzz"},
|
||||
]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let twin = twin_bundle(vec![make_row(0, "hash_twin", Some(&twin_payload))]);
|
||||
|
||||
let report = twin.verify_source_refs(&sensor);
|
||||
assert!(!report.ok);
|
||||
assert_eq!(report.checked, 1);
|
||||
assert_eq!(report.matched, 0);
|
||||
assert_eq!(report.mismatches[0].reason, "not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_source_refs_hash_mismatch() {
|
||||
let sensor = sensor_bundle(vec![make_row(0, "hash_aaa", None)]);
|
||||
|
||||
let twin_payload = serde_json::json!({
|
||||
"data": {
|
||||
"source_refs": [
|
||||
{"chain_id": "chain_test_000001", "sequence": 0, "row_hash": "hash_WRONG"},
|
||||
]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let twin = twin_bundle(vec![make_row(0, "hash_twin", Some(&twin_payload))]);
|
||||
|
||||
let report = twin.verify_source_refs(&sensor);
|
||||
assert!(!report.ok);
|
||||
assert_eq!(report.mismatches[0].reason, "hash_mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_source_refs_no_refs_in_twin_is_ok() {
|
||||
let sensor = sensor_bundle(vec![make_row(0, "hash_aaa", None)]);
|
||||
let twin = twin_bundle(vec![make_row(0, "hash_twin", None)]);
|
||||
let report = twin.verify_source_refs(&sensor);
|
||||
assert!(report.ok);
|
||||
assert_eq!(report.checked, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "keygen"))]
|
||||
mod equivocation_tests {
|
||||
use super::*;
|
||||
use crate::chain::{compute_record_signed, make_chain_id, ComputedRecord, EventType};
|
||||
use crate::crypto::{Ed25519Signer, Signer};
|
||||
use crate::hash::SecretKey;
|
||||
use crate::payload::{PayloadFields, PAYLOAD_VERSION_V2};
|
||||
use serde_json::json;
|
||||
|
||||
fn key() -> SecretKey {
|
||||
SecretKey::from_bytes(b"0123456789abcdef").unwrap()
|
||||
}
|
||||
|
||||
/// One signed row at `sequence`, whose contents depend on `marker` -- two
|
||||
/// different markers at the same sequence is the equivocation.
|
||||
fn signed(signer: &Ed25519Signer, sequence: i64, marker: i64) -> BundleRow {
|
||||
let chain_id = make_chain_id("acme", 1);
|
||||
let mut f = PayloadFields::new_v1(
|
||||
"acme",
|
||||
chain_id.clone(),
|
||||
json!({ "v": marker }),
|
||||
"s",
|
||||
"2026-03-08T00:00:00Z",
|
||||
"2026-03-08T00:00:00.123456Z",
|
||||
"trusty",
|
||||
"genesis",
|
||||
sequence,
|
||||
);
|
||||
f.payload_version = PAYLOAD_VERSION_V2;
|
||||
let rec: ComputedRecord = compute_record_signed(&f, &key(), signer).unwrap();
|
||||
BundleRow {
|
||||
entity_id: "acme".into(),
|
||||
chain_id,
|
||||
event_type: EventType::Mutation.as_str().into(),
|
||||
sequence,
|
||||
prev_hash: "genesis".into(),
|
||||
row_hash: Some(rec.row_hash),
|
||||
anchor_hash: Some(rec.anchor_hash),
|
||||
raw_payload: Some(rec.raw_payload),
|
||||
break_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bundle(rows: Vec<BundleRow>) -> ProofBundle {
|
||||
ProofBundle::new("acme".into(), "2026-01-01T00:00:00Z".into(), rows, vec![])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_signed_rows_at_one_sequence_are_proof() {
|
||||
let signer = Ed25519Signer::generate();
|
||||
let a = bundle(vec![signed(&signer, 1, 100)]);
|
||||
let b = bundle(vec![signed(&signer, 1, 999)]);
|
||||
|
||||
let proofs = detect_equivocation(&a, &b);
|
||||
assert_eq!(proofs.len(), 1, "one producer, one position, two histories");
|
||||
assert_eq!(proofs[0].sequence, 1);
|
||||
assert_ne!(proofs[0].row_a.row_hash, proofs[0].row_b.row_hash);
|
||||
assert_eq!(
|
||||
proofs[0].producer_pk,
|
||||
hex::encode(signer.public_key_bytes()),
|
||||
"the proof must name the key that signed both"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_history_twice_proves_nothing() {
|
||||
let signer = Ed25519Signer::generate();
|
||||
let row = signed(&signer, 1, 100);
|
||||
let a = bundle(vec![row.clone()]);
|
||||
let b = bundle(vec![row]);
|
||||
assert!(detect_equivocation(&a, &b).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_different_producers_are_not_equivocating() {
|
||||
// Two keys signing different content at the same sequence is a
|
||||
// disagreement between parties, not one party showing two faces.
|
||||
let one = Ed25519Signer::generate();
|
||||
let two = Ed25519Signer::generate();
|
||||
let a = bundle(vec![signed(&one, 1, 100)]);
|
||||
let b = bundle(vec![signed(&two, 1, 999)]);
|
||||
assert!(
|
||||
detect_equivocation(&a, &b).is_empty(),
|
||||
"differing keys must never be reported as equivocation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forged_second_row_cannot_manufacture_a_proof() {
|
||||
// The attack this guards: fabricate a row at someone's sequence to
|
||||
// "prove" they equivocated. Without a valid signature it proves nothing,
|
||||
// so it must be skipped rather than reported.
|
||||
let signer = Ed25519Signer::generate();
|
||||
let a = bundle(vec![signed(&signer, 1, 100)]);
|
||||
|
||||
let mut forged = signed(&signer, 1, 999);
|
||||
// Break the signature by rewriting the payload after signing.
|
||||
let raw = forged.raw_payload.clone().unwrap();
|
||||
forged.raw_payload = Some(raw.replace("\"v\":999", "\"v\":1234"));
|
||||
let b = bundle(vec![forged]);
|
||||
|
||||
assert!(
|
||||
detect_equivocation(&a, &b).is_empty(),
|
||||
"an unsigned or tampered row must never be admitted as proof"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_rows_cannot_equivocate() {
|
||||
let signer = Ed25519Signer::generate();
|
||||
let mut brk = signed(&signer, -1, 1);
|
||||
brk.event_type = EventType::Break.as_str().into();
|
||||
brk.row_hash = None;
|
||||
let a = bundle(vec![brk.clone()]);
|
||||
let b = bundle(vec![brk]);
|
||||
assert!(detect_equivocation(&a, &b).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded rule violation, read off a bundle.
|
||||
///
|
||||
/// Self-contained on purpose: everything needed to say what broke, and
|
||||
/// where, without going back to the machine that recorded it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Violation {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
/// The rule's name, when the break named one.
|
||||
///
|
||||
/// `None` for a break with a category but no rule -- tamper
|
||||
/// detection, a sequence gap. Those are real breaks and are still
|
||||
/// reported; they simply have no invariant to name.
|
||||
pub rule: Option<String>,
|
||||
/// The reason exactly as recorded.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What a bundle says about whether its data broke a rule.
|
||||
///
|
||||
/// Three answers, not two. **Absent evidence must never read as clean**
|
||||
/// -- the same rule as "absent policy is not permission" -- so a bundle
|
||||
/// that cannot be verified reports [`Stance::Unverifiable`] rather than
|
||||
/// quietly counting zero violations and looking spotless.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "stance", rename_all = "snake_case")]
|
||||
pub enum Stance {
|
||||
/// Verified, and records no break.
|
||||
Clean,
|
||||
/// Verified, and records at least one break.
|
||||
Violated { violations: Vec<Violation> },
|
||||
/// Could not be verified, so it says nothing either way. Treating
|
||||
/// this as clean is the mistake this variant exists to prevent.
|
||||
Unverifiable { detail: String },
|
||||
}
|
||||
|
||||
impl Stance {
|
||||
/// Whether it is safe to proceed on this bundle alone.
|
||||
///
|
||||
/// Only [`Stance::Clean`]. An unverifiable bundle is not a permit.
|
||||
pub fn is_clean(&self) -> bool {
|
||||
matches!(self, Stance::Clean)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProofBundle {
|
||||
/// Every rule violation this bundle records.
|
||||
///
|
||||
/// Reads the BREAK rows only. Says nothing about whether the bundle
|
||||
/// verifies -- use [`ProofBundle::stance`] for the question a caller
|
||||
/// deciding whether to act should actually be asking.
|
||||
pub fn violations(&self) -> Vec<Violation> {
|
||||
self.rows
|
||||
.iter()
|
||||
.filter(|r| r.event_type == "BREAK")
|
||||
.map(|r| {
|
||||
let reason = r.break_reason.clone().unwrap_or_default();
|
||||
Violation {
|
||||
entity_id: r.entity_id.clone(),
|
||||
chain_id: r.chain_id.clone(),
|
||||
rule: reason
|
||||
.strip_prefix("PROPERTY_VIOLATED:")
|
||||
.map(str::to_string),
|
||||
reason,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Verify, then say whether anything broke.
|
||||
///
|
||||
/// The question a peer asks before serving, quarantining, or
|
||||
/// inspecting: *is this datum carrying a signed violation?* Needs no
|
||||
/// secret key, so any party holding the bundle can ask it.
|
||||
pub fn stance(&self) -> Stance {
|
||||
let report = self.verify_public();
|
||||
if !report.rows_valid {
|
||||
return Stance::Unverifiable {
|
||||
detail: "a signature did not verify or linkage did not hold, so \
|
||||
nothing this bundle says can be relied on"
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
if !report.fully_signed {
|
||||
return Stance::Unverifiable {
|
||||
detail: "a row carries no signature, so it is not merely \
|
||||
unchecked but unattributable"
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
let violations = self.violations();
|
||||
if violations.is_empty() {
|
||||
Stance::Clean
|
||||
} else {
|
||||
Stance::Violated { violations }
|
||||
}
|
||||
}
|
||||
}
|
||||
1742
crates/core/trusty-core/src/chain.rs
Normal file
1742
crates/core/trusty-core/src/chain.rs
Normal file
File diff suppressed because it is too large
Load Diff
334
crates/core/trusty-core/src/conditions.rs
Normal file
334
crates/core/trusty-core/src/conditions.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
// Pluggable break conditions.
|
||||
//
|
||||
// A condition evaluates the state at append time and votes Pass or
|
||||
// Fail(reason). First failure wins — subsequent conditions do not
|
||||
// run. Default set: HashIntegrity, SequenceContinuity. SourceDomain
|
||||
// exists but is opt-in (requires a domain list).
|
||||
//
|
||||
// Byte-identical vote outcomes to the Python reference at
|
||||
// trusty_core_watermark_proxy/core/trusty/conditions/defaults.py
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConditionContext {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub sequence: i64,
|
||||
pub stored_tip_hash: Option<String>,
|
||||
pub computed_hash: Option<String>,
|
||||
pub prev_hash: Option<String>,
|
||||
pub last_sequence: Option<i64>,
|
||||
pub source_url: String,
|
||||
pub extraction_method: String,
|
||||
/// The MEASURED DATA being appended (the payload). S126 gap B: this is what
|
||||
/// lets a condition monitor the DATA PLANE, not just chain metadata -- e.g. a
|
||||
/// threshold over a measured metric (`latency_ms > 500`) evaluated at append
|
||||
/// time, recording a break that names the variable and the observed value.
|
||||
/// `Value::Null` when the backend supplies no data (older callers, tests).
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BreakReason {
|
||||
TamperDetected,
|
||||
SequenceGap,
|
||||
SourceChange,
|
||||
ManualReset,
|
||||
SchemaMigration,
|
||||
ConditionEvaluatorError,
|
||||
/// A declared RUN RULE / invariant was violated at append time -- carries the
|
||||
/// rule's name so the recorded break says WHICH invariant broke, instead of
|
||||
/// being mislabelled `SequenceGap` (the S126 gap A). ADDITIVE and safe: BREAK
|
||||
/// rows are skipped in hash verification (`chain::verify_links`), so the break
|
||||
/// reason was never part of the hashed payload -- existing chains are
|
||||
/// unaffected. The rule name travels via `code()`, never via the frozen
|
||||
/// `as_code()`.
|
||||
PropertyViolated(String),
|
||||
}
|
||||
|
||||
impl BreakReason {
|
||||
/// The FROZEN category code (`&'static str`). Unchanged for existing variants
|
||||
/// so any historical use is byte-stable; `PropertyViolated` reports its
|
||||
/// category here and its rule name via [`code`](Self::code).
|
||||
pub fn as_code(&self) -> &'static str {
|
||||
match self {
|
||||
BreakReason::TamperDetected => "TAMPER_DETECTED",
|
||||
BreakReason::SequenceGap => "SEQUENCE_GAP",
|
||||
BreakReason::SourceChange => "SOURCE_CHANGE",
|
||||
BreakReason::ManualReset => "MANUAL_RESET",
|
||||
BreakReason::SchemaMigration => "SCHEMA_MIGRATION",
|
||||
BreakReason::ConditionEvaluatorError => "CONDITION_EVALUATOR_ERROR",
|
||||
BreakReason::PropertyViolated(_) => "PROPERTY_VIOLATED",
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded reason string: the category, plus the rule name for a
|
||||
/// `PropertyViolated` (`PROPERTY_VIOLATED:<rule>`). This is what a break row
|
||||
/// stores, so the answer to "which invariant broke?" travels on the chain.
|
||||
pub fn code(&self) -> String {
|
||||
match self {
|
||||
BreakReason::PropertyViolated(rule) => format!("PROPERTY_VIOLATED:{rule}"),
|
||||
other => other.as_code().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule name, when this break is a `PropertyViolated`. `None` otherwise.
|
||||
pub fn rule(&self) -> Option<&str> {
|
||||
match self {
|
||||
BreakReason::PropertyViolated(rule) => Some(rule),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConditionOutcome {
|
||||
Pass,
|
||||
Fail(BreakReason),
|
||||
}
|
||||
|
||||
pub trait Condition: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome;
|
||||
}
|
||||
|
||||
pub struct HashIntegrity;
|
||||
|
||||
impl Condition for HashIntegrity {
|
||||
fn name(&self) -> &'static str {
|
||||
"hash_integrity"
|
||||
}
|
||||
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
|
||||
let stored = &ctx.stored_tip_hash;
|
||||
let computed = &ctx.computed_hash;
|
||||
|
||||
// Genesis — first ingestion for this entity.
|
||||
if stored.is_none() && ctx.sequence == 0 {
|
||||
return ConditionOutcome::Pass;
|
||||
}
|
||||
|
||||
// Stored matches computed — chain intact.
|
||||
if let (Some(s), Some(c)) = (stored.as_ref(), computed.as_ref()) {
|
||||
if s == c {
|
||||
return ConditionOutcome::Pass;
|
||||
}
|
||||
return ConditionOutcome::Fail(BreakReason::TamperDetected);
|
||||
}
|
||||
|
||||
// stored present, computed missing — cannot compare, treat as fail.
|
||||
if stored.is_some() && computed.is_none() {
|
||||
return ConditionOutcome::Fail(BreakReason::TamperDetected);
|
||||
}
|
||||
|
||||
// stored=None, sequence>0 — unusual but recoverable.
|
||||
ConditionOutcome::Pass
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SequenceContinuity;
|
||||
|
||||
impl Condition for SequenceContinuity {
|
||||
fn name(&self) -> &'static str {
|
||||
"sequence_continuity"
|
||||
}
|
||||
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
|
||||
match ctx.last_sequence {
|
||||
None => ConditionOutcome::Pass,
|
||||
Some(last) => {
|
||||
if ctx.sequence == last + 1 {
|
||||
ConditionOutcome::Pass
|
||||
} else {
|
||||
ConditionOutcome::Fail(BreakReason::SequenceGap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SourceDomain {
|
||||
pub trusted_domains: Vec<String>,
|
||||
pub hard: bool,
|
||||
}
|
||||
|
||||
impl SourceDomain {
|
||||
pub fn new(trusted_domains: Vec<String>, hard: bool) -> Self {
|
||||
Self {
|
||||
trusted_domains,
|
||||
hard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Condition for SourceDomain {
|
||||
fn name(&self) -> &'static str {
|
||||
"source_domain"
|
||||
}
|
||||
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
|
||||
if ctx.source_url.is_empty() || self.trusted_domains.is_empty() {
|
||||
return ConditionOutcome::Pass;
|
||||
}
|
||||
let ok = self
|
||||
.trusted_domains
|
||||
.iter()
|
||||
.any(|d| ctx.source_url.contains(d));
|
||||
if ok {
|
||||
ConditionOutcome::Pass
|
||||
} else if self.hard {
|
||||
ConditionOutcome::Fail(BreakReason::SourceChange)
|
||||
} else {
|
||||
ConditionOutcome::Pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_conditions() -> Vec<Box<dyn Condition>> {
|
||||
vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)]
|
||||
}
|
||||
|
||||
pub fn evaluate(conditions: &[Box<dyn Condition>], ctx: &ConditionContext) -> ConditionOutcome {
|
||||
for c in conditions {
|
||||
match c.evaluate(ctx) {
|
||||
ConditionOutcome::Pass => continue,
|
||||
ConditionOutcome::Fail(reason) => return ConditionOutcome::Fail(reason),
|
||||
}
|
||||
}
|
||||
ConditionOutcome::Pass
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn property_violated_carries_the_rule_name_via_code_not_the_frozen_as_code() {
|
||||
let r = BreakReason::PropertyViolated("anchor-before-commit".into());
|
||||
assert_eq!(r.as_code(), "PROPERTY_VIOLATED", "frozen category");
|
||||
assert_eq!(
|
||||
r.code(),
|
||||
"PROPERTY_VIOLATED:anchor-before-commit",
|
||||
"recorded reason names the rule"
|
||||
);
|
||||
assert_eq!(r.rule(), Some("anchor-before-commit"));
|
||||
// Existing variants are byte-stable: code() == as_code() for them, so no
|
||||
// historical break row changes meaning.
|
||||
assert_eq!(BreakReason::SequenceGap.code(), "SEQUENCE_GAP");
|
||||
assert_eq!(BreakReason::TamperDetected.rule(), None);
|
||||
}
|
||||
|
||||
fn ctx_genesis() -> ConditionContext {
|
||||
ConditionContext {
|
||||
data: serde_json::Value::Null,
|
||||
entity_id: "acme".into(),
|
||||
chain_id: "chain_acme_001".into(),
|
||||
sequence: 0,
|
||||
stored_tip_hash: None,
|
||||
computed_hash: None,
|
||||
prev_hash: None,
|
||||
last_sequence: None,
|
||||
source_url: "s".into(),
|
||||
extraction_method: "trusty".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_integrity_passes_on_genesis() {
|
||||
let c = HashIntegrity;
|
||||
matches!(c.evaluate(&ctx_genesis()), ConditionOutcome::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_integrity_fails_on_mismatch() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.sequence = 1;
|
||||
ctx.stored_tip_hash = Some("abc".into());
|
||||
ctx.computed_hash = Some("def".into());
|
||||
ctx.last_sequence = Some(0);
|
||||
let out = HashIntegrity.evaluate(&ctx);
|
||||
assert!(matches!(
|
||||
out,
|
||||
ConditionOutcome::Fail(BreakReason::TamperDetected)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_integrity_passes_when_match() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.sequence = 1;
|
||||
ctx.stored_tip_hash = Some("same".into());
|
||||
ctx.computed_hash = Some("same".into());
|
||||
ctx.last_sequence = Some(0);
|
||||
assert!(matches!(
|
||||
HashIntegrity.evaluate(&ctx),
|
||||
ConditionOutcome::Pass
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_continuity_passes_first_row() {
|
||||
assert!(matches!(
|
||||
SequenceContinuity.evaluate(&ctx_genesis()),
|
||||
ConditionOutcome::Pass
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_continuity_fails_on_gap() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.last_sequence = Some(5);
|
||||
ctx.sequence = 7;
|
||||
let out = SequenceContinuity.evaluate(&ctx);
|
||||
assert!(matches!(
|
||||
out,
|
||||
ConditionOutcome::Fail(BreakReason::SequenceGap)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_continuity_passes_on_next() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.last_sequence = Some(5);
|
||||
ctx.sequence = 6;
|
||||
assert!(matches!(
|
||||
SequenceContinuity.evaluate(&ctx),
|
||||
ConditionOutcome::Pass
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_returns_first_failure() {
|
||||
let conditions: Vec<Box<dyn Condition>> =
|
||||
vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)];
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.sequence = 7;
|
||||
ctx.last_sequence = Some(5);
|
||||
ctx.stored_tip_hash = Some("abc".into());
|
||||
ctx.computed_hash = Some("def".into());
|
||||
// Both conditions fail — HashIntegrity runs first, wins.
|
||||
let out = evaluate(&conditions, &ctx);
|
||||
assert!(matches!(
|
||||
out,
|
||||
ConditionOutcome::Fail(BreakReason::TamperDetected)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_domain_soft_does_not_break() {
|
||||
let cond = SourceDomain::new(vec!["sec.gov".into()], false);
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.source_url = "http://evil.example/data".into();
|
||||
assert!(matches!(cond.evaluate(&ctx), ConditionOutcome::Pass));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_domain_hard_breaks_on_mismatch() {
|
||||
let cond = SourceDomain::new(vec!["sec.gov".into()], true);
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.source_url = "http://evil.example/data".into();
|
||||
assert!(matches!(
|
||||
cond.evaluate(&ctx),
|
||||
ConditionOutcome::Fail(BreakReason::SourceChange)
|
||||
));
|
||||
}
|
||||
}
|
||||
160
crates/core/trusty-core/src/credential.rs
Normal file
160
crates/core/trusty-core/src/credential.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! What kind of key a producer enrolled, and what may be done with it.
|
||||
//!
|
||||
//! # The failure this prevents
|
||||
//!
|
||||
//! A `user` principal authenticates with WebAuthn. The credential is a
|
||||
//! **COSE key**, and the authenticator does not sign the message a
|
||||
//! relying party cares about -- it signs
|
||||
//! `authenticatorData ‖ SHA-256(clientDataJSON)`, with the challenge
|
||||
//! buried inside the client data.
|
||||
//!
|
||||
//! An `agent` or `program` holds a raw Ed25519 key and produces a bare
|
||||
//! signature over the message itself.
|
||||
//!
|
||||
//! These are not interchangeable, and the dangerous part is how they
|
||||
//! fail together. Handing a COSE key and a WebAuthn assertion to a bare
|
||||
//! Ed25519 verifier does not produce a clean error -- it produces a
|
||||
//! verification failure that looks like a wrong key, which invites
|
||||
//! somebody to "fix" it by loosening the check. A registry with one
|
||||
//! column and one verifier makes that mistake available; this makes it
|
||||
//! unavailable.
|
||||
//!
|
||||
//! # Where the boundary is
|
||||
//!
|
||||
//! Trusty stores the format and **refuses** to verify what it cannot
|
||||
//! verify. It does not verify WebAuthn assertions, deliberately: doing
|
||||
//! that requires the relying party's RP ID, origin, and challenge
|
||||
//! policy, none of which trusty has any business knowing. A half-built
|
||||
//! WebAuthn verifier is worse than none, because it looks like one.
|
||||
//!
|
||||
//! The consumer that *is* the relying party verifies the assertion and
|
||||
//! tells trusty the outcome.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::crypto::CryptoError;
|
||||
|
||||
/// How a producer's enrolled key material is encoded, and therefore how
|
||||
/// a signature from it must be checked.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum KeyFormat {
|
||||
/// A raw 32-byte Ed25519 public key. The signature covers the
|
||||
/// message directly. This is what an agent or program enrols.
|
||||
#[default]
|
||||
Ed25519Raw,
|
||||
/// A COSE_Key from a WebAuthn authenticator. The signature covers
|
||||
/// `authenticatorData ‖ SHA-256(clientDataJSON)`, not the message,
|
||||
/// so it cannot be checked without the assertion that produced it.
|
||||
/// This is what a user enrols.
|
||||
Cose,
|
||||
}
|
||||
|
||||
impl KeyFormat {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ed25519Raw => "ed25519-raw",
|
||||
Self::Cose => "cose",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored format string.
|
||||
///
|
||||
/// An unrecognised value is an error, not a default. Defaulting to
|
||||
/// `Ed25519Raw` would silently route a credential of unknown shape
|
||||
/// into the bare-signature path -- the exact failure this type
|
||||
/// exists to prevent.
|
||||
pub fn parse(s: &str) -> Result<Self, CryptoError> {
|
||||
match s {
|
||||
"ed25519-raw" => Ok(Self::Ed25519Raw),
|
||||
"cose" => Ok(Self::Cose),
|
||||
other => Err(CryptoError::UnsupportedCredential(format!(
|
||||
"unknown key format {other:?}; refusing rather than guessing, \
|
||||
because guessing wrong means checking a WebAuthn credential \
|
||||
as a bare signature"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bare signature over the message is the right check.
|
||||
pub fn is_bare_signature(self) -> bool {
|
||||
matches!(self, Self::Ed25519Raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse to verify a credential whose format is not bare-signature.
|
||||
///
|
||||
/// Call this before any `Verifier::verify` that uses a key from the
|
||||
/// producer registry. Returns the error a caller should surface rather
|
||||
/// than working around.
|
||||
pub fn require_bare_signature(format: KeyFormat, producer_oid: &str) -> Result<(), CryptoError> {
|
||||
if format.is_bare_signature() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CryptoError::UnsupportedCredential(format!(
|
||||
"producer {producer_oid:?} enrolled a {} credential, which signs \
|
||||
`authenticatorData || SHA-256(clientDataJSON)` rather than the message. \
|
||||
It cannot be checked as a bare signature. Verify the WebAuthn assertion \
|
||||
at the relying party -- which holds the RP ID, origin and challenge -- \
|
||||
and record the outcome; trusty does not verify assertions and will not \
|
||||
pretend to.",
|
||||
format.as_str()
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cose_credential_cannot_be_checked_as_a_bare_signature() {
|
||||
let err = require_bare_signature(KeyFormat::Cose, "user:01H")
|
||||
.expect_err("must refuse");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("clientDataJSON"),
|
||||
"the refusal must say WHY, or the next person will assume a wrong \
|
||||
key and loosen the check to make it pass: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("relying party"),
|
||||
"and it must say who should verify instead, or the reader is left \
|
||||
with a refusal and nowhere to go: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_ed25519_credential_is_allowed_through() {
|
||||
assert!(require_bare_signature(KeyFormat::Ed25519Raw, "agent:01J").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_format_refuses_rather_than_defaulting() {
|
||||
assert!(
|
||||
KeyFormat::parse("something-new").is_err(),
|
||||
"defaulting an unrecognised format to raw Ed25519 would route a \
|
||||
credential of unknown shape into the bare-signature path, which \
|
||||
is the whole failure this type prevents"
|
||||
);
|
||||
// Default is still Ed25519Raw for a row written before the
|
||||
// column existed -- but that is a schema default applied at
|
||||
// write time, never a parse fallback.
|
||||
assert_eq!(KeyFormat::default(), KeyFormat::Ed25519Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stored_strings_round_trip() {
|
||||
for f in [KeyFormat::Ed25519Raw, KeyFormat::Cose] {
|
||||
assert_eq!(KeyFormat::parse(f.as_str()).expect("round trip"), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_formats_are_distinguishable_in_storage() {
|
||||
assert_ne!(
|
||||
KeyFormat::Ed25519Raw.as_str(),
|
||||
KeyFormat::Cose.as_str(),
|
||||
"two formats sharing a stored value would make the column useless"
|
||||
);
|
||||
}
|
||||
}
|
||||
198
crates/core/trusty-core/src/crypto.rs
Normal file
198
crates/core/trusty-core/src/crypto.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
// ============================================================
|
||||
// MODULE: trusty_core::crypto
|
||||
// PURPOSE: Signer/Verifier trait surface + Ed25519 default impl.
|
||||
// Reserved for payload signatures (v2 payload.signatures field).
|
||||
// STATUS: Wired. `chain::compute_record_signed` embeds a signature (with the
|
||||
// public key inline) into `payload.signatures`, and
|
||||
// `chain::verify_links` verifies it via `verify_row_signatures`.
|
||||
// v1 payloads carry `signatures = []` by definition, so verification
|
||||
// is a no-op for them; v2 and v3 are signed and checked.
|
||||
//
|
||||
// This read "Scaffold. Chain-payload integration deferred to a later
|
||||
// session." -- true when written, no longer. Corrected because a
|
||||
// reader who trusts the header concludes signatures do not work, and
|
||||
// they are the one part of a chain that a party WITHOUT the SipHash
|
||||
// key can verify.
|
||||
//
|
||||
// DESIGN:
|
||||
// - Trait surface is byte-oriented (Vec<u8> / &[u8]) so chain
|
||||
// code can remain free of signature-crate type leakage.
|
||||
// - Ed25519Signer wraps ed25519-dalek's SigningKey. Generation
|
||||
// uses OsRng (rand crate). Deterministic reconstruction via
|
||||
// from_secret_bytes for test vectors and key-escrow flows.
|
||||
// - Ed25519Verifier is stateless (verify is a pure function of
|
||||
// pk, msg, sig bytes). Kept as a unit struct to carry the
|
||||
// Verifier trait impl.
|
||||
// ============================================================
|
||||
|
||||
use ed25519_dalek::{
|
||||
Signature as DalekSignature, Signer as DalekSigner, SigningKey, Verifier as DalekVerifier,
|
||||
VerifyingKey, PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH,
|
||||
};
|
||||
#[cfg(feature = "keygen")]
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum CryptoError {
|
||||
#[error("invalid secret key length (expected 32, got {0})")]
|
||||
InvalidSecretLen(usize),
|
||||
#[error("invalid public key: {0}")]
|
||||
InvalidPublicKey(String),
|
||||
#[error("invalid signature length (expected 64, got {0})")]
|
||||
InvalidSignatureLen(usize),
|
||||
#[error("signature verification failed")]
|
||||
VerifyFailed,
|
||||
/// The credential is well-formed but cannot be checked this way.
|
||||
///
|
||||
/// Distinct from `VerifyFailed` on purpose: a failed check invites
|
||||
/// "wrong key, try another", while this says the check itself is the
|
||||
/// wrong one. Conflating them is how a WebAuthn credential ends up
|
||||
/// being verified as a bare signature.
|
||||
#[error("unsupported credential: {0}")]
|
||||
UnsupportedCredential(String),
|
||||
}
|
||||
|
||||
pub trait Signer {
|
||||
fn sign(&self, msg: &[u8]) -> Vec<u8>;
|
||||
fn public_key_bytes(&self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
pub trait Verifier {
|
||||
fn verify(pk_bytes: &[u8], msg: &[u8], sig_bytes: &[u8]) -> Result<(), CryptoError>;
|
||||
}
|
||||
|
||||
pub struct Ed25519Signer {
|
||||
key: SigningKey,
|
||||
}
|
||||
|
||||
impl Ed25519Signer {
|
||||
/// Generate a fresh key pair from the operating-system RNG.
|
||||
///
|
||||
/// Requires the `keygen` feature (on by default). It is the only
|
||||
/// RNG-dependent item in this crate; a `default-features = false` build
|
||||
/// keeps every verification and reconstruction path and drops just this,
|
||||
/// which is what makes the crate usable on `wasm32`.
|
||||
#[cfg(feature = "keygen")]
|
||||
pub fn generate() -> Self {
|
||||
Self {
|
||||
key: SigningKey::generate(&mut OsRng),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_secret_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
|
||||
if bytes.len() != SECRET_KEY_LENGTH {
|
||||
return Err(CryptoError::InvalidSecretLen(bytes.len()));
|
||||
}
|
||||
let mut arr = [0u8; SECRET_KEY_LENGTH];
|
||||
arr.copy_from_slice(bytes);
|
||||
Ok(Self {
|
||||
key: SigningKey::from_bytes(&arr),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn secret_bytes(&self) -> [u8; SECRET_KEY_LENGTH] {
|
||||
self.key.to_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl Signer for Ed25519Signer {
|
||||
fn sign(&self, msg: &[u8]) -> Vec<u8> {
|
||||
DalekSigner::sign(&self.key, msg).to_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn public_key_bytes(&self) -> Vec<u8> {
|
||||
self.key.verifying_key().to_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ed25519Verifier;
|
||||
|
||||
impl Verifier for Ed25519Verifier {
|
||||
fn verify(pk_bytes: &[u8], msg: &[u8], sig_bytes: &[u8]) -> Result<(), CryptoError> {
|
||||
if pk_bytes.len() != PUBLIC_KEY_LENGTH {
|
||||
return Err(CryptoError::InvalidPublicKey(format!(
|
||||
"expected {} bytes, got {}",
|
||||
PUBLIC_KEY_LENGTH,
|
||||
pk_bytes.len()
|
||||
)));
|
||||
}
|
||||
if sig_bytes.len() != SIGNATURE_LENGTH {
|
||||
return Err(CryptoError::InvalidSignatureLen(sig_bytes.len()));
|
||||
}
|
||||
let mut pk_arr = [0u8; PUBLIC_KEY_LENGTH];
|
||||
pk_arr.copy_from_slice(pk_bytes);
|
||||
let vk = VerifyingKey::from_bytes(&pk_arr)
|
||||
.map_err(|e| CryptoError::InvalidPublicKey(e.to_string()))?;
|
||||
let mut sig_arr = [0u8; SIGNATURE_LENGTH];
|
||||
sig_arr.copy_from_slice(sig_bytes);
|
||||
let sig = DalekSignature::from_bytes(&sig_arr);
|
||||
vk.verify(msg, &sig).map_err(|_| CryptoError::VerifyFailed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "keygen")]
|
||||
#[test]
|
||||
fn generated_key_roundtrip() {
|
||||
let signer = Ed25519Signer::generate();
|
||||
let msg = b"trusty chain payload v2";
|
||||
let sig = signer.sign(msg);
|
||||
let pk = signer.public_key_bytes();
|
||||
assert_eq!(sig.len(), SIGNATURE_LENGTH);
|
||||
assert_eq!(pk.len(), PUBLIC_KEY_LENGTH);
|
||||
assert!(Ed25519Verifier::verify(&pk, msg, &sig).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_signature_from_fixed_seed() {
|
||||
// Ed25519 is deterministic: same key + same msg -> same signature.
|
||||
// This invariant underwrites reproducible chain verification.
|
||||
let seed = [7u8; 32];
|
||||
let signer = Ed25519Signer::from_secret_bytes(&seed).unwrap();
|
||||
assert_eq!(signer.secret_bytes(), seed);
|
||||
let msg = b"deterministic";
|
||||
let sig1 = signer.sign(msg);
|
||||
let sig2 = signer.sign(msg);
|
||||
assert_eq!(sig1, sig2);
|
||||
assert!(Ed25519Verifier::verify(&signer.public_key_bytes(), msg, &sig1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_secret_len_rejected() {
|
||||
assert!(matches!(
|
||||
Ed25519Signer::from_secret_bytes(&[0u8; 16]),
|
||||
Err(CryptoError::InvalidSecretLen(16))
|
||||
));
|
||||
assert!(matches!(
|
||||
Ed25519Signer::from_secret_bytes(&[0u8; 64]),
|
||||
Err(CryptoError::InvalidSecretLen(64))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_message_fails_verify() {
|
||||
let signer = Ed25519Signer::from_secret_bytes(&[9u8; 32]).unwrap();
|
||||
let sig = signer.sign(b"hello");
|
||||
let pk = signer.public_key_bytes();
|
||||
assert!(matches!(
|
||||
Ed25519Verifier::verify(&pk, b"hello!", &sig),
|
||||
Err(CryptoError::VerifyFailed)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "keygen")]
|
||||
#[test]
|
||||
fn malformed_signature_rejected() {
|
||||
let signer = Ed25519Signer::generate();
|
||||
let pk = signer.public_key_bytes();
|
||||
let short_sig = vec![0u8; 32];
|
||||
assert!(matches!(
|
||||
Ed25519Verifier::verify(&pk, b"x", &short_sig),
|
||||
Err(CryptoError::InvalidSignatureLen(32))
|
||||
));
|
||||
}
|
||||
}
|
||||
27
crates/core/trusty-core/src/error.rs
Normal file
27
crates/core/trusty-core/src/error.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TrustyError {
|
||||
#[error("secret key must be exactly 16 bytes, got {0}")]
|
||||
InvalidKeyLength(usize),
|
||||
|
||||
#[error("payload serialization failed: {0}")]
|
||||
PayloadSerialization(String),
|
||||
|
||||
#[error("payload is not valid JSON: {0}")]
|
||||
InvalidPayloadJson(String),
|
||||
|
||||
#[error("raw_payload missing for row at sequence {0}")]
|
||||
MissingRawPayload(i64),
|
||||
|
||||
#[error("chain id malformed: {0}")]
|
||||
MalformedChainId(String),
|
||||
|
||||
#[error("payload data too large: {size} bytes exceeds max {max}")]
|
||||
DataTooLarge { size: usize, max: usize },
|
||||
|
||||
#[error("unsupported payload_version: {0}")]
|
||||
UnsupportedPayloadVersion(u32),
|
||||
}
|
||||
|
||||
pub type TrustyResult<T> = Result<T, TrustyError>;
|
||||
253
crates/core/trusty-core/src/hash.rs
Normal file
253
crates/core/trusty-core/src/hash.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
// Dual-hash primitives.
|
||||
//
|
||||
// row_hash — SipHash-2-4 over canonical payload bytes, 16-byte secret key,
|
||||
// rendered as lowercase hex of the 64-bit digest (16 chars).
|
||||
// anchor_hash — SHA-256 over row_hash || 0x00 || entity_id || 0x00 || chain_id,
|
||||
// rendered as lowercase hex (64 chars).
|
||||
//
|
||||
// Semantics are byte-identical to the Python reference at
|
||||
// trusty_core_watermark_proxy/core/trusty/hashing/{siphash,sha256}.py.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use siphasher::sip::SipHasher24;
|
||||
use std::hash::Hasher;
|
||||
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
use crate::error::{TrustyError, TrustyResult};
|
||||
|
||||
pub const SECRET_KEY_LEN: usize = 16;
|
||||
|
||||
/// A SipHash key.
|
||||
///
|
||||
/// Wiped on drop. Key material that merely goes out of scope stays in
|
||||
/// freed memory until something reuses the page, and from there reaches
|
||||
/// a core dump, a swap file, or a hibernation image -- none of which the
|
||||
/// process controls, and all of which outlive it.
|
||||
///
|
||||
/// `ZeroizeOnDrop` rather than a hand-written `Drop`: the compiler is
|
||||
/// free to elide a plain `write` to memory nobody reads again, and the
|
||||
/// crate's whole purpose is to use the primitives that are not elided.
|
||||
///
|
||||
/// Not `Copy`, deliberately. A `Copy` key produces silent duplicates
|
||||
/// that no `Drop` ever runs for, which would make the wipe decorative.
|
||||
/// It stays `Clone`, because a store legitimately holds one, but each
|
||||
/// clone now cleans up after itself.
|
||||
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct SecretKey([u8; SECRET_KEY_LEN]);
|
||||
|
||||
impl std::fmt::Debug for SecretKey {
|
||||
/// Never print key material.
|
||||
///
|
||||
/// A derived `Debug` puts the key in any log line, panic message or
|
||||
/// error report that formats a struct containing one -- the most
|
||||
/// common way a secret escapes a process that never intended to
|
||||
/// disclose it.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("SecretKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretKey {
|
||||
pub fn new(bytes: [u8; SECRET_KEY_LEN]) -> Self {
|
||||
SecretKey(bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> TrustyResult<Self> {
|
||||
if bytes.len() != SECRET_KEY_LEN {
|
||||
return Err(TrustyError::InvalidKeyLength(bytes.len()));
|
||||
}
|
||||
let mut out = [0u8; SECRET_KEY_LEN];
|
||||
out.copy_from_slice(bytes);
|
||||
Ok(SecretKey(out))
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8; SECRET_KEY_LEN] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn k0(&self) -> u64 {
|
||||
u64::from_le_bytes(self.0[0..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn k1(&self) -> u64 {
|
||||
u64::from_le_bytes(self.0[8..16].try_into().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RowHasher {
|
||||
fn hash(&self, key: &SecretKey, data: &[u8]) -> String;
|
||||
}
|
||||
|
||||
pub trait AnchorHasher {
|
||||
fn hash(&self, row_hash: &str, entity_id: &str, chain_id: &str) -> String;
|
||||
}
|
||||
|
||||
pub struct SipHash24Hasher;
|
||||
|
||||
impl RowHasher for SipHash24Hasher {
|
||||
fn hash(&self, key: &SecretKey, data: &[u8]) -> String {
|
||||
let mut hasher = SipHasher24::new_with_keys(key.k0(), key.k1());
|
||||
hasher.write(data);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Sha256Hasher;
|
||||
|
||||
impl AnchorHasher for Sha256Hasher {
|
||||
fn hash(&self, row_hash: &str, entity_id: &str, chain_id: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(row_hash.as_bytes());
|
||||
h.update(b"\x00");
|
||||
h.update(entity_id.as_bytes());
|
||||
h.update(b"\x00");
|
||||
h.update(chain_id.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn row_hash(key: &SecretKey, data: &[u8]) -> String {
|
||||
SipHash24Hasher.hash(key, data)
|
||||
}
|
||||
|
||||
pub fn anchor_hash(row_hash: &str, entity_id: &str, chain_id: &str) -> String {
|
||||
Sha256Hasher.hash(row_hash, entity_id, chain_id)
|
||||
}
|
||||
|
||||
// v3 hashes: identical algorithms, domain-tagged inputs.
|
||||
|
||||
use crate::payload::{DOMAIN_TAG_ANCHOR_V3, DOMAIN_TAG_ROW_V3};
|
||||
|
||||
pub fn row_hash_v3(key: &SecretKey, data: &[u8]) -> String {
|
||||
let mut buf = Vec::with_capacity(DOMAIN_TAG_ROW_V3.len() + data.len());
|
||||
buf.extend_from_slice(DOMAIN_TAG_ROW_V3);
|
||||
buf.extend_from_slice(data);
|
||||
SipHash24Hasher.hash(key, &buf)
|
||||
}
|
||||
|
||||
pub fn anchor_hash_v3(row_hash: &str, entity_id: &str, chain_id: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(DOMAIN_TAG_ANCHOR_V3);
|
||||
h.update(row_hash.as_bytes());
|
||||
h.update(b"\x00");
|
||||
h.update(entity_id.as_bytes());
|
||||
h.update(b"\x00");
|
||||
h.update(chain_id.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
pub fn row_hash_for_version(version: u32, key: &SecretKey, data: &[u8]) -> TrustyResult<String> {
|
||||
match version {
|
||||
1 | 2 => Ok(row_hash(key, data)),
|
||||
3 => Ok(row_hash_v3(key, data)),
|
||||
other => Err(TrustyError::UnsupportedPayloadVersion(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anchor_hash_for_version(
|
||||
version: u32,
|
||||
row_hash_hex: &str,
|
||||
entity_id: &str,
|
||||
chain_id: &str,
|
||||
) -> TrustyResult<String> {
|
||||
match version {
|
||||
1 | 2 => Ok(anchor_hash(row_hash_hex, entity_id, chain_id)),
|
||||
3 => Ok(anchor_hash_v3(row_hash_hex, entity_id, chain_id)),
|
||||
other => Err(TrustyError::UnsupportedPayloadVersion(other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_key() -> SecretKey {
|
||||
SecretKey::from_bytes(b"0123456789abcdef").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_hash_is_16_hex_chars() {
|
||||
let h = row_hash(&test_key(), b"hello world");
|
||||
assert_eq!(h.len(), 16);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_hash_is_deterministic() {
|
||||
let k = test_key();
|
||||
assert_eq!(row_hash(&k, b"abc"), row_hash(&k, b"abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_hash_differs_with_key() {
|
||||
let k1 = SecretKey::from_bytes(b"0123456789abcdef").unwrap();
|
||||
let k2 = SecretKey::from_bytes(b"fedcba9876543210").unwrap();
|
||||
assert_ne!(row_hash(&k1, b"data"), row_hash(&k2, b"data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_hash_is_64_hex_chars() {
|
||||
let h = anchor_hash("0123456789abcdef", "acme", "chain_acme_001");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_hash_depends_on_row_hash() {
|
||||
let a = anchor_hash("0000000000000000", "acme", "chain_acme_001");
|
||||
let b = anchor_hash("1111111111111111", "acme", "chain_acme_001");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_hash_depends_on_entity() {
|
||||
let a = anchor_hash("0000000000000000", "acme", "chain_acme_001");
|
||||
let b = anchor_hash("0000000000000000", "widgets", "chain_acme_001");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_key_length_rejected() {
|
||||
assert!(matches!(
|
||||
SecretKey::from_bytes(b"short"),
|
||||
Err(TrustyError::InvalidKeyLength(5))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod secret_key_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_key_is_never_printed() {
|
||||
let k = SecretKey::new([0xAB; SECRET_KEY_LEN]);
|
||||
let shown = format!("{k:?}");
|
||||
assert!(
|
||||
!shown.contains("ab") && !shown.contains("171"),
|
||||
"a derived Debug puts key material into any log line or panic message that formats a struct containing one, which is the most common way a secret leaves a process that never meant to disclose it: {shown}"
|
||||
);
|
||||
assert_eq!(shown, "SecretKey(<redacted>)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_is_not_copy() {
|
||||
// Structural: if SecretKey were Copy, every use would leave a
|
||||
// silent duplicate that no Drop runs for, and the wipe would be
|
||||
// decorative. This asserts the property by construction -- the
|
||||
// function below only compiles for non-Copy types being moved.
|
||||
fn assert_not_copy<T: Clone>(_: &T) {}
|
||||
let k = SecretKey::new([1u8; SECRET_KEY_LEN]);
|
||||
assert_not_copy(&k);
|
||||
let moved = k;
|
||||
assert_eq!(moved.as_bytes()[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_still_clones_because_a_store_holds_one() {
|
||||
let k = SecretKey::new([7u8; SECRET_KEY_LEN]);
|
||||
let c = k.clone();
|
||||
assert_eq!(k.as_bytes(), c.as_bytes(), "cloning must preserve the key");
|
||||
}
|
||||
}
|
||||
75
crates/core/trusty-core/src/lib.rs
Normal file
75
crates/core/trusty-core/src/lib.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! Format-agnostic tamper-evidence primitives for the Trusty chain.
|
||||
//!
|
||||
//! Dual hash: SipHash-2-4 keyed fingerprint (`row_hash`) + SHA-256 cryptographic
|
||||
//! anchor (`anchor_hash`). Chain-break-as-event primitive. No storage dependency.
|
||||
//!
|
||||
//! # Design invariants (ERC002)
|
||||
//! 1. `raw_payload` is stored verbatim -- verification recomputes from it.
|
||||
//! 2. The chain is append-only.
|
||||
//! 3. `prev_hash` of row N equals `row_hash` of row N-1 on the same chain.
|
||||
//!
|
||||
//! # Payload format (v1 -- locked)
|
||||
//! Canonical JSON with sorted keys and compact separators. Fields in alphabetical
|
||||
//! order: `chain_id`, `data`, `detected_at`, `entity_id`, `extraction_method`,
|
||||
//! `hash_alg`, `payload_version`, `prev_hash`, `received_at`, `sequence`,
|
||||
//! `signatures`, `source_url`. v2 is additive only -- v1 byte shape is never
|
||||
//! altered. See `docs/payload-v2.md`.
|
||||
|
||||
#![deny(clippy::all)]
|
||||
|
||||
pub mod bundle;
|
||||
pub mod chain;
|
||||
pub mod conditions;
|
||||
pub mod credential;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod hash;
|
||||
pub mod payload;
|
||||
pub mod annotation;
|
||||
|
||||
pub use bundle::{
|
||||
Stance, Violation,
|
||||
AnchorEntry, BundleRow, ProofBundle, SourceRef, SourceRefMismatch, SourceRefReport,
|
||||
};
|
||||
pub use chain::{
|
||||
checkpoint_signing_bytes,
|
||||
compute_record,
|
||||
compute_record_signed,
|
||||
make_chain_id,
|
||||
parse_generation,
|
||||
parse_raw_payload,
|
||||
verify_links,
|
||||
verify_links_from,
|
||||
// Verification WITHOUT the operator secret key: the path available to a
|
||||
// holder of a ProofBundle. See `chain::verify_links_public`.
|
||||
verify_links_public,
|
||||
verify_links_public_from,
|
||||
verify_row_signatures,
|
||||
BreakPoint,
|
||||
ChainLink,
|
||||
EventType,
|
||||
PublicVerifyReport,
|
||||
VerifyError,
|
||||
VerifyErrorType,
|
||||
VerifyReport,
|
||||
GENESIS_SENTINEL,
|
||||
};
|
||||
pub use conditions::{
|
||||
default_conditions, evaluate, BreakReason, Condition, ConditionContext, ConditionOutcome,
|
||||
HashIntegrity, SequenceContinuity,
|
||||
};
|
||||
pub use credential::{require_bare_signature, KeyFormat};
|
||||
pub use crypto::{CryptoError, Ed25519Signer, Ed25519Verifier, Signer, Verifier};
|
||||
pub use error::{TrustyError, TrustyResult};
|
||||
pub use hash::{
|
||||
anchor_hash, row_hash, AnchorHasher, RowHasher, SecretKey, Sha256Hasher, SipHash24Hasher,
|
||||
};
|
||||
pub use payload::{
|
||||
build_payload, signing_view_bytes, PayloadFields, DOMAIN_TAG_CHECKPOINT_V1, PAYLOAD_VERSION,
|
||||
PAYLOAD_VERSION_V1, PAYLOAD_VERSION_V2,
|
||||
};
|
||||
|
||||
pub use annotation::{
|
||||
entity_id_for, verify_annotations, Annotation, AnnotationKind, AnnotationProblem,
|
||||
AnnotationReport, CustodyHop, Layer, ANNOTATION_KIND,
|
||||
};
|
||||
337
crates/core/trusty-core/src/payload.rs
Normal file
337
crates/core/trusty-core/src/payload.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
// Canonical payload serializer — PAYLOAD VERSIONS 1 and 2.
|
||||
//
|
||||
// The canonical form is JSON, sorted keys recursively, no whitespace.
|
||||
// The exact byte sequence emitted here is what row_hash (SipHash-2-4)
|
||||
// is computed over, so this file is consensus-critical: any change
|
||||
// to the v1 byte-shape is a breaking format change.
|
||||
//
|
||||
// Fields are shared across versions (same schema + ordering); only the
|
||||
// `payload_version` discriminator and the population of `signatures`
|
||||
// differ. v1 leaves `signatures` empty; v2 emits one object per signer
|
||||
// with shape `{alg, pk, sig}`, hex-encoded, keys alphabetical.
|
||||
//
|
||||
// Fields in canonical (alphabetical) order:
|
||||
// chain_id, data, detected_at, entity_id, extraction_method,
|
||||
// hash_alg, payload_version, prev_hash, received_at, sequence,
|
||||
// signatures, source_url.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::error::{TrustyError, TrustyResult};
|
||||
|
||||
/// Legacy constant preserved for existing call sites. Prefer
|
||||
/// `PAYLOAD_VERSION_V1` in new code.
|
||||
pub const PAYLOAD_VERSION: u32 = 1;
|
||||
|
||||
/// v1 payload format (signatures reserved []). Locked byte-shape.
|
||||
pub const PAYLOAD_VERSION_V1: u32 = 1;
|
||||
|
||||
/// v2 payload format (signatures populated with one `{alg,pk,sig}`
|
||||
/// per signer). Emitted only when a caller opts in via
|
||||
/// `chain::compute_record_signed`.
|
||||
pub const PAYLOAD_VERSION_V2: u32 = 2;
|
||||
|
||||
/// v3 payload format. Identical on-wire byte-shape to v1/v2 (same
|
||||
/// canonical JSON serializer, same 12 fields in alphabetical order),
|
||||
/// but hash inputs are prefixed with domain tags to eliminate
|
||||
/// cross-protocol collision risk. Dispatched by payload_version at
|
||||
/// the hash step, not at the payload step.
|
||||
pub const PAYLOAD_VERSION_V3: u32 = 3;
|
||||
|
||||
/// Domain tag prefixed to the canonical payload bytes before SipHash
|
||||
/// when payload_version == 3.
|
||||
pub const DOMAIN_TAG_ROW_V3: &[u8] = b"trusty.chain.v3\x00";
|
||||
|
||||
/// Domain tag prefixed to the SHA-256 input when payload_version == 3.
|
||||
pub const DOMAIN_TAG_ANCHOR_V3: &[u8] = b"trusty.anchor.v3\x00";
|
||||
|
||||
/// Domain tag prefixed to the canonical bytes a checkpoint attestor
|
||||
/// signs. Distinct from the chain row and anchor domains so a
|
||||
/// checkpoint signature cannot be replayed as a row or anchor
|
||||
/// signature. v1 because the checkpoint envelope is verification-
|
||||
/// layer only and lives outside the v1/v2/v3 payload byte-shape lock
|
||||
/// (memo §5: "MUST NOT change v1 byte-shape").
|
||||
pub const DOMAIN_TAG_CHECKPOINT_V1: &[u8] = b"trusty.checkpoint.v1\x00";
|
||||
|
||||
/// Hash algorithm dispatch string. Verifiers read this before
|
||||
/// recomputing row_hash so future algorithm agility is trivial.
|
||||
pub const HASH_ALG: &str = "siphash24+sha256";
|
||||
|
||||
/// Hard cap on the canonicalized `data` field (bytes). Blobs belong
|
||||
/// in object storage; put a hash + URI in `data`, not the blob
|
||||
/// itself. 64 KiB is generous for metadata and keeps parse/verify
|
||||
/// cost bounded per row.
|
||||
pub const MAX_DATA_BYTES: usize = 65_536;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PayloadFields {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub data: Value,
|
||||
pub source_url: String,
|
||||
pub detected_at: String,
|
||||
pub received_at: String,
|
||||
pub extraction_method: String,
|
||||
pub prev_hash: String,
|
||||
pub sequence: i64,
|
||||
pub hash_alg: String,
|
||||
pub payload_version: u32,
|
||||
pub signatures: Vec<Value>,
|
||||
}
|
||||
|
||||
impl PayloadFields {
|
||||
/// Construct a v1 `PayloadFields` with `hash_alg`, `payload_version`,
|
||||
/// and `signatures` set to their v1 defaults.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_v1(
|
||||
entity_id: impl Into<String>,
|
||||
chain_id: impl Into<String>,
|
||||
data: Value,
|
||||
source_url: impl Into<String>,
|
||||
detected_at: impl Into<String>,
|
||||
received_at: impl Into<String>,
|
||||
extraction_method: impl Into<String>,
|
||||
prev_hash: impl Into<String>,
|
||||
sequence: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
entity_id: entity_id.into(),
|
||||
chain_id: chain_id.into(),
|
||||
data,
|
||||
source_url: source_url.into(),
|
||||
detected_at: detected_at.into(),
|
||||
received_at: received_at.into(),
|
||||
extraction_method: extraction_method.into(),
|
||||
prev_hash: prev_hash.into(),
|
||||
sequence,
|
||||
hash_alg: HASH_ALG.to_string(),
|
||||
payload_version: PAYLOAD_VERSION,
|
||||
signatures: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a v3 `PayloadFields`. Same shape as `new_v1`; only
|
||||
/// `payload_version` differs. The hash step (not this builder)
|
||||
/// is what applies the v3 domain tag.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_v3(
|
||||
entity_id: impl Into<String>,
|
||||
chain_id: impl Into<String>,
|
||||
data: Value,
|
||||
source_url: impl Into<String>,
|
||||
detected_at: impl Into<String>,
|
||||
received_at: impl Into<String>,
|
||||
extraction_method: impl Into<String>,
|
||||
prev_hash: impl Into<String>,
|
||||
sequence: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
entity_id: entity_id.into(),
|
||||
chain_id: chain_id.into(),
|
||||
data,
|
||||
source_url: source_url.into(),
|
||||
detected_at: detected_at.into(),
|
||||
received_at: received_at.into(),
|
||||
extraction_method: extraction_method.into(),
|
||||
prev_hash: prev_hash.into(),
|
||||
sequence,
|
||||
hash_alg: HASH_ALG.to_string(),
|
||||
payload_version: PAYLOAD_VERSION_V3,
|
||||
signatures: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical bytes of a v2 payload with `signatures` forced empty --
|
||||
/// this is the "signing view" a Signer signs over. The final emitted
|
||||
/// payload replaces `signatures` with the signer output and is then
|
||||
/// re-canonicalized; row_hash commits to that final form. Verification
|
||||
/// reconstructs this view by stripping `signatures` back to `[]`.
|
||||
pub fn signing_view_bytes(fields: &PayloadFields) -> TrustyResult<String> {
|
||||
let mut view = fields.clone();
|
||||
view.signatures = Vec::new();
|
||||
build_payload(&view)
|
||||
}
|
||||
|
||||
pub fn build_payload(fields: &PayloadFields) -> TrustyResult<String> {
|
||||
let data_canonical = canonicalize(&fields.data);
|
||||
let data_bytes = serde_json::to_string(&data_canonical)
|
||||
.map_err(|e| TrustyError::PayloadSerialization(e.to_string()))?;
|
||||
if data_bytes.len() > MAX_DATA_BYTES {
|
||||
return Err(TrustyError::DataTooLarge {
|
||||
size: data_bytes.len(),
|
||||
max: MAX_DATA_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
let mut top: BTreeMap<&'static str, Value> = BTreeMap::new();
|
||||
top.insert("chain_id", Value::String(fields.chain_id.clone()));
|
||||
top.insert("data", data_canonical);
|
||||
top.insert("detected_at", Value::String(fields.detected_at.clone()));
|
||||
top.insert("entity_id", Value::String(fields.entity_id.clone()));
|
||||
top.insert(
|
||||
"extraction_method",
|
||||
Value::String(fields.extraction_method.clone()),
|
||||
);
|
||||
top.insert("hash_alg", Value::String(fields.hash_alg.clone()));
|
||||
top.insert(
|
||||
"payload_version",
|
||||
Value::Number(fields.payload_version.into()),
|
||||
);
|
||||
top.insert("prev_hash", Value::String(fields.prev_hash.clone()));
|
||||
top.insert("received_at", Value::String(fields.received_at.clone()));
|
||||
top.insert("sequence", Value::Number(fields.sequence.into()));
|
||||
// Canonicalize nested signature objects so v2 byte-shape is
|
||||
// independent of how the caller built the JSON values. v1 always
|
||||
// passes [], so this is a no-op for v1.
|
||||
top.insert(
|
||||
"signatures",
|
||||
canonicalize(&Value::Array(fields.signatures.clone())),
|
||||
);
|
||||
top.insert("source_url", Value::String(fields.source_url.clone()));
|
||||
|
||||
let mut sorted = Map::new();
|
||||
for (k, v) in top.into_iter() {
|
||||
sorted.insert(k.to_string(), v);
|
||||
}
|
||||
serde_json::to_string(&Value::Object(sorted))
|
||||
.map_err(|e| TrustyError::PayloadSerialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn canonicalize(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut sorted: BTreeMap<String, Value> = BTreeMap::new();
|
||||
for (k, v) in map.iter() {
|
||||
sorted.insert(k.clone(), canonicalize(v));
|
||||
}
|
||||
let mut out = Map::new();
|
||||
for (k, v) in sorted.into_iter() {
|
||||
out.insert(k, v);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(arr) => Value::Array(arr.iter().map(canonicalize).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample() -> PayloadFields {
|
||||
PayloadFields::new_v1(
|
||||
"acme",
|
||||
"chain_acme_000001",
|
||||
json!({"name": "widget", "count": 3}),
|
||||
"src://x",
|
||||
"2026-03-08T00:00:00Z",
|
||||
"2026-03-08T00:00:00.123456Z",
|
||||
"trusty",
|
||||
"genesis",
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_sorted_alphabetically() {
|
||||
let out = build_payload(&sample()).unwrap();
|
||||
let positions = [
|
||||
"chain_id",
|
||||
"data",
|
||||
"detected_at",
|
||||
"entity_id",
|
||||
"extraction_method",
|
||||
"hash_alg",
|
||||
"payload_version",
|
||||
"prev_hash",
|
||||
"received_at",
|
||||
"sequence",
|
||||
"signatures",
|
||||
"source_url",
|
||||
];
|
||||
let mut last: usize = 0;
|
||||
for key in positions {
|
||||
let needle = format!("\"{}\":", key);
|
||||
let idx = out
|
||||
.find(&needle)
|
||||
.unwrap_or_else(|| panic!("missing {}", key));
|
||||
assert!(idx >= last, "field {} out of canonical order", key);
|
||||
last = idx;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_whitespace_in_output() {
|
||||
let out = build_payload(&sample()).unwrap();
|
||||
assert!(!out.contains(' '));
|
||||
assert!(!out.contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_data_keys_are_sorted() {
|
||||
let fields = PayloadFields {
|
||||
data: json!({"z": 1, "a": 2, "m": 3}),
|
||||
..sample()
|
||||
};
|
||||
let out = build_payload(&fields).unwrap();
|
||||
// The data object is the only place keys "a","m","z" appear.
|
||||
let a = out.find("\"a\":").unwrap();
|
||||
let m = out.find("\"m\":").unwrap();
|
||||
let z = out.find("\"z\":").unwrap();
|
||||
assert!(a < m);
|
||||
assert!(m < z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_across_input_order() {
|
||||
let fields_a = PayloadFields {
|
||||
data: json!({"a": 1, "b": 2}),
|
||||
..sample()
|
||||
};
|
||||
let fields_b = PayloadFields {
|
||||
data: json!({"b": 2, "a": 1}),
|
||||
..sample()
|
||||
};
|
||||
assert_eq!(
|
||||
build_payload(&fields_a).unwrap(),
|
||||
build_payload(&fields_b).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_constant_fields_present() {
|
||||
let out = build_payload(&sample()).unwrap();
|
||||
assert!(out.contains("\"hash_alg\":\"siphash24+sha256\""));
|
||||
assert!(out.contains("\"payload_version\":1"));
|
||||
assert!(out.contains("\"received_at\":"));
|
||||
assert!(out.contains("\"signatures\":[]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_size_cap_enforced() {
|
||||
let huge = "x".repeat(MAX_DATA_BYTES + 1);
|
||||
let fields = PayloadFields {
|
||||
data: json!({ "blob": huge }),
|
||||
..sample()
|
||||
};
|
||||
let err = build_payload(&fields).unwrap_err();
|
||||
assert!(matches!(err, TrustyError::DataTooLarge { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_and_received_are_distinct_fields() {
|
||||
// Setting received_at far from detected_at must reach the
|
||||
// canonical bytes verbatim; this guards against code that
|
||||
// accidentally collapses the two values.
|
||||
let mut f = sample();
|
||||
f.detected_at = "2024-01-01T00:00:00Z".to_string();
|
||||
f.received_at = "2026-12-31T23:59:59.999999Z".to_string();
|
||||
let out = build_payload(&f).unwrap();
|
||||
assert!(out.contains("\"detected_at\":\"2024-01-01T00:00:00Z\""));
|
||||
assert!(out.contains("\"received_at\":\"2026-12-31T23:59:59.999999Z\""));
|
||||
}
|
||||
}
|
||||
17
crates/core/trusty-policy/Cargo.toml
Normal file
17
crates/core/trusty-policy/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "trusty-policy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Scoped FSM + policy engine. Constrains agent / tool-chain action surfaces; emits a structured audit event per evaluation. Substrate-independent (no trusty-core dep) -- consumers wire a PolicyAuditor sink (trusty for production, no-op for tests, file logger for dev)."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
171
crates/core/trusty-policy/examples/claude_session.json
Normal file
171
crates/core/trusty-policy/examples/claude_session.json
Normal file
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"transitions": [
|
||||
{
|
||||
"from": "idle",
|
||||
"action": "boot",
|
||||
"to": "booted"
|
||||
},
|
||||
{
|
||||
"from": "booted",
|
||||
"action": "present_plan",
|
||||
"to": "planned"
|
||||
},
|
||||
{
|
||||
"from": "planned",
|
||||
"action": "approved",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "read_file",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "edit_file",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "write_new_file",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "bash_safe",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "delegate_agent",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "hit_edit_limit",
|
||||
"to": "cycle_pause"
|
||||
},
|
||||
{
|
||||
"from": "cycle_pause",
|
||||
"action": "approved",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "cycle_pause",
|
||||
"action": "rollback_cycle",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "run_tests",
|
||||
"to": "testing"
|
||||
},
|
||||
{
|
||||
"from": "testing",
|
||||
"action": "tests_green",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "testing",
|
||||
"action": "tests_red",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "begin_close",
|
||||
"to": "closing"
|
||||
},
|
||||
{
|
||||
"from": "closing",
|
||||
"action": "write_delta_log",
|
||||
"to": "closing"
|
||||
},
|
||||
{
|
||||
"from": "closing",
|
||||
"action": "write_tag",
|
||||
"to": "closing"
|
||||
},
|
||||
{
|
||||
"from": "closing",
|
||||
"action": "write_handoff",
|
||||
"to": "closing"
|
||||
},
|
||||
{
|
||||
"from": "closing",
|
||||
"action": "update_session_gate",
|
||||
"to": "closing"
|
||||
},
|
||||
{
|
||||
"from": "closing",
|
||||
"action": "run_close_check",
|
||||
"to": "closing"
|
||||
},
|
||||
{
|
||||
"from": "closing",
|
||||
"action": "commit_branch",
|
||||
"to": "closed"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_destructive_op",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_main_branch_write",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_skip_hooks",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_unplanned_file",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_external_publish",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_three_errors",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_secret_in_diff",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "working",
|
||||
"action": "halt_dependency_change",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "testing",
|
||||
"action": "halt_tests_3_attempts",
|
||||
"to": "await_human"
|
||||
},
|
||||
{
|
||||
"from": "await_human",
|
||||
"action": "human_resolves",
|
||||
"to": "working"
|
||||
},
|
||||
{
|
||||
"from": "await_human",
|
||||
"action": "human_aborts",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "closed",
|
||||
"action": "next_session",
|
||||
"to": "idle"
|
||||
}
|
||||
],
|
||||
"scope": "claude.code.session.v1",
|
||||
"initial": "idle"
|
||||
}
|
||||
26
crates/core/trusty-policy/examples/pipeline.json
Normal file
26
crates/core/trusty-policy/examples/pipeline.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"transitions": [
|
||||
{
|
||||
"from": "idle",
|
||||
"action": "claim",
|
||||
"to": "claimed"
|
||||
},
|
||||
{
|
||||
"from": "claimed",
|
||||
"action": "execute",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"action": "finish",
|
||||
"to": "done"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"action": "abort",
|
||||
"to": "aborted"
|
||||
}
|
||||
],
|
||||
"scope": "agent.executor.v1",
|
||||
"initial": "idle"
|
||||
}
|
||||
2904
crates/core/trusty-policy/src/lib.rs
Normal file
2904
crates/core/trusty-policy/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
crates/core/trusty-store/Cargo.toml
Normal file
20
crates/core/trusty-store/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "trusty-store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Storage trait for Trusty backends. Defines the contract any backend (sqlite, memory, postgres, etc.) must satisfy."
|
||||
|
||||
[features]
|
||||
default = []
|
||||
testing = []
|
||||
|
||||
[dependencies]
|
||||
trusty-core = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
821
crates/core/trusty-store/src/lib.rs
Normal file
821
crates/core/trusty-store/src/lib.rs
Normal file
@@ -0,0 +1,821 @@
|
||||
//! Backend-agnostic `Store` trait for Trusty.
|
||||
//!
|
||||
//! Defines the contract any storage implementation must satisfy. Core logic in
|
||||
//! `trusty-core` is unchanged regardless of which backend is in use -- callers
|
||||
//! can swap SQLite for an in-memory store (or a future Postgres backend) without
|
||||
//! touching hashing, canonicalization, or verification.
|
||||
//!
|
||||
//! Implementations: [`trusty-memory`] (ref impl / tests), [`trusty-sqlite`] (production).
|
||||
|
||||
#![deny(clippy::all)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Trait
|
||||
// ------------------------------------------------------------
|
||||
|
||||
pub trait Store: Send {
|
||||
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>>;
|
||||
|
||||
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64>;
|
||||
|
||||
fn iter_chain<'a>(
|
||||
&'a self,
|
||||
entity_id: &str,
|
||||
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>>;
|
||||
|
||||
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>>;
|
||||
|
||||
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>>;
|
||||
|
||||
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()>;
|
||||
|
||||
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport>;
|
||||
|
||||
// Transactional scope. Closure returns `StoreResult<()>`; on Err
|
||||
// the backend rolls back. Return-value capture uses outer state.
|
||||
// We use `&mut dyn FnMut` instead of a generic closure because
|
||||
// trait-object methods cannot be generic.
|
||||
fn with_txn(&mut self, f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>)
|
||||
-> StoreResult<()>;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Types
|
||||
// ------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LastRow {
|
||||
pub row_hash: String,
|
||||
pub sequence: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TipRow {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub current_tip_hash: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredRow {
|
||||
pub id: i64,
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub event_type: String,
|
||||
pub sequence: i64,
|
||||
pub prev_hash: String,
|
||||
pub row_hash: Option<String>,
|
||||
pub anchor_hash: Option<String>,
|
||||
pub raw_payload: Option<String>,
|
||||
pub detected_at: String,
|
||||
pub received_at: String,
|
||||
pub source_url: String,
|
||||
pub extraction_method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RowInsert {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub event_type: String,
|
||||
pub sequence: i64,
|
||||
pub prev_hash: String,
|
||||
pub row_hash: Option<String>,
|
||||
pub anchor_hash: Option<String>,
|
||||
pub raw_payload: Option<String>,
|
||||
pub detected_at: String,
|
||||
pub received_at: String,
|
||||
pub source_url: String,
|
||||
pub extraction_method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TipUpsert {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub current_tip_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct KvCrossCheckReport {
|
||||
pub ok: bool,
|
||||
pub kv1_total_hashes: usize,
|
||||
pub kv2_total_entities: usize,
|
||||
pub entities_checked: usize,
|
||||
pub mismatches: Vec<String>,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Higher-level result types
|
||||
//
|
||||
// Promoted from `trusty-sqlite` (S16, resolves §12 OQ #16 /
|
||||
// `s14_insight_general_library_improvements_from_postgres_impl`
|
||||
// items 1-2). Backend-agnostic shape; every adapter constructs and
|
||||
// returns these. trusty-sqlite and trusty-postgres re-export them
|
||||
// for convenience.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/// Result of a single `append` (or `append_signed`). Encodes the
|
||||
/// chain-break-as-event primitive: when a break is detected,
|
||||
/// `chain_break = true`, `break_reason` is set, and `sealed_chain`
|
||||
/// names the chain the break sealed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppendResult {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub event_type: String,
|
||||
pub chain_break: bool,
|
||||
pub break_reason: Option<String>,
|
||||
pub sealed_chain: Option<String>,
|
||||
pub row_hash: String,
|
||||
pub anchor_hash: String,
|
||||
pub sequence: i64,
|
||||
pub is_genesis: bool,
|
||||
}
|
||||
|
||||
/// Operator-facing view of one BREAK envelope row. Surface for
|
||||
/// `breaks_since` -- treats chain-break-as-event as a query result,
|
||||
/// not an exception.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BreakEvent {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub sealed_at_hash: String,
|
||||
pub break_reason: String,
|
||||
pub detected_at: String,
|
||||
}
|
||||
|
||||
/// An entity that has not appended a non-BREAK row within
|
||||
/// `max_gap_secs`. Returned by `liveness_check`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SilentEntity {
|
||||
pub entity_id: String,
|
||||
pub last_seen: String,
|
||||
pub gap_secs: f64,
|
||||
}
|
||||
|
||||
/// One row of `history(entity_id, ...)` output.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryRow {
|
||||
pub id: i64,
|
||||
pub chain_id: String,
|
||||
pub entity_id: String,
|
||||
pub event_type: String,
|
||||
pub sequence: i64,
|
||||
pub detected_at: String,
|
||||
pub source_url: String,
|
||||
pub row_hash: Option<String>,
|
||||
pub anchor_hash: Option<String>,
|
||||
pub raw_payload: Option<String>,
|
||||
}
|
||||
|
||||
/// `integrity()` cross-check report. Aggregates KV cross-check
|
||||
/// outcomes and any envelope/index drifts into one record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IntegrityReport {
|
||||
pub ok: bool,
|
||||
pub entities_checked: usize,
|
||||
pub kv1_total_hashes: usize,
|
||||
pub kv2_total_entities: usize,
|
||||
pub mismatches: Vec<IntegrityMismatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IntegrityMismatch {
|
||||
pub entity_id: String,
|
||||
pub reason: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Borrowed input row for `Trusty::append_batch`. Cheap to construct
|
||||
/// from caller-owned data; one allocation per call, not per row.
|
||||
/// For a signed batch, use per-row `append_signed` in a loop until a
|
||||
/// signed-batch surface lands. Promoted from trusty-sqlite in S16
|
||||
/// alongside the other result types.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RowSpec<'a> {
|
||||
pub entity_id: &'a str,
|
||||
pub data: &'a serde_json::Value,
|
||||
pub source_url: &'a str,
|
||||
}
|
||||
|
||||
/// Lightweight tip handle returned by `Trusty::anchor`. Captures the
|
||||
/// most recent anchor commitment for an entity (chain_id + tip_hash)
|
||||
/// plus a server-side `anchored_at` timestamp at query time. For the
|
||||
/// full per-row anchor stream use `anchors_since`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnchorInfo {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub tip_hash: Option<String>,
|
||||
pub anchored_at: String,
|
||||
}
|
||||
|
||||
/// One row in the `producer_identity` table. OID-anchored producer
|
||||
/// identity per `s14_insight_oid_anchored_chain_identity`. The OID
|
||||
/// is the trust spine: a chain's `attested_by` references this row,
|
||||
/// and the registered `ed25519_pubkey` is the only key trusty trusts
|
||||
/// for verifying that attestation. `siphash_key` is the producer's
|
||||
/// per-row SipHash key (see hash.rs::SecretKey); `metadata` is
|
||||
/// optional caller-supplied JSON.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProducerIdentity {
|
||||
pub producer_oid: String,
|
||||
/// Key material, encoded per `key_format`.
|
||||
pub ed25519_pubkey: Vec<u8>,
|
||||
pub siphash_key: Vec<u8>,
|
||||
pub registered_at: String,
|
||||
pub display_name: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// How `ed25519_pubkey` is encoded, and therefore how a signature
|
||||
/// from it must be checked. Defaults to raw Ed25519 for a row
|
||||
/// written before the column existed -- a schema default at write
|
||||
/// time, never a parse fallback.
|
||||
#[serde(default)]
|
||||
pub key_format: trusty_core::KeyFormat,
|
||||
}
|
||||
|
||||
/// One row in the `producer_revocation` table. Forward-only
|
||||
/// revocation per memo §6 / learn-LCG-ARCH-06: rows signed BEFORE
|
||||
/// `revoked_at` remain verifiable; rows signed AT or AFTER are
|
||||
/// rejected by `verify_since_checkpoint`. `revocation_row_hash`
|
||||
/// references the REVOCATION envelope written to the per-user
|
||||
/// `revocations` chain at revoke time -- the act of revoking is
|
||||
/// itself a chained event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RevocationRecord {
|
||||
pub producer_oid: String,
|
||||
pub revoked_at: String,
|
||||
pub revoked_pubkey: Vec<u8>,
|
||||
pub reason: String,
|
||||
pub attested_by_pubkey: Vec<u8>,
|
||||
pub revocation_row_hash: String,
|
||||
}
|
||||
|
||||
/// One row in the `chain_checkpoint` table. A checkpoint is a
|
||||
/// verification-layer construct (memo §5, Gitea #13): a signed
|
||||
/// `(chain_id, sequence_int, row_hash)` tuple that lets verifiers
|
||||
/// prune their verify window to "from the most recent valid
|
||||
/// checkpoint forward". `attested_by` names the attestor (typically
|
||||
/// an OID from `producer_identity`); `attestation_sig` is Ed25519
|
||||
/// over `trusty_core::checkpoint_signing_bytes(...)`. `checkpoint_at`
|
||||
/// is the server-side timestamp at write time.
|
||||
///
|
||||
/// Chain payload bytes are NEVER touched by this primitive -- v1
|
||||
/// byte-shape lock preserved.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CheckpointRow {
|
||||
pub id: i64,
|
||||
pub chain_id: String,
|
||||
pub sequence_int: i64,
|
||||
pub row_hash: String,
|
||||
pub checkpoint_at: String,
|
||||
pub attested_by: String,
|
||||
pub attestation_sig: Vec<u8>,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Error surface
|
||||
// ------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StoreError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("integrity violation: {0}")]
|
||||
IntegrityViolation(String),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
pub type StoreResult<T> = Result<T, StoreError>;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// TrustyExt -- Tier 1 Phase B (S20 cycle 1)
|
||||
//
|
||||
// Default-method extension trait for any `T: Store`. Promotes
|
||||
// higher-level read paths (history, counts, latest hash) into a
|
||||
// substrate-shared implementation so trusty-sqlite, trusty-postgres,
|
||||
// trusty-memory, and any future backend gain them automatically by
|
||||
// implementing only the Store trait.
|
||||
//
|
||||
// Cycle-1 scope is read-only and additive: existing backend Trusty
|
||||
// types keep their direct implementations untouched. A subsequent
|
||||
// cycle migrates them to delegate to TrustyExt and removes the
|
||||
// duplication. Methods that depend on break-specific fields not yet
|
||||
// in StoredRow (breaks_since with sealed_at_hash + break_reason,
|
||||
// integrity with KV cross-check) follow in S21+ once the StoredRow
|
||||
// shape is extended OR break-specific Store methods are added.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/// Default-method extension trait for any `T: Store`. Implemented
|
||||
/// blanket-style; no consumer action required beyond using the
|
||||
/// methods.
|
||||
pub trait TrustyExt: Store {
|
||||
/// History view of an entity's chain: oldest-first, BREAK rows
|
||||
/// excluded, rows without a `row_hash` excluded. `limit` capped
|
||||
/// at `i64::MAX` when negative.
|
||||
///
|
||||
/// Mirrors the existing per-backend `history(entity_id, limit)`
|
||||
/// surface, sourced from `Store::iter_chain` so the result shape
|
||||
/// is uniform across backends.
|
||||
fn history(&self, entity_id: &str, limit: i64) -> StoreResult<Vec<HistoryRow>> {
|
||||
let cap = if limit < 0 {
|
||||
i64::MAX as usize
|
||||
} else {
|
||||
limit as usize
|
||||
};
|
||||
let mut out: Vec<HistoryRow> = Vec::new();
|
||||
for row_res in self.iter_chain(entity_id)? {
|
||||
let row = row_res?;
|
||||
if row.event_type == "BREAK" || row.row_hash.is_none() {
|
||||
continue;
|
||||
}
|
||||
out.push(stored_to_history(row));
|
||||
if out.len() >= cap {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Count of rows for an entity (BREAK rows included). Cheaper
|
||||
/// than collecting `history` when the caller only wants the
|
||||
/// length.
|
||||
fn count_rows(&self, entity_id: &str) -> StoreResult<usize> {
|
||||
let mut n: usize = 0;
|
||||
for row_res in self.iter_chain(entity_id)? {
|
||||
let _ = row_res?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Latest non-BREAK row hash for an entity. Wraps
|
||||
/// [`Store::last_row`] for the common single-field-of-interest
|
||||
/// case.
|
||||
fn latest_row_hash(&self, entity_id: &str) -> StoreResult<Option<String>> {
|
||||
Ok(self.last_row(entity_id)?.map(|r| r.row_hash))
|
||||
}
|
||||
|
||||
/// Latest non-BREAK sequence number for an entity. Useful for
|
||||
/// "how far has this chain advanced" reporting without loading
|
||||
/// full history.
|
||||
fn latest_sequence(&self, entity_id: &str) -> StoreResult<Option<i64>> {
|
||||
Ok(self.last_row(entity_id)?.map(|r| r.sequence))
|
||||
}
|
||||
|
||||
/// Count of BREAK rows for an entity. Health-check primitive:
|
||||
/// nonzero indicates the chain has been sealed at least once.
|
||||
fn count_breaks(&self, entity_id: &str) -> StoreResult<usize> {
|
||||
let mut n: usize = 0;
|
||||
for row_res in self.iter_chain(entity_id)? {
|
||||
let row = row_res?;
|
||||
if row.event_type == "BREAK" {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Whether the entity has at least one row of any kind. Cheaper
|
||||
/// than counting; short-circuits on first hit.
|
||||
fn entity_seen(&self, entity_id: &str) -> StoreResult<bool> {
|
||||
// `.next()` rather than a `for` that returns on its first pass: the loop
|
||||
// form read as an iteration and was not one, which `#![deny(clippy::all)]`
|
||||
// at the top of this file rejects as `never_loop`. Behaviour is
|
||||
// unchanged -- first row wins, and its error still propagates.
|
||||
match self.iter_chain(entity_id)?.next() {
|
||||
Some(row_res) => {
|
||||
row_res?;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Current chain id for an entity, if a tip exists. Wraps
|
||||
/// [`Store::get_tip_for_entity`] for the common id-only case.
|
||||
fn chain_id_for(&self, entity_id: &str) -> StoreResult<Option<String>> {
|
||||
Ok(self.get_tip_for_entity(entity_id)?.map(|t| t.chain_id))
|
||||
}
|
||||
|
||||
/// Current tip hash for an entity, if a tip exists and has been
|
||||
/// hashed. Wraps [`Store::get_tip_for_entity`].
|
||||
fn tip_hash(&self, entity_id: &str) -> StoreResult<Option<String>> {
|
||||
Ok(self
|
||||
.get_tip_for_entity(entity_id)?
|
||||
.and_then(|t| t.current_tip_hash))
|
||||
}
|
||||
|
||||
/// All BREAK rows for an entity, oldest-first, as raw
|
||||
/// `StoredRow`s. Consumers parse `raw_payload` for break-specific
|
||||
/// fields (sealed-at hash, reason, etc.) since those live in the
|
||||
/// JSON envelope rather than in dedicated columns of every
|
||||
/// backend.
|
||||
///
|
||||
/// Backend-portable replacement for the per-backend
|
||||
/// `breaks_since(...)` surface; richer typed views (with
|
||||
/// `BreakEvent`) stay backend-side until break-specific column
|
||||
/// access lands on the `Store` trait.
|
||||
fn iter_breaks(&self, entity_id: &str) -> StoreResult<Vec<StoredRow>> {
|
||||
let mut out: Vec<StoredRow> = Vec::new();
|
||||
for row_res in self.iter_chain(entity_id)? {
|
||||
let row = row_res?;
|
||||
if row.event_type == "BREAK" {
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most recent BREAK row for an entity, if any. Cheaper than
|
||||
/// loading the full break list when only the latest seal is of
|
||||
/// interest (e.g. health checks).
|
||||
fn latest_break(&self, entity_id: &str) -> StoreResult<Option<StoredRow>> {
|
||||
Ok(self.iter_breaks(entity_id)?.pop())
|
||||
}
|
||||
|
||||
/// Count of rows for an entity matching a given `event_type`.
|
||||
/// Useful for "how many APPEND vs BREAK vs CHECKPOINT" reporting
|
||||
/// without parsing JSON. `event_type` is case-sensitive,
|
||||
/// matching the canonical envelope vocabulary.
|
||||
fn count_events(&self, entity_id: &str, event_type: &str) -> StoreResult<usize> {
|
||||
let mut n: usize = 0;
|
||||
for row_res in self.iter_chain(entity_id)? {
|
||||
let row = row_res?;
|
||||
if row.event_type == event_type {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// All rows for an entity matching a given `event_type`,
|
||||
/// oldest-first. Generic filter -- callers asking for "EVENT" get
|
||||
/// the non-BREAK chain; "BREAK" mirrors [`Self::iter_breaks`];
|
||||
/// future event types (CHECKPOINT, REVOCATION, etc.) work the
|
||||
/// same way.
|
||||
fn iter_by_event_type(&self, entity_id: &str, event_type: &str) -> StoreResult<Vec<StoredRow>> {
|
||||
let mut out: Vec<StoredRow> = Vec::new();
|
||||
for row_res in self.iter_chain(entity_id)? {
|
||||
let row = row_res?;
|
||||
if row.event_type == event_type {
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Store + ?Sized> TrustyExt for T {}
|
||||
|
||||
fn stored_to_history(row: StoredRow) -> HistoryRow {
|
||||
HistoryRow {
|
||||
id: row.id,
|
||||
chain_id: row.chain_id,
|
||||
entity_id: row.entity_id,
|
||||
event_type: row.event_type,
|
||||
sequence: row.sequence,
|
||||
detected_at: row.detected_at,
|
||||
source_url: row.source_url,
|
||||
row_hash: row.row_hash,
|
||||
anchor_hash: row.anchor_hash,
|
||||
raw_payload: row.raw_payload,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trusty_ext_tests {
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// Minimal in-test Store impl. Mirrors trusty-memory but lives
|
||||
/// inside trusty-store to avoid a dev-dep cycle (trusty-memory
|
||||
/// depends on trusty-store).
|
||||
#[derive(Clone, Default)]
|
||||
struct TestStore {
|
||||
rows: BTreeMap<i64, StoredRow>,
|
||||
next_id: i64,
|
||||
tips_by_chain: HashMap<String, TipRow>,
|
||||
tips_by_entity: HashMap<String, TipRow>,
|
||||
}
|
||||
|
||||
impl Store for TestStore {
|
||||
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
|
||||
Ok(self
|
||||
.rows
|
||||
.values()
|
||||
.rev()
|
||||
.find(|r| {
|
||||
r.entity_id == entity_id && r.event_type != "BREAK" && r.row_hash.is_some()
|
||||
})
|
||||
.map(|r| LastRow {
|
||||
row_hash: r.row_hash.clone().unwrap(),
|
||||
sequence: r.sequence,
|
||||
}))
|
||||
}
|
||||
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
self.rows.insert(
|
||||
id,
|
||||
StoredRow {
|
||||
id,
|
||||
entity_id: row.entity_id.clone(),
|
||||
chain_id: row.chain_id.clone(),
|
||||
event_type: row.event_type.clone(),
|
||||
sequence: row.sequence,
|
||||
prev_hash: row.prev_hash.clone(),
|
||||
row_hash: row.row_hash.clone(),
|
||||
anchor_hash: row.anchor_hash.clone(),
|
||||
raw_payload: row.raw_payload.clone(),
|
||||
detected_at: row.detected_at.clone(),
|
||||
received_at: row.received_at.clone(),
|
||||
source_url: row.source_url.clone(),
|
||||
extraction_method: row.extraction_method.clone(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
fn iter_chain<'a>(
|
||||
&'a self,
|
||||
entity_id: &str,
|
||||
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
|
||||
let entity = entity_id.to_string();
|
||||
Ok(Box::new(
|
||||
self.rows
|
||||
.values()
|
||||
.filter(move |r| r.entity_id == entity)
|
||||
.cloned()
|
||||
.map(Ok),
|
||||
))
|
||||
}
|
||||
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
|
||||
Ok(self.tips_by_chain.get(chain_id).cloned())
|
||||
}
|
||||
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
|
||||
Ok(self.tips_by_entity.get(entity_id).cloned())
|
||||
}
|
||||
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
|
||||
let row = TipRow {
|
||||
entity_id: tip.entity_id.clone(),
|
||||
chain_id: tip.chain_id.clone(),
|
||||
current_tip_hash: tip.current_tip_hash.clone(),
|
||||
updated_at: String::new(),
|
||||
};
|
||||
self.tips_by_chain.insert(tip.chain_id.clone(), row.clone());
|
||||
self.tips_by_entity.insert(tip.entity_id.clone(), row);
|
||||
Ok(())
|
||||
}
|
||||
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
|
||||
Ok(KvCrossCheckReport {
|
||||
ok: true,
|
||||
kv1_total_hashes: self.rows.values().filter(|r| r.row_hash.is_some()).count(),
|
||||
kv2_total_entities: self.tips_by_entity.len(),
|
||||
entities_checked: self.tips_by_entity.len(),
|
||||
mismatches: vec![],
|
||||
})
|
||||
}
|
||||
fn with_txn(
|
||||
&mut self,
|
||||
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
|
||||
) -> StoreResult<()> {
|
||||
let snap = self.clone();
|
||||
match f(self) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
*self = snap;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(store: &mut TestStore, entity: &str, seq: i64, event: &str, hash: Option<&str>) {
|
||||
let row = RowInsert {
|
||||
entity_id: entity.into(),
|
||||
chain_id: format!("chain_{entity}_001"),
|
||||
event_type: event.into(),
|
||||
sequence: seq,
|
||||
prev_hash: "genesis".into(),
|
||||
row_hash: hash.map(str::to_string),
|
||||
anchor_hash: hash.map(str::to_string),
|
||||
raw_payload: Some(format!("{{\"seq\":{}}}", seq)),
|
||||
detected_at: format!("2026-05-09T00:00:{:02}Z", seq),
|
||||
received_at: format!("2026-05-09T00:00:{:02}.000Z", seq),
|
||||
source_url: "test://".into(),
|
||||
extraction_method: "trusty".into(),
|
||||
};
|
||||
store.insert_row(&row).unwrap();
|
||||
store
|
||||
.set_tip(&TipUpsert {
|
||||
entity_id: entity.into(),
|
||||
chain_id: row.chain_id.clone(),
|
||||
current_tip_hash: hash.map(str::to_string),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_excludes_break_rows_and_unhashed_rows() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_a", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_a", 2, "BREAK", Some("h2"));
|
||||
insert(&mut store, "ent_a", 3, "EVENT", Some("h3"));
|
||||
insert(&mut store, "ent_a", 4, "EVENT", None); // unhashed -- excluded
|
||||
|
||||
let hist = store.history("ent_a", 100).unwrap();
|
||||
assert_eq!(hist.len(), 2, "BREAK + unhashed excluded: {:?}", hist);
|
||||
assert_eq!(hist[0].sequence, 1);
|
||||
assert_eq!(hist[1].sequence, 3);
|
||||
assert!(hist.iter().all(|h| h.event_type != "BREAK"));
|
||||
assert!(hist.iter().all(|h| h.row_hash.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_respects_positive_limit() {
|
||||
let mut store = TestStore::default();
|
||||
for i in 1..=5 {
|
||||
insert(&mut store, "ent_b", i, "EVENT", Some(&format!("h{i}")));
|
||||
}
|
||||
let hist = store.history("ent_b", 3).unwrap();
|
||||
assert_eq!(hist.len(), 3);
|
||||
assert_eq!(hist[0].sequence, 1);
|
||||
assert_eq!(hist[2].sequence, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_treats_negative_limit_as_unbounded() {
|
||||
let mut store = TestStore::default();
|
||||
for i in 1..=5 {
|
||||
insert(&mut store, "ent_c", i, "EVENT", Some(&format!("h{i}")));
|
||||
}
|
||||
let hist = store.history("ent_c", -1).unwrap();
|
||||
assert_eq!(hist.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_rows_includes_break_rows() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_d", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_d", 2, "BREAK", Some("h2"));
|
||||
insert(&mut store, "ent_d", 3, "EVENT", Some("h3"));
|
||||
let n = store.count_rows("ent_d").unwrap();
|
||||
assert_eq!(n, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_row_hash_returns_last_non_break_hash() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_e", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_e", 2, "EVENT", Some("h2"));
|
||||
insert(&mut store, "ent_e", 3, "BREAK", Some("hbreak"));
|
||||
let h = store.latest_row_hash("ent_e").unwrap();
|
||||
// last_row excludes BREAK + unhashed per Store::last_row contract,
|
||||
// so we expect h2 (the latest non-BREAK).
|
||||
assert_eq!(h.as_deref(), Some("h2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_entity_returns_empty_results() {
|
||||
let store = TestStore::default();
|
||||
assert_eq!(store.history("nope", 10).unwrap().len(), 0);
|
||||
assert_eq!(store.count_rows("nope").unwrap(), 0);
|
||||
assert_eq!(store.latest_row_hash("nope").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_sequence_returns_last_non_break_seq() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_g", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_g", 2, "EVENT", Some("h2"));
|
||||
insert(&mut store, "ent_g", 3, "BREAK", Some("hb"));
|
||||
assert_eq!(store.latest_sequence("ent_g").unwrap(), Some(2));
|
||||
assert_eq!(store.latest_sequence("nope").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_breaks_counts_only_break_rows() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_h", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_h", 2, "BREAK", Some("hb"));
|
||||
insert(&mut store, "ent_h", 3, "EVENT", Some("h3"));
|
||||
insert(&mut store, "ent_h", 4, "BREAK", Some("hb2"));
|
||||
assert_eq!(store.count_breaks("ent_h").unwrap(), 2);
|
||||
assert_eq!(store.count_breaks("nope").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_seen_short_circuits() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_i", 1, "EVENT", Some("h1"));
|
||||
assert!(store.entity_seen("ent_i").unwrap());
|
||||
assert!(!store.entity_seen("nope").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_id_for_and_tip_hash_track_current_tip() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_j", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_j", 2, "EVENT", Some("h2"));
|
||||
assert_eq!(
|
||||
store.chain_id_for("ent_j").unwrap().as_deref(),
|
||||
Some("chain_ent_j_001")
|
||||
);
|
||||
assert_eq!(store.tip_hash("ent_j").unwrap().as_deref(), Some("h2"));
|
||||
assert_eq!(store.chain_id_for("nope").unwrap(), None);
|
||||
assert_eq!(store.tip_hash("nope").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_breaks_returns_only_break_rows_oldest_first() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_k", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_k", 2, "BREAK", Some("hb1"));
|
||||
insert(&mut store, "ent_k", 3, "EVENT", Some("h3"));
|
||||
insert(&mut store, "ent_k", 4, "BREAK", Some("hb2"));
|
||||
|
||||
let breaks = store.iter_breaks("ent_k").unwrap();
|
||||
assert_eq!(breaks.len(), 2);
|
||||
assert_eq!(breaks[0].sequence, 2);
|
||||
assert_eq!(breaks[1].sequence, 4);
|
||||
assert!(breaks.iter().all(|r| r.event_type == "BREAK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_break_returns_most_recent() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_l", 1, "BREAK", Some("hb1"));
|
||||
insert(&mut store, "ent_l", 2, "EVENT", Some("h2"));
|
||||
insert(&mut store, "ent_l", 3, "BREAK", Some("hb2"));
|
||||
let latest = store.latest_break("ent_l").unwrap().unwrap();
|
||||
assert_eq!(latest.sequence, 3);
|
||||
assert_eq!(latest.row_hash.as_deref(), Some("hb2"));
|
||||
|
||||
assert!(store.latest_break("nope").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_events_matches_event_type_exactly() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_m", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_m", 2, "BREAK", Some("hb"));
|
||||
insert(&mut store, "ent_m", 3, "CHECKPOINT", Some("hc"));
|
||||
insert(&mut store, "ent_m", 4, "EVENT", Some("h2"));
|
||||
|
||||
assert_eq!(store.count_events("ent_m", "EVENT").unwrap(), 2);
|
||||
assert_eq!(store.count_events("ent_m", "BREAK").unwrap(), 1);
|
||||
assert_eq!(store.count_events("ent_m", "CHECKPOINT").unwrap(), 1);
|
||||
assert_eq!(store.count_events("ent_m", "UNKNOWN").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_by_event_type_returns_filtered_rows_in_order() {
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_n", 1, "EVENT", Some("h1"));
|
||||
insert(&mut store, "ent_n", 2, "CHECKPOINT", Some("hc1"));
|
||||
insert(&mut store, "ent_n", 3, "EVENT", Some("h2"));
|
||||
insert(&mut store, "ent_n", 4, "CHECKPOINT", Some("hc2"));
|
||||
|
||||
let cps = store.iter_by_event_type("ent_n", "CHECKPOINT").unwrap();
|
||||
assert_eq!(cps.len(), 2);
|
||||
assert_eq!(cps[0].sequence, 2);
|
||||
assert_eq!(cps[1].sequence, 4);
|
||||
|
||||
let evs = store.iter_by_event_type("ent_n", "EVENT").unwrap();
|
||||
assert_eq!(evs.len(), 2);
|
||||
assert!(evs.iter().all(|r| r.event_type == "EVENT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusty_ext_is_object_safe_via_dyn_store() {
|
||||
// The whole point of TrustyExt is that it's blanket-impl'd
|
||||
// for `T: Store + ?Sized`, including `dyn Store`. Verify by
|
||||
// calling through a trait object.
|
||||
let mut store = TestStore::default();
|
||||
insert(&mut store, "ent_f", 1, "EVENT", Some("h1"));
|
||||
let dyn_store: &dyn Store = &store;
|
||||
assert_eq!(dyn_store.count_rows("ent_f").unwrap(), 1);
|
||||
assert_eq!(
|
||||
dyn_store.latest_row_hash("ent_f").unwrap().as_deref(),
|
||||
Some("h1")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
128
crates/core/trusty-store/src/testing.rs
Normal file
128
crates/core/trusty-store/src/testing.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
// Shared conformance harness for Store implementations.
|
||||
//
|
||||
// Enabled via the `testing` feature. Each backend crate depends on
|
||||
// trusty-store with features = ["testing"] in its dev-dependencies
|
||||
// and calls `run_conformance` from its own integration test.
|
||||
//
|
||||
// Adding a new Store backend? Pass its fresh, empty instance here;
|
||||
// if this function returns without panicking, the backend satisfies
|
||||
// the basic contract Trusty needs.
|
||||
|
||||
use crate::{RowInsert, Store, StoreError, StoreResult, TipUpsert};
|
||||
|
||||
pub fn run_conformance<S: Store>(store: &mut S) {
|
||||
insert_and_read_back(store);
|
||||
iter_chain_ordered(store);
|
||||
tip_roundtrip(store);
|
||||
kv_cross_check_clean(store);
|
||||
with_txn_commit(store);
|
||||
with_txn_rollback(store);
|
||||
}
|
||||
|
||||
fn mk_row(entity: &str, chain: &str, seq: i64, row_hash: &str) -> RowInsert {
|
||||
RowInsert {
|
||||
entity_id: entity.to_string(),
|
||||
chain_id: chain.to_string(),
|
||||
event_type: if seq == 0 {
|
||||
"GENESIS".into()
|
||||
} else {
|
||||
"MUTATION".into()
|
||||
},
|
||||
sequence: seq,
|
||||
prev_hash: if seq == 0 {
|
||||
"genesis".to_string()
|
||||
} else {
|
||||
format!("prev_{}", seq - 1)
|
||||
},
|
||||
row_hash: Some(row_hash.to_string()),
|
||||
anchor_hash: Some("a".repeat(64)),
|
||||
raw_payload: Some(format!("{{\"v\":{}}}", seq)),
|
||||
detected_at: "2026-04-15T00:00:00Z".to_string(),
|
||||
received_at: "2026-04-15T00:00:00.001000Z".to_string(),
|
||||
source_url: "s://test".to_string(),
|
||||
extraction_method: "trusty".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_and_read_back<S: Store>(store: &mut S) {
|
||||
let row = mk_row("ent_conf_a", "chain_conf_a", 0, "000000000000000a");
|
||||
store.insert_row(&row).expect("insert ok");
|
||||
let last = store
|
||||
.last_row("ent_conf_a")
|
||||
.expect("last ok")
|
||||
.expect("some");
|
||||
assert_eq!(last.row_hash, "000000000000000a");
|
||||
assert_eq!(last.sequence, 0);
|
||||
}
|
||||
|
||||
fn iter_chain_ordered<S: Store>(store: &mut S) {
|
||||
for i in 0..3i64 {
|
||||
let h = format!("{:016x}", 0xB00_u64 + i as u64);
|
||||
let row = mk_row("ent_conf_b", "chain_conf_b", i, &h);
|
||||
store.insert_row(&row).expect("insert ok");
|
||||
}
|
||||
let rows: Vec<_> = store
|
||||
.iter_chain("ent_conf_b")
|
||||
.expect("iter ok")
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("collect ok");
|
||||
assert_eq!(rows.len(), 3, "expected 3 rows, got {:?}", rows.len());
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
assert_eq!(row.sequence, i as i64, "out of order at index {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
fn tip_roundtrip<S: Store>(store: &mut S) {
|
||||
let row = mk_row("ent_conf_c", "chain_conf_c", 0, "cafebabecafebabe");
|
||||
store.insert_row(&row).expect("insert for tip roundtrip");
|
||||
let tip = TipUpsert {
|
||||
entity_id: "ent_conf_c".to_string(),
|
||||
chain_id: "chain_conf_c".to_string(),
|
||||
current_tip_hash: Some("cafebabecafebabe".to_string()),
|
||||
};
|
||||
store.set_tip(&tip).expect("set tip");
|
||||
let got = store
|
||||
.get_tip("chain_conf_c")
|
||||
.expect("get_tip ok")
|
||||
.expect("some");
|
||||
assert_eq!(got.current_tip_hash.as_deref(), Some("cafebabecafebabe"));
|
||||
let got_ent = store
|
||||
.get_tip_for_entity("ent_conf_c")
|
||||
.expect("get_tip_for_entity ok")
|
||||
.expect("some");
|
||||
assert_eq!(got_ent.chain_id, "chain_conf_c");
|
||||
}
|
||||
|
||||
fn kv_cross_check_clean<S: Store>(store: &mut S) {
|
||||
let report = store.kv_cross_check().expect("cross check ok");
|
||||
assert!(report.ok, "expected clean store ok: {:?}", report);
|
||||
}
|
||||
|
||||
fn with_txn_commit<S: Store>(store: &mut S) {
|
||||
store
|
||||
.with_txn(&mut |s: &mut dyn Store| {
|
||||
let row = mk_row("ent_conf_d", "chain_conf_d", 0, "d000000000000001");
|
||||
s.insert_row(&row)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("txn commit ok");
|
||||
let last = store
|
||||
.last_row("ent_conf_d")
|
||||
.expect("last ok")
|
||||
.expect("committed row visible");
|
||||
assert_eq!(last.row_hash, "d000000000000001");
|
||||
}
|
||||
|
||||
fn with_txn_rollback<S: Store>(store: &mut S) {
|
||||
let result = store.with_txn(&mut |s: &mut dyn Store| {
|
||||
let row = mk_row("ent_conf_e", "chain_conf_e", 0, "e000000000000001");
|
||||
s.insert_row(&row)?;
|
||||
Err(StoreError::Backend("rollback test".into()))
|
||||
});
|
||||
assert!(result.is_err(), "closure returning Err must propagate");
|
||||
let last: StoreResult<Option<_>> = store.last_row("ent_conf_e");
|
||||
assert!(
|
||||
last.expect("last ok").is_none(),
|
||||
"rolled-back row must not be visible after txn rollback"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user