Trusty: provenance library workspace (initial import)
This commit is contained in:
569
crates/adapters/trusty-sqlite/tests/annotation.rs
Normal file
569
crates/adapters/trusty-sqlite/tests/annotation.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
//! Custody annotations across real, separately-owned chains.
|
||||
//!
|
||||
//! Each node here is a genuinely separate store with its own signing key
|
||||
//! and its own chain. Nothing writes to anybody else's, which is the
|
||||
//! property the design rests on: a node cannot forge an annotation it did not
|
||||
//! perform, because it can only sign its own.
|
||||
|
||||
use trusty_core::{
|
||||
Layer,
|
||||
entity_id_for, verify_annotations, Ed25519Signer, Annotation, SecretKey, SourceRef,
|
||||
AnnotationProblem,
|
||||
};
|
||||
use trusty_sqlite::Trusty;
|
||||
|
||||
const TAG: &str = "sha256:d41d8cd9";
|
||||
|
||||
/// A node: its own store, its own key, its own chain.
|
||||
struct Node {
|
||||
name: String,
|
||||
store: Trusty,
|
||||
signer: Ed25519Signer,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
fn new(name: &str, seed: u8) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
store: Trusty::open_in_memory(SecretKey::new([seed; 16])).expect("store"),
|
||||
signer: Ed25519Signer::generate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that this node holds the data, and say where it came from.
|
||||
/// Returns the reference a downstream node will cite.
|
||||
fn record(&mut self, from: Option<SourceRef>, note: &str) -> SourceRef {
|
||||
let mut annotation = Annotation::custody(TAG, &self.name).because(note);
|
||||
annotation.received_from = from;
|
||||
let entity = entity_id_for(TAG, &self.name);
|
||||
let appended = self
|
||||
.store
|
||||
.append_signed(
|
||||
&entity,
|
||||
&annotation.to_data(),
|
||||
"custody://test",
|
||||
&self.signer,
|
||||
)
|
||||
.expect("append");
|
||||
let bundle = self.store.export_bundle(&entity, None, None).expect("export");
|
||||
let row = bundle
|
||||
.rows
|
||||
.iter()
|
||||
.find(|r| r.row_hash.as_deref() == Some(appended.row_hash.as_str()))
|
||||
.expect("the row just written must be in the bundle");
|
||||
SourceRef {
|
||||
chain_id: row.chain_id.clone(),
|
||||
sequence: row.sequence,
|
||||
row_hash: appended.row_hash.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bundle(&self) -> trusty_core::ProofBundle {
|
||||
self.store
|
||||
.export_bundle(&entity_id_for(TAG, &self.name), None, None)
|
||||
.expect("export")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_across_three_nodes_reads_in_order() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
let mut sink = Node::new("sink", 3);
|
||||
|
||||
let a = origin.record(None, "produced here");
|
||||
let b = relay.record(Some(a), "forwarded");
|
||||
sink.record(Some(b), "delivered");
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), relay.bundle(), sink.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report.intact(),
|
||||
"three honest nodes must produce a clean path; problems: {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert_eq!(
|
||||
report.custody_line(),
|
||||
vec!["origin", "relay", "sink"],
|
||||
"the order is reconstructed from the links, not from the order the \
|
||||
bundles happened to be passed in"
|
||||
);
|
||||
assert_eq!(report.found, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_order_does_not_depend_on_the_order_bundles_arrive_in() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
let mut sink = Node::new("sink", 3);
|
||||
let a = origin.record(None, "produced here");
|
||||
let b = relay.record(Some(a), "forwarded");
|
||||
sink.record(Some(b), "delivered");
|
||||
|
||||
let shuffled = verify_annotations(&[sink.bundle(), origin.bundle(), relay.bundle()], TAG);
|
||||
|
||||
assert_eq!(
|
||||
shuffled.custody_line(),
|
||||
vec!["origin", "relay", "sink"],
|
||||
"a merge that depended on argument order would be reporting the \
|
||||
caller's filing habits rather than the history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_middle_node_is_reported_not_papered_over() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
let mut sink = Node::new("sink", 3);
|
||||
|
||||
let a = origin.record(None, "produced here");
|
||||
let b = relay.record(Some(a), "forwarded");
|
||||
sink.record(Some(b), "delivered");
|
||||
|
||||
// The relay's bundle is withheld -- exactly what a relay that would
|
||||
// rather not be in the record would do.
|
||||
let report = verify_annotations(&[origin.bundle(), sink.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
!report.intact(),
|
||||
"a gap must not read as a clean history; problems: {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.problems
|
||||
.iter()
|
||||
.any(|p| matches!(p, AnnotationProblem::DanglingPredecessor { node, .. } if node == "sink")),
|
||||
"the sink cites a row nobody can produce, and that is the finding; \
|
||||
got {:?}",
|
||||
report.problems
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hop_citing_a_row_that_was_altered_does_not_link() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
|
||||
let a = origin.record(None, "produced here");
|
||||
// The relay cites the right position but the wrong content -- what a
|
||||
// forged provenance claim looks like when the citation is guessed.
|
||||
relay.record(
|
||||
Some(SourceRef {
|
||||
row_hash: "00".repeat(32),
|
||||
..a
|
||||
}),
|
||||
"claims to have had it from origin",
|
||||
);
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report
|
||||
.problems
|
||||
.iter()
|
||||
.any(|p| matches!(p, AnnotationProblem::DanglingPredecessor { .. })),
|
||||
"naming a real position with the wrong hash must not link; the hash \
|
||||
is what makes the citation a citation; got {:?}",
|
||||
report.problems
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_nodes_receiving_from_the_same_hop_is_reported_as_a_fork() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut left = Node::new("left", 2);
|
||||
let mut right = Node::new("right", 3);
|
||||
|
||||
let a = origin.record(None, "produced here");
|
||||
left.record(Some(a.clone()), "one copy");
|
||||
right.record(Some(a), "another copy");
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), left.bundle(), right.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report
|
||||
.problems
|
||||
.iter()
|
||||
.any(|p| matches!(p, AnnotationProblem::Fork { .. })),
|
||||
"fanning out is legitimate, but a caller expecting a line must be \
|
||||
told it got a tree; got {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert!(
|
||||
!report.intact(),
|
||||
"intact() is the strict reading; a caller who accepts forks reads \
|
||||
problems and decides"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_claimed_origins_are_reported() {
|
||||
let mut one = Node::new("one", 1);
|
||||
let mut two = Node::new("two", 2);
|
||||
one.record(None, "I made this");
|
||||
two.record(None, "no, I made this");
|
||||
|
||||
let report = verify_annotations(&[one.bundle(), two.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report
|
||||
.problems
|
||||
.iter()
|
||||
.any(|p| matches!(p, AnnotationProblem::MultipleOrigins { .. })),
|
||||
"the same data cannot originate twice, and which claim is false is \
|
||||
not something the log can settle -- but it can refuse to pick; got {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert!(report.hops.is_empty(), "with no single origin there is no order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traffic_about_other_data_is_ignored() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
|
||||
// The relay's chain also carries an annotation about something else.
|
||||
let other = Annotation::custody("sha256:something-else", &relay.name);
|
||||
relay
|
||||
.store
|
||||
.append_signed(
|
||||
&entity_id_for(TAG, &relay.name),
|
||||
&other.to_data(),
|
||||
"custody://test",
|
||||
&relay.signer,
|
||||
)
|
||||
.expect("append other");
|
||||
|
||||
let a = origin.record(None, "produced here");
|
||||
relay.record(Some(a), "forwarded");
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report.intact(),
|
||||
"a bundle may carry unrelated traffic; problems: {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert_eq!(
|
||||
report.found, 2,
|
||||
"only annotations about the tag asked for count, or a busy node would \
|
||||
appear to have handled everything"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_recorded_is_not_a_clean_history() {
|
||||
let origin = Node::new("origin", 1);
|
||||
let report = verify_annotations(&[origin.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
!report.intact(),
|
||||
"no records at all must not read as an unbroken chain of custody; \
|
||||
the difference matters most exactly when someone is asking"
|
||||
);
|
||||
assert_eq!(report.found, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_bundle_takes_no_part_in_the_path() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
let a = origin.record(None, "produced here");
|
||||
relay.record(Some(a), "forwarded");
|
||||
|
||||
// Rewrite what the relay claims it did, after export. The signature
|
||||
// no longer covers it.
|
||||
let mut forged = relay.bundle();
|
||||
for row in forged.rows.iter_mut() {
|
||||
if let Some(payload) = row.raw_payload.as_mut() {
|
||||
*payload = payload.replace("forwarded", "never touched it");
|
||||
}
|
||||
}
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), forged], TAG);
|
||||
|
||||
assert!(
|
||||
report
|
||||
.problems
|
||||
.iter()
|
||||
.any(|p| matches!(p, AnnotationProblem::BundleUnverified { .. })),
|
||||
"a bundle whose rows no longer match their signatures must be \
|
||||
reported as such; got {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert_eq!(
|
||||
report.found, 1,
|
||||
"and its rows must not contribute hops -- a tidy custody report \
|
||||
built over forged rows is worse than no report at all"
|
||||
);
|
||||
assert!(!report.intact());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_honest_bundle_beside_a_forged_one_still_counts() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
let a = origin.record(None, "produced here");
|
||||
relay.record(Some(a), "forwarded");
|
||||
|
||||
let mut forged = relay.bundle();
|
||||
for row in forged.rows.iter_mut() {
|
||||
if let Some(payload) = row.raw_payload.as_mut() {
|
||||
*payload = payload.replace("forwarded", "never touched it");
|
||||
}
|
||||
}
|
||||
let report = verify_annotations(&[origin.bundle(), forged], TAG);
|
||||
|
||||
// The origin is untouched and still says what it said. One party
|
||||
// lying must not erase another party's honest record.
|
||||
assert_eq!(
|
||||
report.custody_line(),
|
||||
vec!["origin"],
|
||||
"the surviving path is what the verified bundles support, no more \
|
||||
and no less; got {:?}",
|
||||
report.custody_line()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Kinds beyond custody.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
impl Node {
|
||||
/// Record that this node ran a check, and what it concluded.
|
||||
fn checked(&mut self, ok: bool, note: &str) {
|
||||
let annotation = Annotation::integrity(TAG, &self.name, ok)
|
||||
.at_layer(Layer::Device)
|
||||
.because(note);
|
||||
self.store
|
||||
.append_signed(
|
||||
&entity_id_for(TAG, &self.name),
|
||||
&annotation.to_data(),
|
||||
"custody://test",
|
||||
&self.signer,
|
||||
)
|
||||
.expect("append");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_check_becomes_an_actionable_marker() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
|
||||
let a = origin.record(None, "produced here");
|
||||
relay.record(Some(a), "forwarded");
|
||||
relay.checked(false, "checksum mismatch on receipt");
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
|
||||
|
||||
assert_eq!(
|
||||
report.failed_checks().len(),
|
||||
1,
|
||||
"a node that ran a check and found a problem must surface it \
|
||||
separately from structural findings, so a peer can decide to \
|
||||
refuse, quarantine or inspect; got {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert!(
|
||||
!report.intact(),
|
||||
"a datum somebody found wrong must not read as clean"
|
||||
);
|
||||
assert_eq!(
|
||||
report.custody_line(),
|
||||
vec!["origin", "relay"],
|
||||
"the custody path is still readable; a failed check is a finding \
|
||||
about the datum, not a reason to lose the history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_passed_check_is_recorded_without_becoming_a_problem() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
let a = origin.record(None, "produced here");
|
||||
relay.record(Some(a), "forwarded");
|
||||
relay.checked(true, "checksum matched");
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report.intact(),
|
||||
"a check that passed is evidence in favour, not a finding; \
|
||||
problems: {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert_eq!(
|
||||
report.found, 3,
|
||||
"the passing check must still be counted -- knowing a check RAN is \
|
||||
the difference between verified and merely unexamined"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custody_claim_is_not_read_as_a_check_that_passed() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
origin.record(None, "produced here");
|
||||
|
||||
let report = verify_annotations(&[origin.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report.failed_checks().is_empty(),
|
||||
"holding data is not a claim about its correctness"
|
||||
);
|
||||
assert_eq!(
|
||||
report.found, 1,
|
||||
"and it is still an annotation, counted as one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_annotation_of_an_unknown_kind_is_reported_not_ignored() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
origin.record(None, "produced here");
|
||||
|
||||
let mut odd = Annotation::custody(TAG, &origin.name);
|
||||
odd.kind = trusty_core::AnnotationKind::Other("future.kind.v9".into());
|
||||
origin
|
||||
.store
|
||||
.append_signed(
|
||||
&entity_id_for(TAG, &origin.name),
|
||||
&odd.to_data(),
|
||||
"custody://test",
|
||||
&origin.signer,
|
||||
)
|
||||
.expect("append");
|
||||
|
||||
let report = verify_annotations(&[origin.bundle()], TAG);
|
||||
|
||||
assert!(
|
||||
report.problems.iter().any(|p| matches!(
|
||||
p,
|
||||
AnnotationProblem::UnrecognisedKind { kind, .. } if kind == "future.kind.v9"
|
||||
)),
|
||||
"silence would let a future kind carrying a failed check pass an old \
|
||||
verifier unnoticed; got {:?}",
|
||||
report.problems
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forged_bundle_cannot_erase_a_failed_check() {
|
||||
let mut origin = Node::new("origin", 1);
|
||||
let mut relay = Node::new("relay", 2);
|
||||
let a = origin.record(None, "produced here");
|
||||
relay.record(Some(a), "forwarded");
|
||||
relay.checked(false, "checksum mismatch on receipt");
|
||||
|
||||
// The relay rewrites its own bundle to say the check passed.
|
||||
let mut forged = relay.bundle();
|
||||
for row in forged.rows.iter_mut() {
|
||||
if let Some(payload) = row.raw_payload.as_mut() {
|
||||
*payload = payload.replace("\"satisfied\":false", "\"satisfied\":true");
|
||||
}
|
||||
}
|
||||
|
||||
let report = verify_annotations(&[origin.bundle(), forged], TAG);
|
||||
|
||||
assert!(
|
||||
report.problems.iter().any(|p| matches!(
|
||||
p,
|
||||
AnnotationProblem::BundleUnverified { .. }
|
||||
)),
|
||||
"editing a finding out of your own bundle must break its signature, \
|
||||
not quietly succeed; got {:?}",
|
||||
report.problems
|
||||
);
|
||||
assert!(
|
||||
!report.intact(),
|
||||
"and the result must not read as clean just because the evidence was \
|
||||
removed"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Credential formats. A WebAuthn credential must never be checked as a
|
||||
// bare signature.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_cose_producer_is_refused_by_the_bare_signature_path() {
|
||||
use trusty_core::KeyFormat;
|
||||
|
||||
let mut trusty = Trusty::open_in_memory(SecretKey::new([4u8; 16])).expect("store");
|
||||
let signer = Ed25519Signer::generate();
|
||||
trusty
|
||||
.append_signed("doc", &serde_json::json!({ "v": 1 }), "t://", &signer)
|
||||
.expect("append");
|
||||
|
||||
// A user enrolled through WebAuthn: the credential is a COSE key,
|
||||
// and its signatures cover authenticatorData ||
|
||||
// SHA-256(clientDataJSON) rather than the message.
|
||||
trusty
|
||||
.register_producer_with_format("user:01H", &[0u8; 32], &[0u8; 16], None, KeyFormat::Cose)
|
||||
.expect("enrol");
|
||||
|
||||
let err = trusty
|
||||
.verify_with_producer("doc", "user:01H")
|
||||
.expect_err("must refuse");
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(
|
||||
msg.contains("clientDataJSON"),
|
||||
"the refusal must explain WHY, or the next person reads it as a wrong key and loosens the check until it passes: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!msg.contains("verification failed"),
|
||||
"and it must not look like a failed verification -- that confusion is what the change exists to remove: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_producer_is_still_verified_normally() {
|
||||
use trusty_core::KeyFormat;
|
||||
|
||||
let mut trusty = Trusty::open_in_memory(SecretKey::new([6u8; 16])).expect("store");
|
||||
let signer = Ed25519Signer::generate();
|
||||
trusty
|
||||
.append_signed("doc", &serde_json::json!({ "v": 1 }), "t://", &signer)
|
||||
.expect("append");
|
||||
trusty
|
||||
.register_producer_with_format(
|
||||
"agent:01J",
|
||||
&[0u8; 32],
|
||||
&[0u8; 16],
|
||||
None,
|
||||
KeyFormat::Ed25519Raw,
|
||||
)
|
||||
.expect("enrol");
|
||||
|
||||
// May or may not verify -- the key is a placeholder. What matters is
|
||||
// that it is not REFUSED: the raw path must be untouched, or this
|
||||
// change would have broken every existing producer.
|
||||
match trusty.verify_with_producer("doc", "agent:01J") {
|
||||
Ok(_) => {}
|
||||
Err(e) => assert!(
|
||||
!e.to_string().contains("unsupported credential"),
|
||||
"a raw Ed25519 producer must reach the signature check, not be turned away before it: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_producer_enrolled_before_the_column_existed_reads_as_raw() {
|
||||
let mut trusty = Trusty::open_in_memory(SecretKey::new([5u8; 16])).expect("store");
|
||||
trusty
|
||||
.register_producer("agent:old", &[0u8; 32], &[0u8; 16], None)
|
||||
.expect("enrol");
|
||||
|
||||
let found = trusty
|
||||
.lookup_producer("agent:old")
|
||||
.expect("lookup")
|
||||
.expect("row");
|
||||
assert_eq!(
|
||||
found.key_format,
|
||||
trusty_core::KeyFormat::Ed25519Raw,
|
||||
"every credential enrolled before the column existed WAS a raw Ed25519 key, so the schema default is correct -- applied at write time, never as a parse fallback"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user