//! 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, 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" ); }