diff --git a/dsm_client/deterministic_state_machine/dsm/src/recovery/succession_binding.rs b/dsm_client/deterministic_state_machine/dsm/src/recovery/succession_binding.rs index af86cddc0..55a5ba18e 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/recovery/succession_binding.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/recovery/succession_binding.rs @@ -12,9 +12,10 @@ //! Authentication split (REUSE, do not reinvent): //! - The new `(A_new,C)` establishment receipt is a normal **bilateral** stitched //! receipt (co-signed by A_new — alive — and C — syncing), authenticated by -//! [`crate::verification::receipt_verification::verify_stitched_receipt`] -//! (both sigs + EK-cert chains + inclusion + parent adjacency + uniqueness). That is -//! the integration layer's job (it supplies `ReceiptVerificationContext`). +//! the offline bilateral decisions ([`crate::bilateral::offline::decide_confirm`], +//! [`crate::bilateral::offline::decide_commit_ack`]: signatures, EK-cert chains, +//! the state rules and the held relationship tip). That is the integration +//! layer's job. //! - This module verifies the **recovery-specific overlay**: device-pair `rel_key` //! derivation, the tombstone/succession successor proof, the old-chain //! forward-ancestry `h^cap ⟶* T_old_current`, the carry-forward commitment, the @@ -454,8 +455,8 @@ impl CrossRelationshipSuccessionEvidence { /// posted (genesis-authenticated) root. Returns the verified new tip. /// /// NOTE: bilateral authentication of the new establishment receipt itself - /// (signatures + EK-cert chains + adjacency) is performed by - /// `verify_stitched_receipt` at the integration layer; this method assumes the + /// (signatures + EK-cert chains + the held tip) is performed by the offline + /// bilateral decisions at the integration layer; this method assumes the /// receipt's tips are the values verified there. pub fn verify(&self, recovery_authority_pubkey: &[u8]) -> Result<[u8; 32], DsmError> { self.verify_succession_semantics(recovery_authority_pubkey)?; diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/types/mod.rs index 31b1527f2..036979251 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/mod.rs @@ -41,9 +41,7 @@ pub mod unified_error; pub use contact_types::DsmVerifiedContact; pub use identifiers::{Entropy, GenesisHash, NodeId, SessionId, Signature, TransactionId, VaultId}; // New type-safe identifiers pub use policy_types::{PolicyAnchor, PolicyFile, TokenPolicy}; -pub use receipt_types::{ - ParentConsumptionTracker, ReceiptAcceptance, ReceiptVerificationContext, StitchedReceiptV2, -}; +pub use receipt_types::StitchedReceiptV2; // `pub use state_types::State` removed: no consumer imports via this short // path. All remaining consumers spell out `types::state_types::State`. // The State struct is being decomposed into DeviceState + RelationshipChainState diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/receipt_types.rs b/dsm_client/deterministic_state_machine/dsm/src/types/receipt_types.rs index ade1e76be..094a1954d 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/receipt_types.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/receipt_types.rs @@ -16,7 +16,6 @@ use crate::common::domain_tags::TAG_RECEIPT_COMMIT; use crate::types::error::DsmError; -use std::collections::HashMap; /// Canonical Stitched Receipt V2 /// @@ -975,172 +974,6 @@ impl From<[u8; 32]> for DeviceTreeAcceptanceCommitment { } } -pub struct ReceiptVerificationContext { - /// Authenticated local commitment used to validate `π_dev: DevID ∈ R_G`. - pub device_tree_commitment: DeviceTreeAcceptanceCommitment, - - /// The sender's pre-state root this verifier expects the step to start - /// from; a receipt over any other root is not this step. - pub expected_parent_root: [u8; 32], - - /// SPHINCS+ public key for party A (the per-step EK_pk for this receipt). - pub pubkey_a: Vec, - - /// SPHINCS+ public key for party B (counterparty's per-step EK_pk). - pub pubkey_b: Vec, - - /// Per-relationship cert chain head for party A. - /// - /// This is the SPHINCS+ public key that authorized the current `pubkey_a` - /// via the ek-cert chain (whitepaper §11.1): - /// - At step n=0: AK_pk (the device-attested long-term key). - /// - At step n>0: the previous step's `EK_pk_n` (which signed cert_{n+1}). - /// - /// `Some(pk)` means the receipt must carry a valid `ek_cert_a` that verifies - /// against `pk`. `None` is fail-closed for receipt acceptance because - /// parent/root inclusion alone is not spend authority. - pub chain_head_pubkey_a: Option>, - - /// Per-relationship cert chain head for party B. - /// Same semantics as `chain_head_pubkey_a`. - pub chain_head_pubkey_b: Option>, - - /// The genesis the receipt's author (`devid_a`) is pinned under. - pub author_genesis: [u8; 32], - - /// The operation the step carries: it decides which leaves the step - /// writes, and the child tip is recomputed from it. - pub operation: crate::types::operations::Operation, - - /// For the author's own offline-bearer spend, its anchor-state leaves. - pub bearer: Option, -} - -impl ReceiptVerificationContext { - pub fn new>( - device_tree_commitment: T, - expected_parent_root: [u8; 32], - pubkey_a: Vec, - pubkey_b: Vec, - author_genesis: [u8; 32], - operation: crate::types::operations::Operation, - ) -> Self { - Self { - device_tree_commitment: device_tree_commitment.into(), - expected_parent_root, - pubkey_a, - pubkey_b, - chain_head_pubkey_a: None, - chain_head_pubkey_b: None, - author_genesis, - operation, - bearer: None, - } - } - - /// Builder: set the cert chain head for party A. - /// Once set, the receipt MUST carry a valid `ek_cert_a` (whitepaper §11.1). - pub fn with_chain_head_a(mut self, pubkey: Vec) -> Self { - self.chain_head_pubkey_a = Some(pubkey); - self - } - - /// Builder: set the cert chain head for party B. - pub fn with_chain_head_b(mut self, pubkey: Vec) -> Self { - self.chain_head_pubkey_b = Some(pubkey); - self - } -} - -/// Receipt acceptance result -#[derive(Debug, Clone)] -pub struct ReceiptAcceptance { - /// Whether the receipt is valid - pub valid: bool, - - /// Detailed reason if invalid - pub reason: Option, - - /// Computed commitment hash - pub commitment: Option<[u8; 32]>, -} - -impl ReceiptAcceptance { - pub fn accept(commitment: [u8; 32]) -> Self { - Self { - valid: true, - reason: None, - commitment: Some(commitment), - } - } - - pub fn reject(reason: impl Into) -> Self { - Self { - valid: false, - reason: Some(reason.into()), - commitment: None, - } - } -} - -/// Parent consumption tracker -/// -/// Tracks which parent tips have been consumed to enforce uniqueness -/// and detect fork attempts. -#[derive(Default)] -pub struct ParentConsumptionTracker { - /// Map: parent_tip -> child_tip - consumed: HashMap<[u8; 32], [u8; 32]>, -} - -impl ParentConsumptionTracker { - pub fn new() -> Self { - Self::default() - } - - pub fn with_capacity(_capacity: usize) -> Self { - Self::new() - } - - /// Try to consume a parent tip - /// - /// Returns Ok(()) if parent is fresh, Err if already consumed. - pub fn try_consume( - &mut self, - parent_tip: [u8; 32], - child_tip: [u8; 32], - ) -> Result<(), DsmError> { - if let Some(existing_child) = self.consumed.get(&parent_tip) { - if existing_child == &child_tip { - // Idempotent: same transition attempted twice (replay) - return Err(DsmError::InvalidOperation( - "Parent already consumed (replay detected)".to_string(), - )); - } else { - // Fork: different children for same parent - return Err(DsmError::InvalidOperation(format!( - "Fork detected: parent {:?} has conflicting children", - &parent_tip[..8] - ))); - } - } - - // Fresh parent: mark as consumed - self.consumed.insert(parent_tip, child_tip); - Ok(()) - } - - /// Check if parent is consumed (read-only) - pub fn is_consumed(&self, parent_tip: &[u8; 32]) -> bool { - self.consumed.contains_key(parent_tip) - } - - /// Get the child for a consumed parent (if any) - pub fn get_child(&self, parent_tip: &[u8; 32]) -> Option<&[u8; 32]> { - self.consumed.get(parent_tip) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1521,25 +1354,6 @@ mod tests { assert_eq!(copied, c); } - // --- ReceiptAcceptance --- - - #[test] - fn receipt_acceptance_accept() { - let commitment = world().transfer.receipt.compute_commitment().unwrap(); - let acc = ReceiptAcceptance::accept(commitment); - assert!(acc.valid); - assert!(acc.reason.is_none()); - assert_eq!(acc.commitment, Some(commitment)); - } - - #[test] - fn receipt_acceptance_reject() { - let acc = ReceiptAcceptance::reject("bad signature"); - assert!(!acc.valid); - assert_eq!(acc.reason.as_deref(), Some("bad signature")); - assert!(acc.commitment.is_none()); - } - // --- ADR 0003 return leg: CountersignB split / overlay / wire codec --- /// The whole return-leg contract in one assertion chain: the recipient's @@ -1865,44 +1679,4 @@ mod tests { assert_ne!(relationship_finalized_signing_target(&c), t0, "field {i}"); } } - - // --- ParentConsumptionTracker --- - - #[test] - fn tracker_fresh_parent_not_consumed() { - let tracker = ParentConsumptionTracker::new(); - assert!(!tracker.is_consumed(&[0; 32])); - assert!(tracker.get_child(&[0; 32]).is_none()); - } - - #[test] - fn tracker_with_capacity_behaves_like_new() { - let tracker = ParentConsumptionTracker::with_capacity(100); - assert!(!tracker.is_consumed(&[0xFF; 32])); - } - - #[test] - fn tracker_multiple_distinct_parents() { - let mut tracker = ParentConsumptionTracker::new(); - let p1 = [1u8; 32]; - let p2 = [2u8; 32]; - let c1 = [0xA0; 32]; - let c2 = [0xB0; 32]; - - tracker.try_consume(p1, c1).unwrap(); - tracker.try_consume(p2, c2).unwrap(); - - assert_eq!(tracker.get_child(&p1), Some(&c1)); - assert_eq!(tracker.get_child(&p2), Some(&c2)); - } - - #[test] - fn tracker_replay_same_child_is_error() { - let mut tracker = ParentConsumptionTracker::new(); - let parent = [0x10; 32]; - let child = [0x20; 32]; - tracker.try_consume(parent, child).unwrap(); - let err = tracker.try_consume(parent, child).unwrap_err(); - assert!(format!("{err}").contains("replay")); - } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/verification/receipt_verification.rs b/dsm_client/deterministic_state_machine/dsm/src/verification/receipt_verification.rs index e7bef6d9b..62950f51c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/verification/receipt_verification.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/verification/receipt_verification.rs @@ -17,161 +17,7 @@ use crate::types::device_state::relationship_chain_tip_v2; use crate::types::error::DsmError; use crate::types::offline_allocation_leaf::{offline_allocation_key, offline_allocation_value}; use crate::types::operations::Operation; -use crate::types::receipt_types::{ - DeviceTreeAcceptanceCommitment, ParentConsumptionTracker, ReceiptAcceptance, ReceiptLeaf, - ReceiptVerificationContext, StitchedReceiptV2, -}; - -/// Verify a stitched receipt against all acceptance predicates -/// -/// Per whitepaper, a receipt is accepted iff: -/// 1. Both SPHINCS+ signatures verify over canonical commit bytes -/// 2. The receipt's state rules hold ([`verify_receipt_state`]): the step's -/// writes are exactly the ones its operation implies, and they fold from -/// `parent_root` to `child_root`; the device proof verifies -/// 4. Parent tip has not been previously consumed (uniqueness) -/// 5. Size cap enforced (≤128 KiB) -/// -/// Token balance conservation and non-negativity are verified by the -/// state-transition layer when the receipt is applied, not here. -/// -/// # Arguments -/// * `receipt` - The stitched receipt to verify -/// * `ctx` - Verification context (roots, pubkeys, consumed set) -/// * `tracker` - Parent consumption tracker (for uniqueness) -/// -/// # Returns -/// `ReceiptAcceptance` with validity and optional rejection reason -pub fn verify_stitched_receipt( - receipt: &StitchedReceiptV2, - ctx: &ReceiptVerificationContext, - tracker: &mut ParentConsumptionTracker, -) -> Result { - // Rule 0: Size cap (must check before expensive operations) - if let Err(e) = receipt.validate_size_cap() { - return Ok(ReceiptAcceptance::reject(format!("Size cap: {}", e))); - } - - // Rule 1: Compute canonical commitment - let commitment = match receipt.compute_commitment() { - Ok(c) => c, - Err(e) => { - return Ok(ReceiptAcceptance::reject(format!( - "Commitment error: {}", - e - ))) - } - }; - - // Rule 2: Verify both bilateral signatures over the commitment. - if receipt.sig_a.is_empty() { - return Ok(ReceiptAcceptance::reject( - "Missing sender signature (sig_a)".to_string(), - )); - } - if receipt.sig_b.is_empty() { - return Ok(ReceiptAcceptance::reject( - "Missing receiver signature (sig_b)".to_string(), - )); - } - - if !verify_sphincs_signature(&commitment, &receipt.sig_a, &ctx.pubkey_a)? { - return Ok(ReceiptAcceptance::reject( - "Signature A verification failed".to_string(), - )); - } - if !verify_sphincs_signature(&commitment, &receipt.sig_b, &ctx.pubkey_b)? { - return Ok(ReceiptAcceptance::reject( - "Signature B verification failed".to_string(), - )); - } - - // Rule 2b: EK-cert-chain ephemeral-key authorization (whitepaper §11.1). - // Offline receipt acceptance is fail-closed: the sender signature must be - // made by a per-step EK that is certified by the current AK/EK chain head. - // Parent/root inclusion proves state consistency; this cert proves live - // enrolled-device authorization for the proposed transition. - let Some(chain_head) = &ctx.chain_head_pubkey_a else { - return Ok(ReceiptAcceptance::reject( - "Missing chain_head_pubkey_a (EK cert chain required)".to_string(), - )); - }; - if receipt.ek_cert_a.is_empty() { - return Ok(ReceiptAcceptance::reject( - "Missing ek_cert_a (EK cert chain required)".to_string(), - )); - } - match crate::crypto::ephemeral_key::verify_ek_cert( - chain_head, - &ctx.pubkey_a, - &receipt.parent_tip, - &receipt.ek_cert_a, - ) { - Ok(true) => {} - Ok(false) => { - return Ok(ReceiptAcceptance::reject( - "ek_cert_a verification failed (EK_pk not authorized by chain head)".to_string(), - )) - } - Err(e) => return Ok(ReceiptAcceptance::reject(format!("ek_cert_a error: {}", e))), - } - let Some(chain_head) = &ctx.chain_head_pubkey_b else { - return Ok(ReceiptAcceptance::reject( - "Missing chain_head_pubkey_b (EK cert chain required)".to_string(), - )); - }; - if receipt.ek_cert_b.is_empty() { - return Ok(ReceiptAcceptance::reject( - "Missing ek_cert_b (EK cert chain required)".to_string(), - )); - } - match crate::crypto::ephemeral_key::verify_ek_cert( - chain_head, - &ctx.pubkey_b, - &receipt.parent_tip, - &receipt.ek_cert_b, - ) { - Ok(true) => {} - Ok(false) => { - return Ok(ReceiptAcceptance::reject( - "ek_cert_b verification failed".to_string(), - )) - } - Err(e) => return Ok(ReceiptAcceptance::reject(format!("ek_cert_b error: {}", e))), - } - - // Rule 2c: the step starts from the root this verifier expects. - if receipt.parent_root != ctx.expected_parent_root { - return Ok(ReceiptAcceptance::reject( - "the receipt's parent root is not the root this verifier expects".to_string(), - )); - } - - // Rules 3–4: the receipt's state — the step's writes are the ones its - // operation implies and fold from the pre-state root to the post-state - // root, and the device proof puts the sender under the authenticated - // Device Tree. - let state = ReceiptStateContext { - device_tree_commitment: &ctx.device_tree_commitment, - author_genesis: ctx.author_genesis, - operation: &ctx.operation, - bearer: ctx.bearer, - }; - if let Err(e) = verify_receipt_state(receipt, &state) { - return Ok(ReceiptAcceptance::reject(e.to_string())); - } - - // Rule 5: Parent uniqueness (Tripwire enforcement) - if let Err(e) = tracker.try_consume(receipt.parent_tip, receipt.child_tip) { - return Ok(ReceiptAcceptance::reject(format!( - "Parent uniqueness: {}", - e - ))); - } - - // All checks passed - Ok(ReceiptAcceptance::accept(commitment)) -} +use crate::types::receipt_types::{DeviceTreeAcceptanceCommitment, ReceiptLeaf, StitchedReceiptV2}; /// The anchor-state leaf of the author's own offline-bearer spend, before and /// after, as the verifier derived them under the pinned bundle from the @@ -202,8 +48,8 @@ pub struct ReceiptStateContext<'a> { } /// The state rules of a receipt, whoever checks it: the sender before it signs -/// anything, the recipient before it accepts, and [`verify_stitched_receipt`] -/// as its rules 3–4. There is one implementation. +/// anything, and the recipient before it accepts (`bilateral::offline`'s +/// `decide_confirm` and `decide_commit_ack`). There is one implementation. /// /// - Every fixed field names something: a zero genesis, device, tip, root or /// transition entropy is not a field of any transition; and the genesis is @@ -390,22 +236,6 @@ pub fn verify_receipt_state( Ok(()) } -/// Helper: Verify SPHINCS+ signature -/// -/// Verifies a SPHINCS+ signature over commitment bytes using public key. -fn verify_sphincs_signature( - commitment: &[u8; 32], - signature: &[u8], - public_key: &[u8], -) -> Result { - if signature.is_empty() || public_key.is_empty() { - return Ok(false); - } - - // Use SignatureKeyPair::verify_raw for static signature verification - crate::crypto::signatures::SignatureKeyPair::verify_raw(commitment, signature, public_key) -} - /// The side of a stitched receipt: its sender (A) or its receiver (B). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BilateralSide { @@ -527,69 +357,6 @@ mod tests { }) } - /// The transfer's receipt signed as [`verify_stitched_receipt`] checks - /// it: each side's per-step EK, certified by that side's AK at the - /// receipt's parent tip, signs the receipt's commitment. With the context - /// a verifier expecting the step holds. - fn signed_receipt_and_context() -> (StitchedReceiptV2, ReceiptVerificationContext) { - let w = world(); - let mut receipt = w.transfer.receipt.clone(); - let commitment = receipt.compute_commitment().unwrap(); - let a = w.sender.answer( - &w.receiver, - &receipt.parent_tip, - &w.transfer.c_pre, - &commitment, - ); - let b = w.receiver.answer( - &w.sender, - &receipt.parent_tip, - &w.transfer.c_pre, - &commitment, - ); - receipt.add_sig_a(a.sig); - receipt.set_ek_cert_a(a.ek_cert); - receipt.set_ek_pk_a(a.ek_pk.clone()); - receipt.add_sig_b(b.sig); - receipt.set_ek_cert_b(b.ek_cert); - receipt.set_ek_pk_b(b.ek_pk.clone()); - let ctx = ReceiptVerificationContext::new( - w.sender.device_tree_commitment(), - receipt.parent_root, - a.ek_pk, - b.ek_pk, - w.sender.genesis(), - w.transfer.operation.clone(), - ) - .with_chain_head_a(w.sender.signing_public_key().to_vec()) - .with_chain_head_b(w.receiver.signing_public_key().to_vec()); - (receipt, ctx) - } - - #[test] - fn test_verify_empty_receipt_rejects() { - let (_, ctx) = signed_receipt_and_context(); - let receipt = world().transfer.receipt.clone(); - let result = - verify_stitched_receipt(&receipt, &ctx, &mut ParentConsumptionTracker::new()).unwrap(); - assert!(!result.valid); - assert!(result.reason.unwrap().contains("Missing sender signature")); - } - - #[test] - fn test_verify_receipt_rejects_missing_receiver_signature() { - let (signed, ctx) = signed_receipt_and_context(); - let mut receipt = world().transfer.receipt.clone(); - receipt.add_sig_a(signed.sig_a.clone()); - let result = - verify_stitched_receipt(&receipt, &ctx, &mut ParentConsumptionTracker::new()).unwrap(); - assert!(!result.valid); - assert!(result - .reason - .unwrap() - .contains("Missing receiver signature")); - } - /// The state rules hold for the receipt of a real step and refuse each /// thing that would let a receipt claim a move its writes do not prove: /// another post-state root, another pre-state root, a changed sibling, a @@ -712,119 +479,4 @@ mod tests { "another Device Tree" ); } - - /// The real receipt is accepted on a fresh parent; once that parent is - /// consumed the same receipt is refused for parent uniqueness, and for - /// nothing else. - #[test] - fn test_parent_uniqueness_enforcement() { - let (receipt, ctx) = signed_receipt_and_context(); - let accepted = - verify_stitched_receipt(&receipt, &ctx, &mut ParentConsumptionTracker::new()).unwrap(); - assert!(accepted.valid, "{:?}", accepted.reason); - - let fork = &world().fork.receipt; - assert_eq!( - fork.parent_tip, receipt.parent_tip, - "the fork shares the parent" - ); - let mut tracker = ParentConsumptionTracker::new(); - tracker - .try_consume(fork.parent_tip, fork.child_tip) - .unwrap(); - let result = verify_stitched_receipt(&receipt, &ctx, &mut tracker).unwrap(); - assert!(!result.valid); - let reason = result.reason.unwrap_or_default(); - assert!(reason.contains("Parent uniqueness"), "got: {reason}"); - } - - /// A verifier that expects the step to start from one root refuses a - /// receipt — fully signed, its writes authentic — over any other root: - /// that receipt is a different step. - #[test] - fn a_receipt_over_a_root_the_verifier_does_not_expect_is_refused() { - let (receipt, ctx) = signed_receipt_and_context(); - let accepted = - verify_stitched_receipt(&receipt, &ctx, &mut ParentConsumptionTracker::new()).unwrap(); - assert!(accepted.valid, "{:?}", accepted.reason); - - let mut elsewhere = ctx; - elsewhere.expected_parent_root = receipt.child_root; - let mut tracker = ParentConsumptionTracker::new(); - let refused = verify_stitched_receipt(&receipt, &elsewhere, &mut tracker).unwrap(); - assert!(!refused.valid); - let reason = refused.reason.unwrap_or_default(); - assert!( - reason.contains("is not the root this verifier expects"), - "got: {reason}" - ); - assert!( - tracker - .try_consume(receipt.parent_tip, receipt.child_tip) - .is_ok(), - "a refused receipt consumes no parent" - ); - } - - /// Whitepaper §11.1: when a chain head is set on the verification context, - /// the receipt MUST carry a valid `ek_cert_a`. A missing cert must be - /// rejected with a specific error — otherwise an attacker could strip the - /// cert and bypass AK-rooted authorization for the per-step EK. - #[test] - fn test_missing_ek_cert_a_rejected_when_chain_head_set() { - let (mut receipt, ctx) = signed_receipt_and_context(); - receipt.ek_cert_a.clear(); - let result = - verify_stitched_receipt(&receipt, &ctx, &mut ParentConsumptionTracker::new()).unwrap(); - assert!(!result.valid, "missing ek_cert_a must be rejected"); - let reason = result.reason.unwrap(); - assert!( - reason.contains("Missing ek_cert_a"), - "wrong rejection reason: {reason}" - ); - } - - /// An ek_cert_a signed by a key that is not the chain head — here the - /// receiver's AK certifying the sender's EK — must not verify against the - /// legitimate head: the core forgery resistance of the cert chain. - #[test] - fn test_ek_cert_a_signed_by_wrong_key_rejected() { - let w = world(); - let (mut receipt, ctx) = signed_receipt_and_context(); - receipt.ek_cert_a = crate::crypto::ephemeral_key::sign_ek_cert( - w.receiver.signing_secret_key(), - &ctx.pubkey_a, - &receipt.parent_tip, - ) - .expect("the receiver's AK signs a cert"); - let result = - verify_stitched_receipt(&receipt, &ctx, &mut ParentConsumptionTracker::new()).unwrap(); - assert!(!result.valid, "forged ek_cert_a must be rejected"); - let reason = result.reason.unwrap(); - assert!( - reason.contains("ek_cert_a verification failed"), - "wrong rejection reason: {reason}" - ); - } - - /// Receipt verification is fail-closed when no sender chain head is set. - /// Parent/root inclusion alone is not spend authority; the recipient must - /// also verify the per-step EK cert chain. - #[test] - fn test_no_chain_head_rejects_receipt_authorization() { - let (receipt, ctx) = signed_receipt_and_context(); - let headless = ReceiptVerificationContext { - chain_head_pubkey_a: None, - ..ctx - }; - let result = - verify_stitched_receipt(&receipt, &headless, &mut ParentConsumptionTracker::new()) - .unwrap(); - assert!(!result.valid, "missing chain head must reject"); - let reason = result.reason.unwrap(); - assert!( - reason.contains("chain_head_pubkey_a"), - "wrong rejection reason: {reason}" - ); - } } diff --git a/dsm_client/deterministic_state_machine/dsm/tests/smt_tripwire_theorem.rs b/dsm_client/deterministic_state_machine/dsm/tests/smt_tripwire_theorem.rs index bc35db01e..7f183387c 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/smt_tripwire_theorem.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/smt_tripwire_theorem.rs @@ -22,12 +22,18 @@ use std::collections::HashSet; use dsm::common::device_tree::DeviceTree; -use dsm::core::bilateral_transaction_manager::{compute_smt_key, compute_successor_tip}; +use dsm::bilateral::identity_binding::binding_digest; +use dsm::bilateral::offline::{ + decide_prepare, PeerCredentials, PinnedPeer, PrepareClaims, PrepareDecision, +}; +use dsm::core::bilateral_transaction_manager::{ + bilateral_sign_message, compute_smt_key, compute_successor_tip, BilateralPreCommitment, +}; +use dsm::crypto::kyber::generate_kyber_keypair_from_entropy; use dsm::crypto::blake3::{domain_hash_bytes, dsm_domain_hasher}; use dsm::crypto::signatures::SignatureKeyPair; use dsm::merkle::sparse_merkle_tree::ZERO_LEAF; use dsm::types::operations::{Operation, TransactionMode}; -use dsm::types::receipt_types::ParentConsumptionTracker; use dsm::types::token_types::Balance; use dsm::merkle::batch_fold::{verify_batch, FoldEntry}; use dsm::merkle::sparse_merkle_tree::{hash_smt_leaf, hash_smt_node, DeviceSmtHashes}; @@ -185,25 +191,69 @@ fn theorem2_two_successors_same_parent_rejected() { // Second (forked) transfer from SAME h_0: different amount. let entropy2 = [0xBBu8; 32]; let receipt_digest2 = domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_RECEIPT, &[0x02; 32]); - let (_op2, op2_bytes) = make_transfer_op(&bob.device_id, 200); + let (op2, op2_bytes) = make_transfer_op(&bob.device_id, 200); let h_1_prime = compute_successor_tip(&h_0, &op2_bytes, &entropy2, &receipt_digest2); // Different operations from the same parent produce different successor tips. assert_ne!(h_1, h_1_prime, "forked tips must differ"); - // ParentConsumptionTracker enforces single-use. - let mut tracker = ParentConsumptionTracker::new(); - tracker - .try_consume(h_0, h_1) - .expect("first consumption must succeed"); - - // Attempting to consume h_0 again with a different child must fail (fork detected). - let err = tracker.try_consume(h_0, h_1_prime); - assert!(err.is_err(), "second consumption must be rejected (fork)"); - let msg = format!("{}", err.unwrap_err()); + // Production's receiver decides a proposal against the relationship tip it + // holds (Core's `decide_prepare`). Once the first child has committed it + // holds h_1, and the second child — which extends h_0 — is refused as a + // stale tip. While it holds h_0, the same proposal is considered: the + // refusal is the held tip's, not the proposal's. + let (kyber_pk, _kyber_sk) = + generate_kyber_keypair_from_entropy(&[0x5A; 32], "tripwire-theorem").unwrap(); + let binding_sig = alice + .keypair + .sign(&binding_digest( + &alice.device_id, + &alice.genesis_hash, + &kyber_pk, + )) + .unwrap(); + let commitment = BilateralPreCommitment::new(h_0, op2.clone()).bilateral_commitment_hash; + let signature = alice + .keypair + .sign(&bilateral_sign_message(&commitment)) + .unwrap(); + let pinned = PinnedPeer { + device_id: alice.device_id, + genesis: alice.genesis_hash, + signing_key: &alice.keypair.public_key, + kyber_public_key: &kyber_pk, + }; + let claims = PrepareClaims { + addressed_to: &bob.device_id, + expected_tip: Some(h_0), + credentials: PeerCredentials { + signing_key: &alice.keypair.public_key, + kyber_public_key: &kyber_pk, + kyber_binding_sig: &binding_sig, + }, + signature: &signature, + }; + let decide = |held| { + decide_prepare( + commitment, + &op2, + claims, + &pinned, + &bob.device_id, + held, + None, + ) + .expect("the proposal is authenticated") + }; + let refused = decide(h_1); assert!( - msg.contains("Fork detected"), - "error must mention fork; got: {msg}" + matches!(refused, PrepareDecision::StaleTip { held, .. } if held == h_1), + "the second child of h_0 must be refused once h_1 is held; got {refused:?}" + ); + let considered = decide(h_0); + assert!( + matches!(considered, PrepareDecision::Consider { .. }), + "the same proposal is considered while h_0 is held; got {considered:?}" ); } @@ -639,44 +689,6 @@ fn chain_first_transaction_from_zero() { // Invariants // =========================================================================== -#[test] -fn parent_consumed_exactly_once() { - let mut tracker = ParentConsumptionTracker::new(); - - let parent = domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_TEST_PARENT, &[0x01; 32]); - let child_a = domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_TEST_CHILD, &[0x0A; 32]); - let child_b = domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_TEST_CHILD, &[0x0B; 32]); - - // Fresh parent: first consumption succeeds. - assert!(!tracker.is_consumed(&parent)); - tracker - .try_consume(parent, child_a) - .expect("first consumption must succeed"); - assert!(tracker.is_consumed(&parent)); - - // Replay (same child): must fail. - assert!( - tracker.try_consume(parent, child_a).is_err(), - "replay must be rejected" - ); - - // Fork (different child): must fail. - let fork_err = tracker.try_consume(parent, child_b); - assert!(fork_err.is_err(), "fork must be rejected"); - let msg = format!("{}", fork_err.unwrap_err()); - assert!( - msg.contains("Fork detected"), - "error must identify fork; got: {msg}" - ); - - // Recorded child is the first one. - assert_eq!( - tracker.get_child(&parent), - Some(&child_a), - "canonical child must be the first consumed" - ); -} - #[test] fn balance_conservation_arithmetic() { let test_cases: &[(u64, u64)] = &[ diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 5e0a5720c..7ad8b9edf 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -265,7 +265,7 @@ What the backend does that no requirement asks for, or that a requirement forbid | Location | Note | |---|---| | `dsm` · core/state_machine/transition.rs · `create_next_state`, `apply_transition`, `verify_transition_integrity`, `apply_token_balance_delta` | Legacy `State` path, a second public way to produce state outside `DeviceState::advance` (MR-DSM-0243, 0244). Deleted with the rest of the layer (§6.10). | -| `dsm_sdk` · sdk/receipts.rs · `verify_stitched_receipt`; `dsm` · verification/receipt_verification.rs · `verify_stitched_receipt` | The only code shaped like candidate/guard checking (`ForkCandidate`). No handler calls it. | +| `dsm_sdk` · sdk/receipts.rs · `verify_stitched_receipt`; `dsm` · verification/receipt_verification.rs · `verify_stitched_receipt` | The only code shaped like candidate/guard checking (`ForkCandidate`). No handler calls it. Deleted, with its `ParentConsumptionTracker` (§6.45). | | `dsm_sdk` · sdk/dlv_pre_commitment_sdk.rs · `DlvPreCommitmentSdk` | No handler callers. Deleted (§6.10). | | `dsm_sdk` · sdk/smart_commitment_sdk.rs · `SmartCommitmentSdk` | No callers. | | `dsm` · core/verification/identity_verifier.rs; types/identity.rs; types/state_types.rs (second `IdentityAnchor`) | `IdentityVerifier`, `IdentityClaim`, `IdentityAnchor`. No production callers. | @@ -531,7 +531,7 @@ Owner rulings of 2026-09-24 (receipts, `wallet.send`, the rule that nothing whic | Location | Finding | |---|---| -| receipt verification on the live path | `verify_stitched_receipt` has no production caller; production checks the state rules with `verify_receipt_state` (the BLE confirm converged onto it, §6.11). | +| receipt verification on the live path | `verify_stitched_receipt` has no production caller; production checks the state rules with `verify_receipt_state` (the BLE confirm converged onto it, §6.11). Resolved: deleted, and its checks moved onto production's decisions (§6.45). | | DLV operations | `DlvCreate`/`DlvUnlock`/`DlvClaim`/`DlvInvalidate` signatures were verified only in the deleted `create_next_state`; their executor is `BitcoinTapSdk` (dBTC deferred). No live verification. | | `dsm` · types/state_types.rs · `State` | A compatibility view synthesized from the head, still read in production (token and wallet SDKs, and the Bitcoin routes, which store `State` in structs and read its `hash` and `token_balances`). Removing it touches Bitcoin code. | | `dsm` · core/bilateral_transaction_manager.rs · `initial_chain_tip_from_device_ids` | Self-loop relationships derive `h_0` with 32 zero bytes where each genesis goes. | @@ -1303,7 +1303,7 @@ Tests: `dsm::verification::receipt_verification::tests::the_state_rules_hold_onl - The online receiver does not hold the sender's A-side receipt to the state rules; it binds the receipt's child tip to the validated debit's successor and relies on the economic lineage walk. - History rows restored by recovery keep no operation, so their badge reads not verified. - Σ and K are not in the receipt (G3; MR-DSM-0160, 0161, 0162). -- `verify_stitched_receipt` still has no production caller (§6.11): the vertical-validation models use it; production uses `verify_receipt_state`. +- `verify_stitched_receipt` still has no production caller (§6.11): the vertical-validation models use it; production uses `verify_receipt_state`. Resolved: deleted, and the models moved onto production's decisions (§6.45). ### 6.36 The placeholder sweep of 2026-09-27 (branch `fix/placeholder-sweep-2026-09-27`) @@ -1659,9 +1659,32 @@ Mutation controls, run on 2026-09-29, each restored byte for byte: Found, not changed here: -- **The tripwire's integration test proves it through machinery production never runs.** `dsm/tests/smt_tripwire_theorem.rs::theorem2_two_successors_same_parent_rejected` rejects the second child through `ParentConsumptionTracker`. Its only other user is `verification::receipt_verification::verify_stitched_receipt`, which only tests call. Production verifies receipts with `verify_receipt_state` and records consumption in the canonical apply (MR-DSM-0170). `ParentConsumptionTracker::with_capacity` also ignores its argument. +- **The tripwire's integration test proves it through machinery production never runs.** `dsm/tests/smt_tripwire_theorem.rs::theorem2_two_successors_same_parent_rejected` rejects the second child through `ParentConsumptionTracker`. Its only other user is `verification::receipt_verification::verify_stitched_receipt`, which only tests call. Production verifies receipts with `verify_receipt_state` and records consumption in the canonical apply (MR-DSM-0170). `ParentConsumptionTracker::with_capacity` also ignores its argument. Resolved: the tracker and the verifier are deleted, and the test asserts the refusal through `decide_prepare` (§6.45). - **Nine more MR-DSM rows cite items that no longer exist** inside files that do: 0002, 0004, 0005, 0014, 0092 and 0199 (`sdk/receipts.rs::verify_receipt_bytes`); 0034 and 0083 (`sdk/storage_node_sdk.rs::put_cell_leader_first`); 0068 (`StorageNodeSDK`, `sdk/storage_io.rs::fetch_immutable_payload`). Their statuses are unverified until re-examined. +### 6.45 The unreached receipt verifier is deleted; its checks run on production's decisions (`refactor/delete-unreached-stitched-receipt-verifier`, 2026-09-30) + +**The finding (§6.3, §6.11, §6.44).** `verification::receipt_verification::verify_stitched_receipt` and its `ParentConsumptionTracker` had no production caller. Production decides a bilateral step with `bilateral::offline` (`decide_prepare`, `decide_confirm`, `decide_commit_ack`), and a second child of one tip is refused because the receiver holds the tip it committed. The verifier's only users were tests and the vertical-validation tool. So the tool's attack suite, its fork-exclusion property, its Tripwire traces and its TLA trace replays reported properties of code production never runs (owner ruling, 2026-09-30: port the checks onto the production path, then delete). + +**Changed** + +| Where | What | +|---|---| +| `dsm` · verification/receipt_verification.rs, types/receipt_types.rs, types/mod.rs | `verify_stitched_receipt`, its signature helper, `ReceiptVerificationContext`, `ReceiptAcceptance` and `ParentConsumptionTracker` deleted, with the tests that exercised only them. `verify_receipt_state` and the per-step EK checks, which production calls, are unchanged. | +| `dsm` · tests/smt_tripwire_theorem.rs | `theorem2_two_successors_same_parent_rejected` asserts the rejection through `decide_prepare`: the second child of h_0 is `StaleTip` once h_1 is held, and is considered while h_0 is held. `parent_consumed_exactly_once`, a test of the tracker alone, is deleted. | +| `tools/vertical_validation` · live_device.rs | A step is production's offline step: proposed on the shared tip the sender holds (from `initial_chain_tip_from_device_ids`), σ_A over the step commitment, the receipt signed by a per-step EK its chain head certifies, decided by `decide_prepare` and `decide_confirm` against the receiver's held tip and pinned keys (real Kyber identity binding), and committed on both devices. | +| `tools/vertical_validation` · adversarial_bilateral.rs, property_tests.rs, implementation_traces.rs, tla_trace_replay.rs, tla_runner.rs | Every judge is the receiver's decision: double spend, replay and a receiver behind are refused as `StaleTip`; wrong-key, garbage and uncertified-EK signatures and forged post-states are refused by `decide_prepare` or `decide_confirm`. The TLA DSM replay delivers each step through them, and the Tripwire replay maps the model's tips onto the shared tips real devices hold. `receipt_verifier_tripwire` becomes `receiver_tripwire`; `tripwire_parent_consumption`, a trace of the tracker alone, is deleted. | + +**Evidence (2026-09-30)** +- `vertical-validation adversarial`: 6 of 6 attacks refused. `property-tests --iterations 5 --seed 42`: 6 of 6. `implementation-traces`: 15 of 15. `tla-check`: all 91 specs pass, and the Tripwire and DSM_tiny, DSM_small and DSM_system traces replay literal=PASS direct=PASS on the new path. +- `dsm::smt_tripwire_theorem::theorem2_two_successors_same_parent_rejected`; `dsm::bilateral::offline::tests::a_stale_proposal_says_whether_its_claimed_tip_recomputes`. +- `cargo test -p dsm -p dsm_vertical_validation --release`: 1,630 passed, 0 failed. +- Mutation control: the stale-tip arm of `decide_prepare` disabled turns both tests above red and fails double_spend_second_child, step_replay and receiver_behind (the fork is then refused only by the commitment recompute, the wrong reason); restored. + +**Removed with the verifier.** Its rule 2c, a receipt's parent root compared with a root the verifier expects, has no production counterpart: production's receiver decides against the relationship tip it holds, not a payer's device root. The matrix row that mapped that rule to MR-DSM-0028 and MR-DSM-0041 is deleted. Both rows are Met on other evidence (`peer_acceptance`'s EK ancestry, SoFi's in-hand refutation). + +**Not driven by the tool.** A forged countersignature (σ_B) is judged by the sender's `decide_commit_ack`, which the tool does not drive; Core's `dsm::bilateral::offline::tests::an_ack_is_only_the_receivers_counter_signed_receipt` refuses it there. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | @@ -1770,7 +1793,7 @@ Found, not changed here: | MR-DSM-0089 | Partial | dsm_storage_node · api/objects/immutable.rs::get_immutable (177-187) | no test found | Same gap as 0067 (same mechanism). | | MR-DSM-0090 | Not code | — | — | Fault-model assumption. | | MR-DSM-0091 | Not code | — | — | Dependency boundary; nothing to implement here. | -| MR-DSM-0092 | Partial | `dsm/src/types/device_state.rs::advance` (1134); `dsm_sdk/src/sdk/receipts.rs::verify_receipt_bytes` (844) | `receipts.rs::tests::first_ever_receipt_requires_merkle_pre_root_not_cas_parent_root` (3251) | New finding: the only candidate/guard mechanism in the tree (`verification::receipt_verification::verify_stitched_receipt`, using `ForkCandidate`) is wrapped by `dsm_sdk::sdk::receipts::verify_stitched_receipt` (602), which has **zero handler callers** — production acceptance (`verify_receipt_bytes`) has no candidate/guard/linearity stage at all. | +| MR-DSM-0092 | Partial | `dsm/src/types/device_state.rs::advance` (1134); `dsm_sdk/src/sdk/receipts.rs::verify_receipt_bytes` (844) | `receipts.rs::tests::first_ever_receipt_requires_merkle_pre_root_not_cas_parent_root` (3251) | New finding: the only candidate/guard mechanism in the tree (`verification::receipt_verification::verify_stitched_receipt`, using `ForkCandidate`) is wrapped by `dsm_sdk::sdk::receipts::verify_stitched_receipt` (602), which has **zero handler callers** — production acceptance (`verify_receipt_bytes`) has no candidate/guard/linearity stage at all. Both were deleted, with the parent-consumption tracker (§6.45). | | MR-DSM-0093 | Partial | `dsm/src/types/device_state.rs::advance` (pure, operates only on `self`); `core/state_machine/mod.rs::prepare_advance_relationship/commit_advance` | no test found | Enforced structurally (no API accepts on another device's behalf) but no named test isolates this property. | | MR-DSM-0094 | Not code | — | — | Liveness boundary; nothing to build. | | MR-DSM-0095 | Partial | `dsm/src/types/device_state.rs` (`RelationshipChainState`/`DeviceState` fields, no counter/timestamp, confirmed by reading the struct) | no test found | True by field-absence; no negative test exercises it (transition.rs's tests are off the production path). | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 79870217a..b79a936e5 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -36,8 +36,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0026, MR-DSM-0122: the counter and the value source are inside the root, so a step's receipt proves them moving with it — no counter step is spent on a step whose receipt does not hold | `dsm` · types/receipt_types.rs · `StitchedReceiptV2::of_step`; verification/receipt_verification.rs · `verify_receipt_state` (bearer arm); `dsm_sdk` · bluetooth/bilateral_ble_handler.rs (sender: the receipt before `release_offline_bearer`); bluetooth/anchor_accept.rs · `bearer_leaves_of_release` | `dsm_sdk::bluetooth::offline_step_tests::a_bearer_receipt_holds_only_for_the_write_set_its_spend_makes` (the allocation or the anchor write omitted with the child root recomputed, an allocation smaller than the spend, writes out of key order, a write repeated, anchor leaves that do not move, no anchor leaves, the release's anchor pre-state, another bundle; a release carrying a counter or frontier successor it does not derive, or read under another challenge); `dsm_sdk::bluetooth::offline_step_tests::a_bearer_receipt_that_does_not_hold_spends_no_counter_step`; `dsm_sdk::bluetooth::offline_step_tests::a_bearer_step_proves_its_whole_write_set_and_commits_on_both` | Red on its named assertion (2026-09-27): the write-count check (removed, the debit-free receipt is accepted); the allocation, anchor-movement, key-order and bearer-required checks; `bearer_leaves_of_release`'s successor check; the receipt built after the release (the counter moves). | `tla/DSM_ReceiptWriteSet.tla::AllocationConserved`; `tla/DSM_ReceiptWriteSet.tla::CounterMovesWithRoot`; `tla/DSM_ReceiptWriteSet.tla::HonestSpendAcceptable`; `tla/DSM_OfflineAnchorSingleAppliance.tla::NoCommitWithoutDeliverableConfirm` | | A write set's proof is canonical: every path it carries is the tree's own | `dsm` · merkle/batch_fold.rs · `FoldError::InconsistentPath`; merkle/smt_path.rs · `decode` | `dsm::merkle::batch_fold::tests::device_paths_of_two_trees_are_refused` | The separation check removed → red (2026-09-27). | `lean4/DSMStepTransition.lean::fold_canonical`; `lean4/DSMStepTransition.lean::lax_fold_is_not_canonical` | | A history row's receipt badge is re-derived from the operation the row keeps and its author's pinned genesis and Device Tree | `dsm_sdk` · handlers/wallet_routes.rs · `receipt_state_holds` | `dsm_sdk::handlers::wallet_routes::receipt_badge_tests::the_history_badge_holds_only_for_the_operation_the_receipt_binds` | A contact's receipt checked against this device's own genesis and Device Tree → red (2026-09-27). | — | -| MR-DSM-0028, MR-DSM-0041: a receipt is judged against the payer's root the verifier holds | `dsm` · verification/receipt_verification.rs · `verify_stitched_receipt` rule 2c | `dsm::verification::receipt_verification::tests::a_receipt_over_a_root_the_verifier_does_not_expect_is_refused`; vertical validation (adversarial): unexpected_parent_root | Rule removed → red (2026-09-24). | — | -| MR-DSM-0166, MR-DSM-0170: a second child of a consumed parent is refused | `dsm` · types/receipt_types.rs · `ParentConsumptionTracker` in `verify_stitched_receipt` rule 5 | `dsm::verification::receipt_verification::tests::test_parent_uniqueness_enforcement`; vertical validation: double_spend_second_child, receipt_replay, fork_exclusion, receipt_verifier_tripwire | Not run. | TLA+ `DSM_Tripwire` (literal and direct replay pass) | +| MR-DSM-0166, MR-DSM-0170: a second child of a committed tip is refused | `dsm` · bilateral/offline.rs · `decide_prepare` (a proposal that does not extend the tip the receiver holds is `StaleTip`) | `dsm::smt_tripwire_theorem::theorem2_two_successors_same_parent_rejected`; `dsm::bilateral::offline::tests::a_stale_proposal_says_whether_its_claimed_tip_recomputes`; vertical validation: double_spend_second_child, step_replay, receiver_behind, fork_exclusion, receiver_tripwire, tripwire_first_contact_binding | The stale-tip arm of `decide_prepare` disabled → `theorem2_two_successors_same_parent_rejected` and `a_stale_proposal_says_whether_its_claimed_tip_recomputes` red, and double_spend_second_child, step_replay and receiver_behind fail: the fork is then refused only by the commitment recompute, for the wrong reason (2026-09-30). | TLA+ `DSM_Tripwire` (literal and direct replay pass) | | MR-DSM-0024: a debit never exceeds the head's balance | `dsm` · types/device_state.rs · `DeviceState::advance` (checked debit) | `dsm::types::device_state::tests::advance_rejects_balance_underflow`; vertical validation: balance_underflow, overspend_refused, token_manager_overspend_rejection | Not run. | — | | A transfer names its token exactly | `dsm_sdk` · handlers/wallet_routes.rs · `wallet.sendSmart`; handlers/app_router_impl.rs · `process_online_transfer_logic` | `dsm_sdk::handlers::sender_admission_tests::a_transfer_naming_no_token_or_a_misspelled_one_is_refused_and_nothing_moves` | Empty-token default restored → red (2026-09-24). | — | | MR-STOR-0012, MR-STOR-0021: a transfer half becomes recipient state only when SIG A verifies under the key this device stored for the contact the envelope names; a half that does not verify, or names a contact with no stored key, is recorded nowhere | `dsm_sdk` · handlers/recipient_dispatch.rs · `recognize_transfer` (`Operation::decode_and_bind_signed`) | `dsm_sdk::handlers::recipient_dispatch::tests::a_transfer_whose_sig_a_does_not_verify_is_recorded_nowhere`; `dsm_sdk::handlers::recipient_dispatch::tests::junk_and_an_unknown_sender_are_recorded_nowhere` | SIG A check replaced by an unverified decode → red: the forged copy was staged (2026-09-28). | — | diff --git a/tools/vertical_validation/src/adversarial_bilateral.rs b/tools/vertical_validation/src/adversarial_bilateral.rs index 56c5367df..885ea2143 100644 --- a/tools/vertical_validation/src/adversarial_bilateral.rs +++ b/tools/vertical_validation/src/adversarial_bilateral.rs @@ -3,23 +3,21 @@ //! Adversarial bilateral attacks against the path production runs. //! //! Every attack is made on real device heads (`DeviceState::advance`) and -//! judged by Core's receipt verifier with a parent-consumption tracker, the -//! same objects a recipient holds. Each attack first shows the honest step it -//! perturbs is accepted, so a refusal is the verifier refusing the attack and -//! not refusing everything. An attack that is accepted is a hard failure. - -// Validation harness: a device or receipt that cannot be built is a broken +//! decided as production's receiver decides a step: Core's `decide_prepare` +//! and `decide_confirm` (`dsm::bilateral::offline`), against the shared tip +//! the receiver holds and the keys its contact pins. Each attack first shows +//! the honest step it perturbs is accepted, so a refusal is the receiver +//! refusing the attack and not refusing everything. An attack that is +//! accepted is a hard failure. + +// Validation harness: a device or step that cannot be built is a broken // harness, and panicking says so. #![allow(clippy::expect_used)] use instant::Instant; use serde::Serialize; -use dsm::types::operations::Operation; -use dsm::types::receipt_types::{ParentConsumptionTracker, StitchedReceiptV2}; -use dsm::verification::receipt_verification::verify_stitched_receipt; - -use crate::live_device::{connect, stitched_receipt, verification_context, LiveDevice}; +use crate::live_device::{commit, connect, marked, Decision, LiveDevice, Stage}; // --------------------------------------------------------------------------- // Result types @@ -51,11 +49,11 @@ pub fn collect_adversarial_results() -> AdversarialSuiteResult { let attacks = vec![ attack_double_spend(), - attack_forged_signature(), + attack_forged_sender_signature(), attack_replay(), attack_balance_underflow(), attack_forged_post_state(), - attack_unexpected_parent_root(), + attack_receiver_behind(), ]; for a in &attacks { @@ -76,7 +74,7 @@ pub fn collect_adversarial_results() -> AdversarialSuiteResult { // Setup // --------------------------------------------------------------------------- -/// Alice, holding two faucet payouts of ERA, and Bob. +/// Alice, holding two faucet payouts of ERA, and Bob, contacts. fn funded_pair() -> (LiveDevice, LiveDevice) { let mut alice = LiveDevice::new("adversarial-alice").expect("alice"); alice.claim_faucet(1).expect("first claim"); @@ -86,34 +84,6 @@ fn funded_pair() -> (LiveDevice, LiveDevice) { (alice, bob) } -/// Alice's transfer of `amount` to Bob from her current head: the receipt of -/// it both have signed, and the transfer. -fn signed_step( - alice: &LiveDevice, - bob: &LiveDevice, - amount: u64, - nonce: u8, -) -> (StitchedReceiptV2, Operation) { - let op = alice.transfer(bob, amount, &[nonce; 8]).expect("transfer"); - let outcome = alice.send(bob, &op).expect("sender advance"); - (stitched_receipt(alice, bob, &outcome).expect("receipt"), op) -} - -/// The verifier's judgement: `Ok(())` accepted, `Err(reason)` refused. -fn judge( - alice: &LiveDevice, - bob: &LiveDevice, - (receipt, op): (&StitchedReceiptV2, &Operation), - tracker: &mut ParentConsumptionTracker, -) -> Result<(), String> { - let ctx = verification_context(alice, bob, alice.head.root(), op); - match verify_stitched_receipt(receipt, &ctx, tracker) { - Ok(acceptance) if acceptance.valid => Ok(()), - Ok(acceptance) => Err(acceptance.reason.unwrap_or_default()), - Err(e) => Err(format!("error: {e}")), - } -} - fn result( name: &str, description: &str, @@ -134,123 +104,138 @@ fn result( } // --------------------------------------------------------------------------- -// Attack 1: double spend — a second child of one parent (Tripwire) +// Attack 1: double spend — a second child of one tip (Tripwire) // --------------------------------------------------------------------------- fn attack_double_spend() -> AdversarialAttackResult { - let (alice, bob) = funded_pair(); - // Two different transfers built from the SAME head: two children of one - // parent tip, each fully signed. - let (first, first_op) = signed_step(&alice, &bob, 150, 0x01); - let (second, second_op) = signed_step(&alice, &bob, 120, 0x02); - let mut tracker = ParentConsumptionTracker::new(); + let (mut alice, mut bob) = funded_pair(); + // Two different steps proposed on the SAME tip: two children of one + // parent, each fully signed. + let first = alice.propose(&bob, marked(0x01)).expect("first step"); + let second = alice.propose(&bob, marked(0x02)).expect("second step"); let outcome = (|| { - if first.parent_tip != second.parent_tip || first.child_tip == second.child_tip { - return Err("the two spends are not two children of one parent".into()); + if first.expected_tip != second.expected_tip || first.successor_tip == second.successor_tip + { + return Err("the two steps are not two children of one tip".into()); } - judge(&alice, &bob, (&first, &first_op), &mut tracker) - .map_err(|r| format!("the honest first spend was refused: {r}"))?; - match judge(&alice, &bob, (&second, &second_op), &mut tracker) { - Ok(()) => Err("the second child of a consumed parent was ACCEPTED".into()), - Err(r) if r.contains("Parent uniqueness") => Ok(format!("second child refused: {r}")), - Err(r) => Err(format!("refused for the wrong reason: {r}")), + let verified = match bob.decide(&alice, &first) { + Decision::Accepted(v) => v, + other => return Err(format!("the honest first step was refused: {other}")), + }; + commit(&mut alice, &mut bob, first, &verified).map_err(|e| e.to_string())?; + match bob.decide(&alice, &second) { + Decision::Accepted(_) => Err("the second child of a committed tip was ACCEPTED".into()), + Decision::StaleTip => { + Ok("second child refused: it does not extend the held tip".into()) + } + other => Err(format!("refused for the wrong reason: {other}")), } })(); result( "double_spend_second_child", - "Two fully signed children of one parent tip: the first is accepted, the second refused", - "first accepted, second refused for parent uniqueness", + "Two fully signed children of one tip: the first commits, the second is refused", + "first committed, second refused as a stale tip", outcome, ) } // --------------------------------------------------------------------------- -// Attack 2: a countersignature by someone other than the counterparty +// Attack 2: a step signed by someone other than the pinned sender // --------------------------------------------------------------------------- -fn attack_forged_signature() -> AdversarialAttackResult { - let (alice, bob) = funded_pair(); - let (honest, op) = signed_step(&alice, &bob, 50, 0x03); +fn attack_forged_sender_signature() -> AdversarialAttackResult { + let (mut alice, bob) = funded_pair(); + let honest = alice.propose(&bob, marked(0x03)).expect("honest step"); let mallory = LiveDevice::new("adversarial-mallory").expect("mallory"); let outcome = (|| { - judge( - &alice, - &bob, - (&honest, &op), - &mut ParentConsumptionTracker::new(), - ) - .map_err(|r| format!("the honest receipt was refused: {r}"))?; + match bob.decide(&alice, &honest) { + Decision::Accepted(_) => {} + other => return Err(format!("the honest step was refused: {other}")), + } // A valid SPHINCS+ signature over the right commitment, by the wrong key. let mut wrong_signer = honest.clone(); - wrong_signer.sig_b.clear(); - let commitment = wrong_signer - .compute_commitment() - .map_err(|e| format!("commitment: {e}"))?; - wrong_signer.add_sig_b( - mallory - .keypair - .sign(&commitment) - .map_err(|e| e.to_string())?, - ); - if judge( - &alice, - &bob, - (&wrong_signer, &op), - &mut ParentConsumptionTracker::new(), - ) - .is_ok() - { - return Err("a countersignature by the wrong key was ACCEPTED".into()); + wrong_signer.sender_signature = mallory + .keypair + .sign( + &dsm::core::bilateral_transaction_manager::bilateral_sign_message( + &honest.commitment_hash, + ), + ) + .map_err(|e| e.to_string())?; + let decided = bob.decide(&alice, &wrong_signer); + match decided.refusal_at(Stage::Prepare) { + Some(e) if e.to_string().contains(NOT_SIGNED_BY_PINNED_AK) => {} + _ => return Err(format!("a step signed by the wrong key: {decided}")), } // Bytes that are no signature at all, at the production size. let mut garbage = honest.clone(); - garbage.sig_b = vec![0xDE; honest.sig_b.len()]; - if judge( - &alice, - &bob, - (&garbage, &op), - &mut ParentConsumptionTracker::new(), - ) - .is_ok() - { - return Err("a garbage countersignature was ACCEPTED".into()); + garbage.sender_signature = vec![0xDE; honest.sender_signature.len()]; + let decided = bob.decide(&alice, &garbage); + match decided.refusal_at(Stage::Prepare) { + Some(e) if e.to_string().contains(NOT_SIGNED_BY_PINNED_AK) => {} + _ => return Err(format!("a garbage signature: {decided}")), + } + + // A genuinely signed receipt under an EK the sender's chain never + // certified: Mallory's AK certifies it. + let foreign = honest + .clone() + .with_ek_certified_by(&mallory) + .map_err(|e| e.to_string())?; + let decided = bob.decide(&alice, &foreign); + match decided.refusal_at(Stage::Confirm) { + Some(e) if e.to_string().contains(EK_NOT_CHAINED) => { + Ok("wrong-key, garbage and foreign-EK signatures refused".into()) + } + _ => Err(format!("a receipt under a foreign EK: {decided}")), } - Ok("wrong-key and garbage countersignatures refused".into()) })(); result( - "forged_countersignature", - "A receipt countersigned by a key that is not the counterparty's is refused", - "both forgeries refused", + "forged_sender_signature", + "A step whose signature is not the pinned sender's, or whose receipt's EK the sender's chain never certified, is refused", + "all three forgeries refused, each by the check it defeats", outcome, ) } +/// `decide_prepare`'s refusal of a proposal its sender's pinned AK did not +/// sign over the commitment. +const NOT_SIGNED_BY_PINNED_AK: &str = "is not signed over its commitment by the pinned AK"; +/// `decide_confirm`'s refusal of a receipt whose EK its sender's chain did +/// not certify. +const EK_NOT_CHAINED: &str = "does NOT chain"; +/// `decide_confirm`'s refusal of a receipt whose writes do not fold. +const WRITES_DO_NOT_FOLD: &str = "do not fold"; + // --------------------------------------------------------------------------- -// Attack 3: replay of an accepted receipt +// Attack 3: replay of a committed step // --------------------------------------------------------------------------- fn attack_replay() -> AdversarialAttackResult { - let (alice, bob) = funded_pair(); - let (receipt, op) = signed_step(&alice, &bob, 40, 0x04); - let mut tracker = ParentConsumptionTracker::new(); + let (mut alice, mut bob) = funded_pair(); + let step = alice.propose(&bob, marked(0x04)).expect("step"); + let replayed = step.clone(); let outcome = (|| { - judge(&alice, &bob, (&receipt, &op), &mut tracker) - .map_err(|r| format!("the receipt was refused the first time: {r}"))?; - match judge(&alice, &bob, (&receipt, &op), &mut tracker) { - Ok(()) => Err("the replayed receipt was ACCEPTED".into()), - Err(r) if r.contains("Parent uniqueness") => Ok(format!("replay refused: {r}")), - Err(r) => Err(format!("refused for the wrong reason: {r}")), + let verified = match bob.decide(&alice, &step) { + Decision::Accepted(v) => v, + other => return Err(format!("the step was refused the first time: {other}")), + }; + commit(&mut alice, &mut bob, step, &verified).map_err(|e| e.to_string())?; + match bob.decide(&alice, &replayed) { + Decision::Accepted(_) => Err("the replayed step was ACCEPTED".into()), + Decision::StaleTip => Ok("replay refused: it does not extend the held tip".into()), + other => Err(format!("refused for the wrong reason: {other}")), } })(); result( - "receipt_replay", - "An accepted receipt presented again is refused", - "first accepted, replay refused", + "step_replay", + "A committed step presented again is refused", + "first committed, replay refused as a stale tip", outcome, ) } @@ -294,109 +279,103 @@ fn attack_balance_underflow() -> AdversarialAttackResult { // --------------------------------------------------------------------------- fn attack_forged_post_state() -> AdversarialAttackResult { - let (alice, bob) = funded_pair(); - let (honest, op) = signed_step(&alice, &bob, 30, 0x07); + let (mut alice, bob) = funded_pair(); + let honest = alice.propose(&bob, marked(0x07)).expect("honest step"); let outcome = (|| { - judge( - &alice, - &bob, - (&honest, &op), - &mut ParentConsumptionTracker::new(), - ) - .map_err(|r| format!("the honest receipt was refused: {r}"))?; - - // Both parties re-sign a receipt whose child root is not the one the - // relationship path folds to: the signatures hold, the state does not. + match bob.decide(&alice, &honest) { + Decision::Accepted(_) => {} + other => return Err(format!("the honest step was refused: {other}")), + } + + // The sender signs a receipt whose child root is not the one the + // relationship path folds to: the signature holds, the state does not. let mut forged = honest.clone(); - forged.child_root[0] ^= 0x01; - forged.sig_a.clear(); - forged.sig_b.clear(); - let commitment = forged.compute_commitment().map_err(|e| e.to_string())?; - forged.add_sig_a(alice.keypair.sign(&commitment).map_err(|e| e.to_string())?); - forged.add_sig_b(bob.keypair.sign(&commitment).map_err(|e| e.to_string())?); - if judge( - &alice, - &bob, - (&forged, &op), - &mut ParentConsumptionTracker::new(), - ) - .is_ok() - { - return Err("a signed receipt over a forged post-state root was ACCEPTED".into()); + forged.receipt.child_root[0] ^= 0x01; + let forged = forged.resigned().map_err(|e| e.to_string())?; + let decided = bob.decide(&alice, &forged); + match decided.refusal_at(Stage::Confirm) { + Some(e) if e.to_string().contains(WRITES_DO_NOT_FOLD) => {} + _ => { + return Err(format!( + "a signed receipt over a forged post-state root: {decided}" + )) + } } // The same with one sibling of the path changed: the writes no // longer fold to the pre-state root. let mut bent = honest.clone(); - let siblings = &mut bent.step_writes[0].path.siblings; + let siblings = &mut bent.receipt.step_writes[0].path.siblings; if siblings.is_empty() { return Err("the relationship path carries no sibling to bend".into()); } siblings[0] ^= 0x01; - bent.sig_a.clear(); - bent.sig_b.clear(); - let commitment = bent.compute_commitment().map_err(|e| e.to_string())?; - bent.add_sig_a(alice.keypair.sign(&commitment).map_err(|e| e.to_string())?); - bent.add_sig_b(bob.keypair.sign(&commitment).map_err(|e| e.to_string())?); - match judge( - &alice, - &bob, - (&bent, &op), - &mut ParentConsumptionTracker::new(), - ) { - Ok(()) => Err("a signed receipt over a bent relationship path was ACCEPTED".into()), - Err(r) => Ok(format!("forged post-state and bent path refused: {r}")), + let bent = bent.resigned().map_err(|e| e.to_string())?; + let decided = bob.decide(&alice, &bent); + match decided.refusal_at(Stage::Confirm) { + Some(e) if e.to_string().contains(WRITES_DO_NOT_FOLD) => { + Ok(format!("forged post-state and bent path refused: {e}")) + } + _ => Err(format!( + "a signed receipt over a bent relationship path: {decided}" + )), } })(); result( "forged_post_state", "Signed receipts whose roots the one relationship path does not produce are refused", - "forged child root and bent path refused", + "forged child root and bent path refused by the state rules", outcome, ) } // --------------------------------------------------------------------------- -// Attack 6: a step presented against a root the verifier does not expect +// Attack 6: a step on a tip the receiver does not hold // --------------------------------------------------------------------------- -fn attack_unexpected_parent_root() -> AdversarialAttackResult { - let (mut alice, bob) = funded_pair(); - let root_before = alice.head.root(); - let op = alice.transfer(&bob, 20, &[0x08; 8]).expect("transfer"); - let first = alice.send(&bob, &op).expect("first advance"); - alice.install(first); - // A genuine, fully signed second step, presented to a verifier that still - // expects the chain to stand where it stood before the first. - let (second, second_op) = signed_step(&alice, &bob, 10, 0x09); +fn attack_receiver_behind() -> AdversarialAttackResult { + let (mut alice, mut bob) = funded_pair(); + // The same device as Bob, restored from before the first step: it holds + // the relationship's tip as it stood then. + let mut restored = LiveDevice::new("adversarial-bob").expect("restored bob"); + connect(&mut restored, &mut alice).expect("restored contact"); let outcome = (|| { - judge( - &alice, - &bob, - (&second, &second_op), - &mut ParentConsumptionTracker::new(), - ) - .map_err(|r| format!("the second step was refused at its own root: {r}"))?; - let stale = verification_context(&alice, &bob, root_before, &second_op); - match verify_stitched_receipt(&second, &stale, &mut ParentConsumptionTracker::new()) { - Ok(a) if a.valid => Err("a step over an unexpected parent root was ACCEPTED".into()), - Ok(a) => { - let reason = a.reason.unwrap_or_default(); - if reason.contains("is not the root this verifier expects") { - Ok(format!("refused: {reason}")) - } else { - Err(format!("refused for the wrong reason: {reason}")) - } + let first = alice + .propose(&bob, marked(0x08)) + .map_err(|e| e.to_string())?; + let verified = match bob.decide(&alice, &first) { + Decision::Accepted(v) => v, + other => return Err(format!("the first step was refused: {other}")), + }; + commit(&mut alice, &mut bob, first, &verified).map_err(|e| e.to_string())?; + // A genuine, fully signed second step on the tip the first committed. + let second = alice + .propose(&bob, marked(0x09)) + .map_err(|e| e.to_string())?; + match bob.decide(&alice, &second) { + Decision::Accepted(_) => {} + other => { + return Err(format!( + "the second step was refused at its own tip: {other}" + )) + } + } + match restored.decide(&alice, &second) { + Decision::Accepted(_) => { + Err("a step on a tip the receiver does not hold was ACCEPTED".into()) + } + Decision::StaleTip => { + Ok("refused: it does not extend the tip the receiver holds".into()) } - Err(e) => Err(format!("the verifier errored: {e}")), + other => Err(format!("refused for the wrong reason: {other}")), } })(); result( - "unexpected_parent_root", - "A genuine step over a parent root the verifier does not expect is refused", - "accepted at its own root, refused at the stale one", + "receiver_behind", + "A genuine step on a tip the receiver does not hold is refused", + "accepted at its own tip, refused by a receiver behind it", outcome, ) } diff --git a/tools/vertical_validation/src/implementation_traces.rs b/tools/vertical_validation/src/implementation_traces.rs index 4d92d9fba..f0e4f6640 100644 --- a/tools/vertical_validation/src/implementation_traces.rs +++ b/tools/vertical_validation/src/implementation_traces.rs @@ -29,15 +29,11 @@ use dsm::emissions::{ use dsm::types::contact_types::DsmVerifiedContact; use dsm::types::device_state::{BalanceDelta, BalanceDirection, DeviceState}; use dsm::types::operations::{Operation, TransactionMode}; -use dsm::types::receipt_types::{ - ParentConsumptionTracker, ReceiptVerificationContext, StitchedReceiptV2, -}; use dsm::types::token_types::Balance; use dsm::vault::{DLVManager, FulfillmentMechanism, VaultState}; -use dsm::verification::receipt_verification::verify_stitched_receipt; use crate::live_device::{ - connect, faucet_claim, stitched_receipt, verification_context, LiveDevice, + commit, connect, faucet_claim, marked, Decision, LiveDevice, Stage, Step, }; const TRACE_VARIANT: SphincsVariant = SphincsVariant::SPX256f; @@ -116,7 +112,7 @@ pub fn collect_named_implementation_trace_results( } } -fn implementation_trace_catalog() -> [(&'static str, TraceFn); 16] { +fn implementation_trace_catalog() -> [(&'static str, TraceFn); 15] { [ ( "state_machine_transfer_chain", @@ -138,11 +134,7 @@ fn implementation_trace_catalog() -> [(&'static str, TraceFn); 16] { "bilateral_precomputed_finalize_hash", trace_bilateral_precomputed_finalize_hash, ), - ( - "tripwire_parent_consumption", - trace_tripwire_parent_consumption, - ), - ("receipt_verifier_tripwire", trace_receipt_verifier_tripwire), + ("receiver_tripwire", trace_receiver_tripwire), ( "tripwire_first_contact_binding", trace_tripwire_first_contact_binding, @@ -342,7 +334,7 @@ fn trace_state_machine_fork_divergence( ) -> ImplementationTraceResult { let start = Instant::now(); let mut failures = Vec::new(); - let (alice, bob) = trace_pair("fork"); + let (mut alice, mut bob) = trace_pair("fork"); let parent_tip = alice.tip_with(&bob).expect("established"); let op_a = alice.transfer(&bob, 1, &[1; 8]).expect("transfer a"); @@ -356,25 +348,32 @@ fn trace_state_machine_fork_divergence( if pair_a.1 == pair_b.1 { failures.push("different operations produced the same child tip".into()); } - let receipt_a = stitched_receipt(&alice, &bob, &child_a).expect("receipt a"); - let receipt_b = stitched_receipt(&alice, &bob, &child_b).expect("receipt b"); - let ctx_a = verification_context(&alice, &bob, alice.head.root(), &op_a); - let ctx_b = verification_context(&alice, &bob, alice.head.root(), &op_b); - let mut tracker = ParentConsumptionTracker::new(); - match verify_stitched_receipt(&receipt_a, &ctx_a, &mut tracker) { - Ok(a) if a.valid => {} - Ok(a) => failures.push(format!( - "the first child was refused: {}", - a.reason.unwrap_or_default() - )), - Err(e) => failures.push(format!("verifier error: {e}")), - } - match verify_stitched_receipt(&receipt_b, &ctx_b, &mut tracker) { - Ok(a) if a.valid => { - failures.push("the second child of one parent was accepted".into()) + // The receiver takes one of two children of its tip. + let step_a = alice.propose(&bob, op_a.clone()); + let step_b = alice.propose(&bob, op_b.clone()); + match (step_a, step_b) { + (Ok(step_a), Ok(step_b)) => { + match bob.decide(&alice, &step_a) { + Decision::Accepted(verified) => { + if let Err(e) = commit(&mut alice, &mut bob, step_a, &verified) { + failures.push(format!("the first child did not commit: {e}")); + } + } + other => failures.push(format!("the first child was refused: {other}")), + } + match bob.decide(&alice, &step_b) { + Decision::Accepted(_) => { + failures.push("the second child of one tip was accepted".into()) + } + Decision::StaleTip => {} + other => failures.push(format!( + "the second child was refused for another reason: {other}" + )), + } + } + (Err(e), _) | (_, Err(e)) => { + failures.push(format!("a step could not be proposed: {e}")) } - Ok(_) => {} - Err(e) => failures.push(format!("verifier error: {e}")), } } (Err(e), _) | (_, Err(e)) => failures.push(format!("advance refused: {e}")), @@ -910,139 +909,91 @@ fn trace_token_manager_overspend_rejection( } } -fn trace_tripwire_parent_consumption( +/// The receiver's decision on `step`, reported as a trace failure message +/// when it does not match `want`: `None` for accepted, `Some(needle)` for a +/// refusal whose reason holds `needle` ("stale tip" for a stale tip). +fn expect_decision( + failures: &mut Vec, + label: &str, + receiver: &LiveDevice, + sender: &LiveDevice, + step: &Step, + want: Option<&str>, +) -> Option { + match (receiver.decide(sender, step), want) { + (Decision::Accepted(verified), None) => return Some(verified), + (Decision::Accepted(_), Some(_)) => failures.push(format!("{label}: ACCEPTED")), + (Decision::StaleTip, Some("stale tip")) => {} + (decided, Some(needle)) + if decided + .refusal_at(Stage::Prepare) + .or(decided.refusal_at(Stage::Confirm)) + .is_some_and(|e| e.to_string().contains(needle)) => {} + (other, want) => failures.push(format!("{label}: {other}, wanted {want:?}")), + } + None +} + +fn trace_receiver_tripwire( _seed_bytes: &[u8; 32], _pk: &[u8], _sk: &[u8], ) -> ImplementationTraceResult { let start = Instant::now(); let mut failures = Vec::new(); - let mut tracker = ParentConsumptionTracker::new(); - - let parent = [0x71; 32]; - let child_a = [0x72; 32]; - let child_b = [0x73; 32]; + let (mut alice, mut bob) = trace_pair("receiver-tripwire"); - if let Err(e) = tracker.try_consume(parent, child_a) { - failures.push(format!("fresh parent rejected unexpectedly: {e}")); - } - - match tracker.try_consume(parent, child_a) { - Ok(()) => failures.push("replay was accepted by parent-consumption tracker".into()), - Err(e) => { - let msg = format!("{e}"); - if !msg.contains("replay detected") { - failures.push(format!("replay rejection message was too weak: {msg}")); - } - } - } - - match tracker.try_consume(parent, child_b) { - Ok(()) => failures.push("fork child was accepted by parent-consumption tracker".into()), - Err(e) => { - let msg = format!("{e}"); - if !msg.contains("Fork detected") { - failures.push(format!("fork rejection message was too weak: {msg}")); - } + let step_a = alice.propose(&bob, marked(0xA1)).expect("step a"); + let step_b = alice.propose(&bob, marked(0xB1)).expect("step b"); + let replayed = step_a.clone(); + let successor = step_a.successor_tip; + if let Some(verified) = expect_decision(&mut failures, "the step", &bob, &alice, &step_a, None) + { + if let Err(e) = commit(&mut alice, &mut bob, step_a, &verified) { + failures.push(format!("the step did not commit: {e}")); } } - - if tracker.get_child(&parent) != Some(&child_a) { - failures.push("canonical child mapping was overwritten after fork attempt".into()); - } - - ImplementationTraceResult { - trace_name: "tripwire_parent_consumption".into(), - steps: 3, - passed: failures.is_empty(), - failures, - duration_ms: start.elapsed().as_secs_f64() * 1000.0, - } -} - -/// The verifier's reason for refusing `receipt`, or `None` if it accepted. -fn refusal( - receipt: &StitchedReceiptV2, - ctx: &ReceiptVerificationContext, - tracker: &mut ParentConsumptionTracker, -) -> Result, String> { - match verify_stitched_receipt(receipt, ctx, tracker) { - Ok(a) if a.valid => Ok(None), - Ok(a) => Ok(Some(a.reason.unwrap_or_default())), - Err(e) => Err(format!("verifier error: {e}")), + if alice.shared_tip_with(&bob) != Some(successor) + || bob.shared_tip_with(&alice) != Some(successor) + { + failures.push("the two devices do not hold the committed successor".into()); } -} - -fn trace_receipt_verifier_tripwire( - _seed_bytes: &[u8; 32], - _pk: &[u8], - _sk: &[u8], -) -> ImplementationTraceResult { - let start = Instant::now(); - let mut failures = Vec::new(); - let (alice, bob) = trace_pair("receipt-tripwire"); - let root = alice.head.root(); - let mut tracker = ParentConsumptionTracker::new(); - - let op_a = alice.transfer(&bob, 5, &[0xA1; 8]).expect("transfer a"); - let op_b = alice.transfer(&bob, 6, &[0xB1; 8]).expect("transfer b"); - let child_a = alice.send(&bob, &op_a).expect("advance a"); - let child_b = alice.send(&bob, &op_b).expect("advance b"); - let receipt_a = stitched_receipt(&alice, &bob, &child_a).expect("receipt a"); - let receipt_b = stitched_receipt(&alice, &bob, &child_b).expect("receipt b"); - - let mut expect = |label: &str, - receipt: &StitchedReceiptV2, - op: &dsm::types::operations::Operation, - want: Option<&str>| match ( - refusal( - receipt, - &verification_context(&alice, &bob, root, op), - &mut tracker, - ), - want, - ) { - (Ok(None), None) => {} - (Ok(None), Some(_)) => failures.push(format!("{label}: ACCEPTED")), - (Ok(Some(reason)), None) => failures.push(format!("{label}: refused: {reason}")), - (Ok(Some(reason)), Some(needle)) if reason.contains(needle) => {} - (Ok(Some(reason)), Some(needle)) => { - failures.push(format!("{label}: refused without \"{needle}\": {reason}")) - } - (Err(e), _) => failures.push(format!("{label}: {e}")), - }; - - expect("the receipt", &receipt_a, &op_a, None); - expect("its replay", &receipt_a, &op_a, Some("replay detected")); - expect( - "a second child of the parent", - &receipt_b, - &op_b, - Some("Fork detected"), + expect_decision( + &mut failures, + "its replay", + &bob, + &alice, + &replayed, + Some("stale tip"), + ); + expect_decision( + &mut failures, + "a second child of the tip", + &bob, + &alice, + &step_b, + Some("stale tip"), ); - // Both parties re-sign a receipt whose writes no longer fold to the + // The sender signs a receipt whose writes no longer fold to the // pre-state root: one sibling changed. - let mut bent = receipt_a.clone(); - bent.step_writes[0].path.siblings[0] ^= 0x01; - bent.sig_a.clear(); - bent.sig_b.clear(); - let commitment = bent.compute_commitment().expect("commitment"); - bent.add_sig_a(alice.keypair.sign(&commitment).expect("sig a")); - bent.add_sig_b(bob.keypair.sign(&commitment).expect("sig b")); - expect( + let mut bent = alice.propose(&bob, marked(0xC1)).expect("step c"); + bent.receipt.step_writes[0].path.siblings[0] ^= 0x01; + let bent = bent.resigned().expect("re-signed"); + expect_decision( + &mut failures, "a signed receipt over a bent path", + &bob, + &alice, &bent, - &op_a, Some("do not fold to the claimed pre-root"), ); - - if tracker.get_child(&receipt_a.parent_tip) != Some(&receipt_a.child_tip) { - failures.push("the tracker lost the accepted child after the fork attempt".into()); + if bob.shared_tip_with(&alice) != Some(successor) { + failures.push("a refused step moved the receiver's tip".into()); } ImplementationTraceResult { - trace_name: "receipt_verifier_tripwire".into(), + trace_name: "receiver_tripwire".into(), steps: 4, passed: failures.is_empty(), failures, @@ -1057,58 +1008,62 @@ fn trace_tripwire_first_contact_binding( ) -> ImplementationTraceResult { let start = Instant::now(); let mut failures = Vec::new(); - let (mut alice, bob) = trace_pair("first-contact"); - let h0 = alice.tip_with(&bob).expect("established"); - let mut tracker = ParentConsumptionTracker::new(); - - // First contact: the relationship's first step extends h_0, which the - // advance seeds into the tree, so its parent path carries h_0. - let root0 = alice.head.root(); - let first_op = alice.transfer(&bob, 3, &[0x51; 8]).expect("transfer"); - let first = alice.send(&bob, &first_op).expect("first step"); - let alternate_op = alice.transfer(&bob, 4, &[0x52; 8]).expect("transfer"); - let alternate = alice - .send(&bob, &alternate_op) - .expect("alternate first step"); - let first_receipt = stitched_receipt(&alice, &bob, &first).expect("first receipt"); - let alternate_receipt = stitched_receipt(&alice, &bob, &alternate).expect("alternate receipt"); - if first_receipt.parent_tip != h0 { - failures.push("the first step does not extend the relationship's h_0".into()); + let (mut alice, mut bob) = trace_pair("first-contact"); + let h0 = bob.shared_tip_with(&alice).expect("established"); + if alice.shared_tip_with(&bob) != Some(h0) { + failures.push("the two devices do not hold one h_0".into()); } - alice.install(first); - let root1 = alice.head.root(); - let extension_op = alice.transfer(&bob, 5, &[0x53; 8]).expect("transfer"); - let extension = alice.send(&bob, &extension_op).expect("extension step"); - let extension_receipt = stitched_receipt(&alice, &bob, &extension).expect("extension receipt"); - - let first_ctx = verification_context(&alice, &bob, root0, &first_op); - let extension_ctx = verification_context(&alice, &bob, root1, &extension_op); - let alternate_ctx = verification_context(&alice, &bob, root0, &alternate_op); - match refusal(&first_receipt, &first_ctx, &mut tracker) { - Ok(None) => {} - Ok(Some(r)) => failures.push(format!("the first-contact receipt was refused: {r}")), - Err(e) => failures.push(e), - } - match refusal(&extension_receipt, &extension_ctx, &mut tracker) { - Ok(None) => {} - Ok(Some(r)) => failures.push(format!("the extension was refused: {r}")), - Err(e) => failures.push(e), + // First contact: the relationship's first step extends h_0; an + // alternate first step extends it too. + let first = alice.propose(&bob, marked(0x51)).expect("first step"); + let alternate = alice + .propose(&bob, marked(0x52)) + .expect("alternate first step"); + if first.expected_tip != h0 || alternate.expected_tip != h0 { + failures.push("a first step does not extend the relationship's h_0".into()); } - match refusal(&alternate_receipt, &alternate_ctx, &mut tracker) { - Ok(None) => failures.push("an alternate first-contact branch was accepted".into()), - Ok(Some(r)) if r.contains("Fork detected") => {} - Ok(Some(r)) => failures.push(format!( - "the alternate branch was refused without a fork: {r}" - )), - Err(e) => failures.push(e), + let first_successor = first.successor_tip; + if let Some(verified) = expect_decision( + &mut failures, + "the first-contact step", + &bob, + &alice, + &first, + None, + ) { + if let Err(e) = commit(&mut alice, &mut bob, first, &verified) { + failures.push(format!("the first-contact step did not commit: {e}")); + } } - if tracker.get_child(&h0) != Some(&first_receipt.child_tip) { - failures.push("first contact did not bind h_0 to the accepted first child".into()); + let extension = alice.propose(&bob, marked(0x53)).expect("extension step"); + if extension.expected_tip != first_successor { + failures.push("the extension does not extend the first step's successor".into()); + } + let extension_successor = extension.successor_tip; + if let Some(verified) = expect_decision( + &mut failures, + "the extension", + &bob, + &alice, + &extension, + None, + ) { + if let Err(e) = commit(&mut alice, &mut bob, extension, &verified) { + failures.push(format!("the extension did not commit: {e}")); + } } - if tracker.get_child(&first_receipt.child_tip) != Some(&extension_receipt.child_tip) { - failures.push("the extension did not anchor on the accepted child".into()); + expect_decision( + &mut failures, + "an alternate first-contact branch", + &bob, + &alice, + &alternate, + Some("stale tip"), + ); + if bob.shared_tip_with(&alice) != Some(extension_successor) { + failures.push("the receiver does not hold the extension's successor".into()); } ImplementationTraceResult { @@ -1968,8 +1923,12 @@ mod tests { } #[test] - fn receipt_verifier_tripwire_trace_passes() { - let result = trace_receipt_verifier_tripwire(&[0u8; 32], &[], &[]); + fn receiver_tripwire_trace_passes() { + let seed = dsm::crypto::blake3::domain_hash_bytes( + dsm::common::domain_tags::TAG_DSM_TRACE_DEVICE, + b"receiver-tripwire-trace", + ); + let result = trace_receiver_tripwire(&seed, &[], &[]); assert!(result.passed, "{}", result.failures.join("; ")); } diff --git a/tools/vertical_validation/src/live_device.rs b/tools/vertical_validation/src/live_device.rs index 30ee1a9a8..5623097df 100644 --- a/tools/vertical_validation/src/live_device.rs +++ b/tools/vertical_validation/src/live_device.rs @@ -6,41 +6,181 @@ //! advanced only by `DeviceState::advance`, with every operation signed by the //! device's SPHINCS+ key over `Operation::signing_bytes`. A transfer is two //! advances, the sender's debit and the recipient's credit, each on that -//! device's own chain for the relationship. Its stitched receipt is built from -//! the sender's outcome — the relationship path, both roots, the transition -//! entropy Core derived — and is checked by Core's receipt verifier. A harness -//! that disagrees with this module disagrees with production. +//! device's own chain for the relationship. +//! +//! A step between two devices is the offline bilateral protocol's step, as +//! production's sender builds it and production's receiver decides it +//! (`dsm::bilateral::offline`): the sender proposes the operation on the +//! relationship tip it holds, signed over the step's commitment; its stitched +//! receipt carries a per-step EK certified back to the sender's chain head; +//! the receiver decides with `decide_prepare` and `decide_confirm` against the +//! tip it holds and the keys its contact pins; and both commit, moving the +//! shared tip to the successor. A harness that disagrees with this module +//! disagrees with production. //! //! ERA enters a device through the faucet-claim advance, one protocol payout //! per claim. In production an economic admission precedes that advance; the //! harnesses exercise the transition, not the admission. +use std::collections::BTreeMap; + +use dsm::bilateral::identity_binding::binding_digest; +use dsm::bilateral::offline::{ + decide_confirm, decide_prepare, AcceptedStep, ConfirmClaims, PeerCredentials, PinnedPeer, + PrepareClaims, PrepareDecision, VerifiedConfirm, +}; use dsm::common::device_tree::DeviceTree; use dsm::common::domain_tags::{TAG_DSM_TRACE_DEVICE, TAG_DSM_TRACE_GENESIS}; -use dsm::core::bilateral_transaction_manager::compute_smt_key; +use dsm::core::bilateral_transaction_manager::{ + bilateral_sign_message, compute_precommit, compute_smt_key, compute_successor_tip, + initial_chain_tip_from_device_ids, BilateralPreCommitment, +}; use dsm::core::token::token_state_manager::era_policy_commit; use dsm::crypto::blake3::domain_hash_bytes; use dsm::crypto::ephemeral_key::{generate_ephemeral_keypair, sign_ek_cert}; +use dsm::crypto::kyber::generate_kyber_keypair_from_entropy; use dsm::crypto::signatures::SignatureKeyPair; +use dsm::crypto::sphincs::sphincs_sign; use dsm::economic::native_reserve::{era_reserve_id, ERA_FAUCET_PAYOUT}; use dsm::types::device_state::{AdvanceOutcome, BalanceDelta, BalanceDirection, DeviceState}; use dsm::types::error::DsmError; use dsm::types::operations::{Operation, TransactionMode}; use dsm::types::receipt_types::{ - DeviceTreeAcceptanceCommitment, ReceiptVerificationContext, StitchedReceiptV2, + compute_receipt_challenge_response_target, DeviceTreeAcceptanceCommitment, StitchedReceiptV2, }; use dsm::types::token_types::Balance; -/// One device: identity, signing key, the chain head its per-step keys -/// certify back to (§11.1), and its canonical head. +/// One device: identity, its AK and the Kyber key bound to it, its canonical +/// head, and, per relationship, what production keeps durably beside the +/// head: the shared tip it holds, its own per-step EK chain head, and the +/// counterparty's. pub struct LiveDevice { pub label: String, pub devid: [u8; 32], pub genesis: [u8; 32], pub keypair: SignatureKeyPair, - chain_head_pk: Vec, - chain_head_sk: Vec, + /// The device's Kyber keypair, `(pk, sk)`, and its AK's binding of the + /// public key to the device's identity. + kyber: (Vec, Vec), + kyber_binding_sig: Vec, pub head: DeviceState, + /// Counterparty -> the relationship tip this device holds. + shared_tips: BTreeMap<[u8; 32], [u8; 32]>, + /// Counterparty -> this device's newest EK on the relationship, `(pk, sk)`. + own_ek_heads: BTreeMap<[u8; 32], (Vec, Vec)>, + /// Counterparty -> the counterparty's newest EK on the relationship. + peer_ek_heads: BTreeMap<[u8; 32], Vec>, + /// Steps this device has proposed, so each per-step EK is its own. + proposals: u64, +} + +/// One step of the offline bilateral protocol as its sender built it: the +/// proposal and the confirm, and the sender's advance, not installed. +#[derive(Clone)] +pub struct Step { + pub operation: Operation, + pub sender_outcome: AdvanceOutcome, + /// The relationship tip the proposal extends. + pub expected_tip: [u8; 32], + pub commitment_hash: [u8; 32], + /// σ_A: the sender's AK over the step's commitment. + pub sender_signature: Vec, + pub receipt: StitchedReceiptV2, + pub pre_entropy: [u8; 32], + pub successor_tip: [u8; 32], + /// The per-step EK the receipt is signed with, `(pk, sk)`. + ek: (Vec, Vec), +} + +impl Step { + /// This step with its receipt signed by a fresh EK that `certifier`'s AK + /// certifies over the parent tip: an EK the sender's own chain never + /// certified, every other artifact of the receipt genuine. + pub fn with_ek_certified_by(mut self, certifier: &LiveDevice) -> Result { + let ek = generate_ephemeral_keypair(&domain_hash_bytes( + TAG_DSM_TRACE_DEVICE, + format!("{}/foreign-ek", certifier.label).as_bytes(), + ))?; + let parent_tip = self.receipt.parent_tip; + self.receipt.set_ek_pk_a(ek.0.clone()); + self.receipt.set_ek_cert_a(sign_ek_cert( + certifier.keypair.secret_key(), + &ek.0, + &parent_tip, + )?); + self.ek = ek; + self.resigned() + } + + /// This step with its receipt re-signed by the step's own per-step EK + /// over its current bytes: what a sender that alters its own receipt can + /// sign. + pub fn resigned(mut self) -> Result { + self.receipt.sig_a.clear(); + let target = compute_receipt_challenge_response_target( + &self.receipt.compute_commitment()?, + &self.commitment_hash, + ); + self.receipt.add_sig_a(sphincs_sign(&self.ek.1, &target)?); + Ok(self) + } +} + +/// The receiver's decision on a step, as Core makes it. +#[derive(Debug)] +pub enum Decision { + /// Both `decide_prepare` and `decide_confirm` accept the step. + Accepted(VerifiedConfirm), + /// `decide_prepare`: the proposal does not extend the tip the receiver + /// holds. + StaleTip, + /// `decide_prepare` answered neither `Consider` nor `StaleTip`. + Unconsidered(PrepareDecision), + /// `decide_prepare` refused the proposal. + PrepareRefused(DsmError), + /// The receipt could not be encoded for the confirm. + Unencodable(DsmError), + /// `decide_confirm` refused the confirm. + ConfirmRefused(DsmError), + /// The sender is not this device's contact. + NotAContact, +} + +impl std::fmt::Display for Decision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Decision::Accepted(verified) => write!( + f, + "accepted, successor {}", + dsm::utils::text_id::encode_base32_crockford(&verified.successor_tip) + ), + Decision::StaleTip => write!(f, "refused: a stale tip"), + Decision::Unconsidered(answer) => write!(f, "prepare answered {answer:?}"), + Decision::PrepareRefused(e) => write!(f, "prepare refused: {e}"), + Decision::Unencodable(e) => write!(f, "the receipt does not encode: {e}"), + Decision::ConfirmRefused(e) => write!(f, "confirm refused: {e}"), + Decision::NotAContact => write!(f, "the sender is not a contact"), + } + } +} + +/// The two decisions the receiver makes on a step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stage { + Prepare, + Confirm, +} + +impl Decision { + /// The error `stage` refused the step with, when this is that stage's + /// refusal. + pub fn refusal_at(&self, stage: Stage) -> Option<&DsmError> { + match (self, stage) { + (Decision::PrepareRefused(e), Stage::Prepare) + | (Decision::ConfirmRefused(e), Stage::Confirm) => Some(e), + _ => None, + } + } } impl LiveDevice { @@ -51,19 +191,24 @@ impl LiveDevice { SignatureKeyPair::generate_from_entropy(format!("vv/device/{label}").as_bytes())?; let devid = domain_hash_bytes(TAG_DSM_TRACE_DEVICE, label.as_bytes()); let genesis = domain_hash_bytes(TAG_DSM_TRACE_GENESIS, label.as_bytes()); - let (chain_head_pk, chain_head_sk) = generate_ephemeral_keypair(&domain_hash_bytes( - TAG_DSM_TRACE_DEVICE, - format!("{label}/chain-head").as_bytes(), - ))?; + let kyber = generate_kyber_keypair_from_entropy( + &domain_hash_bytes(TAG_DSM_TRACE_DEVICE, format!("{label}/kyber").as_bytes()), + "vv/device/kyber", + )?; + let kyber_binding_sig = keypair.sign(&binding_digest(&devid, &genesis, &kyber.0))?; let head = DeviceState::new(genesis, devid, keypair.public_key.clone()); Ok(Self { label: label.to_string(), devid, genesis, keypair, - chain_head_pk, - chain_head_sk, + kyber, + kyber_binding_sig, head, + shared_tips: BTreeMap::new(), + own_ek_heads: BTreeMap::new(), + peer_ek_heads: BTreeMap::new(), + proposals: 0, }) } @@ -83,15 +228,54 @@ impl LiveDevice { } /// Establish the relationship with `other` on this device, as adding a - /// contact does: its leaf enters the tree at `h_0`. A relationship - /// already established stays as it is. + /// contact does: its leaf enters the tree at `h_0`, and the contact holds + /// the shared tip `h_0`. A relationship already established stays as it + /// is. pub fn establish(&mut self, other: &LiveDevice) -> Result<(), DsmError> { - if self.tip_with(other).is_none() { - self.head = self.head.establish_relationship(other.devid)?; + self.establish_with(other.devid) + } + + /// [`Self::establish`] with the device whose id is `other`. + pub fn establish_with(&mut self, other: [u8; 32]) -> Result<(), DsmError> { + if self + .head + .chain_tip(&compute_smt_key(&self.devid, &other)) + .is_none() + { + self.head = self.head.establish_relationship(other)?; + self.shared_tips.insert( + other, + initial_chain_tip_from_device_ids(&self.devid, &other), + ); } Ok(()) } + /// The shared relationship tip this device holds for `other`, as its + /// contact record keeps it. + pub fn shared_tip_with(&self, other: &LiveDevice) -> Option<[u8; 32]> { + self.shared_tips.get(&other.devid).copied() + } + + /// This device as a counterparty's contact record pins it. + pub fn pinned(&self) -> PinnedPeer<'_> { + PinnedPeer { + device_id: self.devid, + genesis: self.genesis, + signing_key: &self.keypair.public_key, + kyber_public_key: &self.kyber.0, + } + } + + /// The keys this device sends with a proposal. + pub fn credentials(&self) -> PeerCredentials<'_> { + PeerCredentials { + signing_key: &self.keypair.public_key, + kyber_public_key: &self.kyber.0, + kyber_binding_sig: &self.kyber_binding_sig, + } + } + /// This device's tip for the relationship with `other`, once established. pub fn tip_with(&self, other: &LiveDevice) -> Option<[u8; 32]> { self.head.chain_tip(&self.rel_key(other)) @@ -122,40 +306,152 @@ impl LiveDevice { Ok(op.with_signature(signature)) } - /// The sender's advance for `op`: one debit of its amount on the - /// relationship with `to`. Not installed. + /// The sender's advance for `op` on the relationship with `to`: a + /// transfer debits its amount, any other operation moves no value. Not + /// installed. pub fn send(&self, to: &LiveDevice, op: &Operation) -> Result { self.head.advance( self.rel_key(to), to.devid, op.clone(), - &[BalanceDelta { - policy_commit: era_policy_commit(), - direction: BalanceDirection::Debit, - amount: transfer_amount(op)?, - }], + value_moved(op, BalanceDirection::Debit)?.as_slice(), None, None, ) } - /// The recipient's advance for `op`: one credit of its amount on the - /// relationship with `from`. Not installed. + /// The recipient's advance for `op` on the relationship with `from`: a + /// transfer credits its amount, any other operation moves no value. Not + /// installed. pub fn receive(&self, from: &LiveDevice, op: &Operation) -> Result { self.head.advance( self.rel_key(from), from.devid, op.clone(), - &[BalanceDelta { - policy_commit: era_policy_commit(), - direction: BalanceDirection::Credit, - amount: transfer_amount(op)?, - }], + value_moved(op, BalanceDirection::Credit)?.as_slice(), None, None, ) } + /// The step this device proposes to `to`: `operation` on the shared tip + /// it holds, as production's sender builds the proposal and the confirm. + /// The commitment binds the tip and the operation; σ_A is the AK's + /// signature over it; the receipt is the sender's stitched receipt of its + /// advance, signed by a fresh per-step EK that the sender's newest EK on + /// the relationship (its AK at the first step) certifies over the parent + /// tip; and the successor tip is reproduced from the tip, the operation + /// and the entropy the advance derived. + pub fn propose(&mut self, to: &LiveDevice, operation: Operation) -> Result { + let expected_tip = self + .shared_tip_with(to) + .ok_or_else(|| DsmError::relationship("the counterparty is not a contact"))?; + let commitment_hash = + BilateralPreCommitment::new(expected_tip, operation.clone()).bilateral_commitment_hash; + let sender_signature = self + .keypair + .sign(&bilateral_sign_message(&commitment_hash))?; + let sender_outcome = self.send(to, &operation)?; + + self.proposals += 1; + let ek = generate_ephemeral_keypair(&domain_hash_bytes( + TAG_DSM_TRACE_DEVICE, + format!("{}/ek/{}", self.label, self.proposals).as_bytes(), + ))?; + let mut receipt = StitchedReceiptV2::of_step( + self.genesis, + self.devid, + to.devid, + &sender_outcome, + None, + &self.device_tree_commitment(), + )?; + let parent_tip = receipt.parent_tip; + let certifier = match self.own_ek_heads.get(&to.devid) { + Some((_, sk)) => sk.clone(), + None => self.keypair.secret_key().to_vec(), + }; + receipt.set_ek_pk_a(ek.0.clone()); + receipt.set_ek_cert_a(sign_ek_cert(&certifier, &ek.0, &parent_tip)?); + let target = compute_receipt_challenge_response_target( + &receipt.compute_commitment()?, + &commitment_hash, + ); + receipt.add_sig_a(sphincs_sign(&ek.1, &target)?); + + let pre_entropy = sender_outcome.transition_entropy(); + let op_bytes = operation.to_bytes(); + let c_pre = compute_precommit(&expected_tip, &op_bytes, &pre_entropy); + let successor_tip = compute_successor_tip(&expected_tip, &op_bytes, &pre_entropy, &c_pre); + Ok(Step { + operation, + sender_outcome, + expected_tip, + commitment_hash, + sender_signature, + receipt, + pre_entropy, + successor_tip, + ek, + }) + } + + /// This device's decision, as receiver, on `step` from `from`: Core's + /// `decide_prepare` against the shared tip it holds and the keys its + /// contact pins, then `decide_confirm` against the sender's Device Tree + /// commitment and the sender's EK chain head it keeps. + pub fn decide(&self, from: &LiveDevice, step: &Step) -> Decision { + let Some(held_tip) = self.shared_tip_with(from) else { + return Decision::NotAContact; + }; + let prepare = decide_prepare( + step.commitment_hash, + &step.operation, + PrepareClaims { + addressed_to: &self.devid, + expected_tip: Some(step.expected_tip), + credentials: from.credentials(), + signature: &step.sender_signature, + }, + &from.pinned(), + &self.devid, + held_tip, + None, + ); + match prepare { + Ok(PrepareDecision::Consider { .. }) => {} + Ok(PrepareDecision::StaleTip { .. }) => return Decision::StaleTip, + Ok(other) => return Decision::Unconsidered(other), + Err(e) => return Decision::PrepareRefused(e), + } + // The confirm carries the signed receipt: its full wire bytes. + let receipt = match step.receipt.to_full_protobuf() { + Ok(bytes) => bytes, + Err(e) => return Decision::Unencodable(e), + }; + match decide_confirm( + ConfirmClaims { + signature: &step.sender_signature, + receipt: &receipt, + pre_entropy: &step.pre_entropy, + successor_tip: Some(step.successor_tip), + }, + AcceptedStep { + commitment_hash: step.commitment_hash, + operation: &step.operation, + held_tip, + receiver_device_id: self.devid, + sender_device_tree_root: DeviceTree::single(from.devid).root(), + sender_chain_head: self.peer_ek_heads.get(&from.devid).map(Vec::as_slice), + bearer: None, + }, + &from.pinned(), + ) { + Ok(verified) => Decision::Accepted(verified), + Err(e) => Decision::ConfirmRefused(e), + } + } + /// Install an advance this device computed. pub fn install(&mut self, outcome: AdvanceOutcome) { self.head = outcome.new_device_state; @@ -174,6 +470,59 @@ pub fn connect(a: &mut LiveDevice, b: &mut LiveDevice) -> Result<(), DsmError> { b.establish(a) } +/// Commit a step the receiver accepted, on both devices, as production does: +/// the receiver's advance and the sender's are installed, both hold the +/// verified successor as the relationship's tip, and each keeps the step's EK +/// as the sender's chain head on the relationship. +pub fn commit( + sender: &mut LiveDevice, + receiver: &mut LiveDevice, + step: Step, + verified: &VerifiedConfirm, +) -> Result<(), DsmError> { + let received = receiver.receive(sender, &step.operation)?; + receiver.install(received); + receiver + .shared_tips + .insert(sender.devid, verified.successor_tip); + receiver + .peer_ek_heads + .insert(sender.devid, step.ek.0.clone()); + sender.install(step.sender_outcome); + sender + .shared_tips + .insert(receiver.devid, verified.successor_tip); + sender.own_ek_heads.insert(receiver.devid, step.ek); + Ok(()) +} + +/// A non-value operation the offline protocol carries, marked `mark`, as +/// production's offline step tests carry one. +pub fn marked(mark: u8) -> Operation { + Operation::Generic { + operation_type: b"vertical-validation-step".to_vec(), + data: vec![mark], + message: String::new(), + signature: Vec::new(), + } +} + +/// The balance a step's operation moves: a transfer's amount in `direction`; +/// any other operation moves none. +fn value_moved( + op: &Operation, + direction: BalanceDirection, +) -> Result, DsmError> { + match op { + Operation::Transfer { .. } => Ok(Some(BalanceDelta { + policy_commit: era_policy_commit(), + direction, + amount: transfer_amount(op)?, + })), + _ => Ok(None), + } +} + /// One faucet claim on `head`'s self-loop: the protocol payout of ERA, as the /// release at `generation` (never 0, the reserve's genesis) of the network's /// reserve. @@ -207,57 +556,3 @@ pub fn transfer_amount(op: &Operation) -> Result { ))), } } - -/// The stitched receipt of the step `outcome` records on `sender`'s device, -/// countersigned by `receiver`, each signature under a key certified back to -/// its signer's chain head over the parent tip. -pub fn stitched_receipt( - sender: &LiveDevice, - receiver: &LiveDevice, - outcome: &AdvanceOutcome, -) -> Result { - let mut receipt = StitchedReceiptV2::of_step( - sender.genesis, - sender.devid, - receiver.devid, - outcome, - None, - &sender.device_tree_commitment(), - )?; - let parent_tip = receipt.parent_tip; - receipt.set_ek_cert_a(sign_ek_cert( - &sender.chain_head_sk, - &sender.keypair.public_key, - &parent_tip, - )?); - receipt.set_ek_cert_b(sign_ek_cert( - &receiver.chain_head_sk, - &receiver.keypair.public_key, - &parent_tip, - )?); - let commitment = receipt.compute_commitment()?; - receipt.add_sig_a(sender.keypair.sign(&commitment)?); - receipt.add_sig_b(receiver.keypair.sign(&commitment)?); - Ok(receipt) -} - -/// What a verifier expecting `sender`'s step of `operation` from -/// `parent_root` holds: the sender's authenticated Device Tree commitment and -/// genesis, the step's operation, and both chain heads. -pub fn verification_context( - sender: &LiveDevice, - receiver: &LiveDevice, - parent_root: [u8; 32], - operation: &Operation, -) -> ReceiptVerificationContext { - ReceiptVerificationContext::new( - sender.device_tree_commitment(), - parent_root, - sender.keypair.public_key.clone(), - receiver.keypair.public_key.clone(), - sender.genesis, - operation.clone(), - ) - .with_chain_head_a(sender.chain_head_pk.clone()) - .with_chain_head_b(receiver.chain_head_pk.clone()) -} diff --git a/tools/vertical_validation/src/property_tests.rs b/tools/vertical_validation/src/property_tests.rs index 2355b9a98..af6ba2cc1 100644 --- a/tools/vertical_validation/src/property_tests.rs +++ b/tools/vertical_validation/src/property_tests.rs @@ -21,10 +21,8 @@ use serde::Serialize; use dsm::common::domain_tags::{TAG_DSM_GENESIS_ENTROPY, TAG_DSM_STATE_ENTROPY}; use dsm::crypto::blake3::dsm_domain_hasher; use dsm::crypto::sphincs::{sphincs_sign, sphincs_verify}; -use dsm::types::receipt_types::ParentConsumptionTracker; -use dsm::verification::receipt_verification::verify_stitched_receipt; -use crate::live_device::{connect, stitched_receipt, verification_context, LiveDevice}; +use crate::live_device::{commit, connect, marked, Decision, LiveDevice}; // --------------------------------------------------------------------------- // Result types @@ -330,52 +328,54 @@ fn overspend_refused(iterations: u64, seed: u64) -> PropertyTestResult { } // --------------------------------------------------------------------------- -// Property 5: of two children of one parent, the verifier accepts one +// Property 5: of two children of one tip, the receiver takes one // --------------------------------------------------------------------------- fn fork_exclusion(iterations: u64, seed: u64) -> PropertyTestResult { let start = Instant::now(); let mut failures = Vec::new(); let mut rng = ChaCha20Rng::seed_from_u64(seed ^ 0x464f_524b); - let (mut alice, bob) = funded_pair(1); - let mut tracker = ParentConsumptionTracker::new(); + let (mut alice, mut bob) = funded_pair(1); for i in 0..iterations { - let op_a = alice - .transfer(&bob, 1, &random_nonce(&mut rng)) - .expect("transfer a"); - let op_b = alice - .transfer(&bob, 2, &random_nonce(&mut rng)) - .expect("transfer b"); - let (Ok(child_a), Ok(child_b)) = (alice.send(&bob, &op_a), alice.send(&bob, &op_b)) else { - failures.push(format!("iter {i}: advance refused")); - continue; + // Two different operations proposed on one tip: two children of it. + let mark = random_nonce(&mut rng)[0] & 0xFE; + let (child_a, child_b) = match ( + alice.propose(&bob, marked(mark)), + alice.propose(&bob, marked(mark | 1)), + ) { + (Ok(a), Ok(b)) => (a, b), + (Err(e), _) | (_, Err(e)) => { + failures.push(format!("iter {i}: a step could not be proposed: {e}")); + continue; + } }; - let receipt_a = stitched_receipt(&alice, &bob, &child_a).expect("receipt a"); - let receipt_b = stitched_receipt(&alice, &bob, &child_b).expect("receipt b"); - if receipt_a.child_tip == receipt_b.child_tip { + if child_a.successor_tip == child_b.successor_tip { failures.push(format!( - "iter {i}: two different operations produced one child tip" + "iter {i}: two different operations produced one successor tip" )); } - let ctx_a = verification_context(&alice, &bob, alice.head.root(), &op_a); - let ctx_b = verification_context(&alice, &bob, alice.head.root(), &op_b); - match verify_stitched_receipt(&receipt_a, &ctx_a, &mut tracker) { - Ok(a) if a.valid => {} - Ok(a) => failures.push(format!( - "iter {i}: the first child was refused: {}", - a.reason.unwrap_or_default() - )), - Err(e) => failures.push(format!("iter {i}: verifier error: {e}")), + match bob.decide(&alice, &child_a) { + Decision::Accepted(verified) => { + if let Err(e) = commit(&mut alice, &mut bob, child_a, &verified) { + failures.push(format!("iter {i}: the first child did not commit: {e}")); + continue; + } + } + other => { + failures.push(format!("iter {i}: the first child was refused: {other}")); + continue; + } } - match verify_stitched_receipt(&receipt_b, &ctx_b, &mut tracker) { - Ok(a) if a.valid => failures.push(format!( - "iter {i}: FORK ACCEPTED: a second child of one parent" + match bob.decide(&alice, &child_b) { + Decision::Accepted(_) => failures.push(format!( + "iter {i}: FORK ACCEPTED: a second child of one tip" + )), + Decision::StaleTip => {} + other => failures.push(format!( + "iter {i}: the second child was refused for another reason: {other}" )), - Ok(_) => {} - Err(e) => failures.push(format!("iter {i}: verifier error: {e}")), } - alice.install(child_a); } finish("fork_exclusion", iterations, failures, start) } diff --git a/tools/vertical_validation/src/tla_runner.rs b/tools/vertical_validation/src/tla_runner.rs index 21178ee41..d496cad92 100644 --- a/tools/vertical_validation/src/tla_runner.rs +++ b/tools/vertical_validation/src/tla_runner.rs @@ -401,8 +401,7 @@ impl TlaRunner { ], properties: vec![], linked_implementation_traces: vec![ - "tripwire_parent_consumption".into(), - "receipt_verifier_tripwire".into(), + "receiver_tripwire".into(), "tripwire_first_contact_binding".into(), "bilateral_precommit_tripwire".into(), "bilateral_precomputed_finalize_hash".into(), diff --git a/tools/vertical_validation/src/tla_trace_replay.rs b/tools/vertical_validation/src/tla_trace_replay.rs index fe67fb6aa..04492ff36 100644 --- a/tools/vertical_validation/src/tla_trace_replay.rs +++ b/tools/vertical_validation/src/tla_trace_replay.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context}; use serde::Serialize; -use crate::live_device::{stitched_receipt, verification_context, LiveDevice}; +use crate::live_device::{commit, connect, marked, Decision, LiveDevice}; use crate::tla_runner::TlaSpec; use dsm::crypto::blake3::{domain_hash, domain_hash_bytes}; use dsm::crypto::kyber::generate_kyber_keypair_from_entropy; @@ -22,8 +22,6 @@ use dsm::crypto::sphincs::{generate_keypair_from_seed, SphincsVariant}; use dsm::economic::native_reserve::ERA_FAUCET_PAYOUT; use dsm::emissions::{JoinActivationProof, SourceDlvState}; use dsm::types::operations::Operation; -use dsm::types::receipt_types::ParentConsumptionTracker; -use dsm::verification::receipt_verification::verify_stitched_receipt; #[derive(Debug, Clone, Serialize)] pub struct TlaTraceReplayResult { @@ -260,7 +258,6 @@ struct DsmImplementationHarness { /// `send_group -> (sender, amount)`. undelivered: BTreeMap, ledger: BTreeSet, - parent_tracker: ParentConsumptionTracker, next_msg_id: i64, storage_nodes: BTreeSet, activated_devices: BTreeSet, @@ -339,7 +336,6 @@ impl DsmImplementationHarness { pending_messages: Vec::new(), undelivered: BTreeMap::new(), ledger, - parent_tracker: ParentConsumptionTracker::new(), next_msg_id: int_var(initial, "nextMsgId").unwrap_or(0), storage_nodes: cloned_set_var(initial, "storageNodes"), activated_devices: cloned_set_var(initial, "activatedDevices"), @@ -516,17 +512,10 @@ impl DsmImplementationHarness { .devices .get_mut(me) .ok_or_else(|| anyhow!("unknown device {}", me.display()))?; - let rel_key = dsm::core::bilateral_transaction_manager::compute_smt_key( - &direct.live.devid, - &other_devid, - ); - if direct.live.head.chain_tip(&rel_key).is_none() { - direct.live.head = direct - .live - .head - .establish_relationship(other_devid) - .map_err(|e| anyhow!("establishing the relationship: {e}"))?; - } + direct + .live + .establish_with(other_devid) + .map_err(|e| anyhow!("establishing the relationship: {e}"))?; } Ok(()) } @@ -662,53 +651,24 @@ impl DsmImplementationHarness { // established on both devices before its first step. self.establish_pair(&pending.from, &pending.to)?; - // The step, as production runs it: the sender's advance, the receipt - // both parties sign, the recipient's verifier with its parent tracker, - // and the recipient's credit. - let (sender_outcome, receipt, receiver_outcome) = { - let sender = &self - .devices - .get(&pending.from) - .ok_or_else(|| anyhow!("unknown sender {}", pending.from.display()))? - .live; - let recipient = &self - .devices - .get(&pending.to) - .ok_or_else(|| anyhow!("unknown recipient {}", pending.to.display()))? - .live; - let sender_outcome = sender - .send(recipient, &pending.operation) - .map_err(|e| anyhow!("the sender's advance was refused: {e}"))?; - let receipt = stitched_receipt(sender, recipient, &sender_outcome) - .map_err(|e| anyhow!("the step's receipt could not be built: {e}"))?; - let ctx = - verification_context(sender, recipient, sender.head.root(), &pending.operation); - match verify_stitched_receipt(&receipt, &ctx, &mut self.parent_tracker) { - Ok(acceptance) if acceptance.valid => {} - Ok(acceptance) => bail!( - "the recipient refused the step's receipt: {}", - acceptance.reason.unwrap_or_default() - ), - Err(e) => bail!("the receipt verifier errored: {e}"), - } - let receiver_outcome = recipient - .receive(sender, &pending.operation) - .map_err(|e| anyhow!("the recipient's advance was refused: {e}"))?; - (sender_outcome, receipt, receiver_outcome) - }; - if receipt.child_tip == receipt.parent_tip { - bail!("the delivered step did not move the sender's tip"); + // The step, as production runs it: the sender proposes it on the tip + // it holds, the recipient decides it with Core's decisions against + // the tip it holds, and both commit it. + if pending.from == pending.to { + bail!("net_deliver of a step from a device to itself"); } - self.devices - .get_mut(&pending.from) - .ok_or_else(|| anyhow!("missing sender"))? - .live - .install(sender_outcome); - self.devices - .get_mut(&pending.to) - .ok_or_else(|| anyhow!("missing recipient"))? - .live - .install(receiver_outcome); + let mut sender = self + .devices + .remove(&pending.from) + .ok_or_else(|| anyhow!("unknown sender {}", pending.from.display()))?; + let Some(mut recipient) = self.devices.remove(&pending.to) else { + self.devices.insert(pending.from.clone(), sender); + bail!("unknown recipient {}", pending.to.display()); + }; + let delivered = deliver_step(&mut sender.live, &mut recipient.live, &pending.operation); + self.devices.insert(pending.from.clone(), sender); + self.devices.insert(pending.to.clone(), recipient); + delivered?; self.relationships .get_mut(&key) @@ -1122,13 +1082,45 @@ impl DsmImplementationHarness { } } +/// One step of `operation` from `sender` to `recipient`, as production runs +/// it: proposed on the sender's tip, decided by the recipient with Core's +/// `decide_prepare` and `decide_confirm`, and committed on both. +fn deliver_step( + sender: &mut LiveDevice, + recipient: &mut LiveDevice, + operation: &Operation, +) -> anyhow::Result<()> { + let step = sender + .propose(recipient, operation.clone()) + .map_err(|e| anyhow!("the step could not be proposed: {e}"))?; + if step.receipt.child_tip == step.receipt.parent_tip { + bail!("the delivered step did not move the sender's tip"); + } + match recipient.decide(sender, &step) { + Decision::Accepted(verified) => commit(sender, recipient, step, &verified) + .map_err(|e| anyhow!("the step did not commit: {e}")), + other => bail!("the recipient refused the step: {other}"), + } +} + +/// A Tripwire trace replayed on production's receiver: every relation of the +/// model is a relationship between two real devices, and every receipt the +/// model adds is a step one of them proposes and the other decides with +/// Core's decisions and commits. The model's tips map onto the shared tips +/// the devices hold: a receipt must extend the tip its `oldTip` maps to, and +/// its `newTip` maps to the successor both devices then hold. +#[derive(Default)] +struct TripwireImplementation { + devices: BTreeMap, + tips: BTreeMap<(TlaValue, TlaValue), BTreeMap>, +} + fn replay_tripwire_trace_into_implementation(states: &[TlaState]) -> Vec { let mut failures = Vec::new(); - let mut tracker = ParentConsumptionTracker::new(); + let mut implementation = TripwireImplementation::default(); for (idx, pair) in states.windows(2).enumerate() { - if let Err(err) = replay_tripwire_step_into_implementation(&mut tracker, &pair[0], &pair[1]) - { + if let Err(err) = implementation.replay_step(&pair[0], &pair[1]) { failures.push(format!("step {}: {err}", idx + 1)); } } @@ -1136,39 +1128,97 @@ fn replay_tripwire_trace_into_implementation(states: &[TlaState]) -> Vec failures } -fn replay_tripwire_step_into_implementation( - tracker: &mut ParentConsumptionTracker, - current: &TlaState, - next: &TlaState, -) -> anyhow::Result<()> { - // First require the TLC step to satisfy the abstract Tripwire transition: - // current root revisions must match the receipt anchors, and the relation-local - // SMT tips must advance deterministically for both participants. - replay_tripwire_step(current, next)?; +impl TripwireImplementation { + fn replay_step(&mut self, current: &TlaState, next: &TlaState) -> anyhow::Result<()> { + // First require the TLC step to satisfy the abstract Tripwire + // transition: current root revisions must match the receipt anchors, + // and the relation-local SMT tips must advance deterministically for + // both participants. + replay_tripwire_step(current, next)?; - let current_ledger = set_var(current, "ledger")?; - let next_ledger = set_var(next, "ledger")?; - let added = set_difference(next_ledger, current_ledger); - let receipt = added - .first() - .ok_or_else(|| anyhow!("Tripwire implementation replay expected one added receipt"))?; - let record = record_fields(receipt)?; - let rel_devices = set_items( - record - .get("rel") - .ok_or_else(|| anyhow!("Tripwire receipt missing rel"))?, - )?; - if rel_devices.len() != 2 { - bail!("Tripwire implementation replay expected binary relation"); - } - let old_tip = receipt_int(record, "oldTip")?; - let new_tip = receipt_int(record, "newTip")?; - let parent_hash = tripwire_transition_hash(&rel_devices, old_tip); - let child_hash = tripwire_transition_hash(&rel_devices, new_tip); - tracker - .try_consume(parent_hash, child_hash) - .map_err(|e| anyhow!("ParentConsumptionTracker rejected Tripwire receipt: {e}"))?; - Ok(()) + let current_ledger = set_var(current, "ledger")?; + let next_ledger = set_var(next, "ledger")?; + let added = set_difference(next_ledger, current_ledger); + let receipt = added + .first() + .ok_or_else(|| anyhow!("Tripwire implementation replay expected one added receipt"))?; + let record = record_fields(receipt)?; + let rel_devices = set_items( + record + .get("rel") + .ok_or_else(|| anyhow!("Tripwire receipt missing rel"))?, + )?; + let [proposer, decider] = rel_devices.as_slice() else { + bail!("Tripwire implementation replay expected binary relation"); + }; + let old_tip = receipt_int(record, "oldTip")?; + let new_tip = receipt_int(record, "newTip")?; + + for device in [proposer, decider] { + if !self.devices.contains_key(device) { + let live = LiveDevice::new(&format!("tla-tripwire-{}", tla_atom(device)?)) + .map_err(|e| anyhow!("device {}: {e}", device.display()))?; + self.devices.insert(device.clone(), live); + } + } + let mut sender = self + .devices + .remove(proposer) + .ok_or_else(|| anyhow!("missing device {}", proposer.display()))?; + let Some(mut recipient) = self.devices.remove(decider) else { + self.devices.insert(proposer.clone(), sender); + bail!("missing device {}", decider.display()); + }; + let tips = self + .tips + .entry((proposer.clone(), decider.clone())) + .or_default(); + let replayed = Self::step(tips, &mut sender, &mut recipient, old_tip, new_tip); + self.devices.insert(proposer.clone(), sender); + self.devices.insert(decider.clone(), recipient); + replayed + } + + fn step( + tips: &mut BTreeMap, + sender: &mut LiveDevice, + recipient: &mut LiveDevice, + old_tip: i64, + new_tip: i64, + ) -> anyhow::Result<()> { + connect(sender, recipient).map_err(|e| anyhow!("contacts: {e}"))?; + let held = recipient + .shared_tip_with(sender) + .ok_or_else(|| anyhow!("the recipient holds no tip for the sender"))?; + match tips.get(&old_tip) { + Some(mapped) if *mapped == held => {} + Some(_) => bail!("the model's tip {old_tip} is not the tip the recipient holds"), + None if tips.is_empty() => { + tips.insert(old_tip, held); + } + None => { + bail!("the receipt extends model tip {old_tip}, which no committed step produced") + } + } + // The model's tip names the step's operation: its low byte marks it. + let step = sender + .propose(recipient, marked(new_tip.to_le_bytes()[0])) + .map_err(|e| anyhow!("the step could not be proposed: {e}"))?; + let successor = step.successor_tip; + match recipient.decide(sender, &step) { + Decision::Accepted(verified) => commit(sender, recipient, step, &verified) + .map_err(|e| anyhow!("the step did not commit: {e}"))?, + other => { + bail!("production refused the model's receipt {old_tip}->{new_tip}: {other}") + } + } + match tips.insert(new_tip, successor) { + Some(previous) if previous != successor => { + bail!("the model's tip {new_tip} maps to two different successors") + } + _ => Ok(()), + } + } } fn replay_dsm_trace_into_implementation(states: &[TlaState]) -> Vec { @@ -1240,17 +1290,6 @@ fn canonical_pair_key(left: &TlaValue, right: &TlaValue) -> (TlaValue, TlaValue) } } -fn tripwire_transition_hash(devices: &[TlaValue], tip: i64) -> [u8; 32] { - let mut labels: Vec = devices - .iter() - .map(|device| format!("{}", device.display())) - .collect(); - labels.sort(); - let mut bytes = labels.join("|").into_bytes(); - bytes.extend_from_slice(&tip.to_le_bytes()); - bytes32_from_hash(dsm::tagged_domain!(b"DSM/VV/tripwire-parent"), &bytes) -} - fn tla_atom(value: &TlaValue) -> anyhow::Result { match value { TlaValue::Symbol(value) | TlaValue::Str(value) => Ok(value.clone()),