From 4b7e8389241f50a21ecc5a6d7e526dbe4aeb8a2c Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:34:52 -0400 Subject: [PATCH 1/3] fix(artifact): a row superseded after selection is not replayed over the newer one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The republish pass selects a batch before it makes a single network call, and every iteration below that awaits. A close committing in that window supersedes a row already in the batch and publishes its own bytes under the same .../latest mirror key. Replaying the stale row afterwards puts an older generation back on every member — a closed vault serves its birth anchor again — and the damage is permanent and invisible: upsert_artifact_publication_state declines to write to a superseded row, so nothing records that the fleet now disagrees with us, and list_unpublished_artifacts skips published rows, so the newer bytes are never re-sent. The module's contract is that a superseded row is never replayed. It is now honoured against current state rather than state at selection time, by re-reading each row immediately before it is sent. republish_rows is split out so a test can hand it a deliberately stale batch, which is exactly what the real pass holds after its own SELECT. The existing sweep test cannot catch this: its SELECT already filters superseded rows, so it passes whether or not the pass re-checks anything. Also deletes clear_accepting_member and KeyedPutFanout::accepting_member_ids, which have no callers. --- .../src/handlers/artifact_republish.rs | 118 ++++++++++++++++++ .../dsm_sdk/src/sdk/storage_node_sdk.rs | 11 -- .../client_db/frozen_publication_artifact.rs | 14 --- 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/artifact_republish.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/artifact_republish.rs index 41ea5eb11..c49c47d44 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/artifact_republish.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/artifact_republish.rs @@ -27,6 +27,15 @@ pub(crate) const ARTIFACT_REPUBLISH_ROWS_PER_POLL: u32 = 8; pub(crate) async fn republish_unpublished_artifacts() -> Result { let rows = fpa::list_unpublished_artifacts(ARTIFACT_REPUBLISH_ROWS_PER_POLL) .map_err(|e| format!("list unpublished artifacts: {e}"))?; + republish_rows(rows).await +} + +/// The pass proper, over an already-selected batch. +/// +/// Split out so a test can hand it a DELIBERATELY STALE batch — the selection +/// happens once, before any network I/O, and everything below is what has to +/// stay correct when the world moves underneath that snapshot. +async fn republish_rows(rows: Vec) -> Result { if rows.is_empty() { return Ok(0); } @@ -35,6 +44,45 @@ pub(crate) async fn republish_unpublished_artifacts() -> Result { let mut published = 0u32; for row in rows { + // THE ROW MAY HAVE DIED WHILE AN EARLIER ROW WAS IN FLIGHT. + // + // `rows` was selected before this pass made a single network call, and + // every iteration below awaits. A `dlv.close` (or any later generation) + // committing in that window supersedes this row and publishes its own + // bytes under the same `.../latest` mirror key. Replaying the stale row + // afterwards would put an OLDER generation's bytes back on every member + // — a closed vault would serve its birth anchor again — and the damage + // would be permanent and invisible: `upsert_artifact_publication_state` + // declines to write to a superseded row, so nothing records that the + // fleet now disagrees with us, and `list_unpublished_artifacts` skips + // published rows, so the newer bytes are never re-sent. + // + // The module's own contract is that a superseded row is never replayed. + // Honour it against the CURRENT state, not the state at selection time. + match fpa::get_artifact(&row.object_key, &row.content_digest) { + Ok(Some(current)) => { + if !matches!( + current.state, + fpa::ArtifactState::Frozen | fpa::ArtifactState::PublicationPending + ) { + log::info!( + "[artifact republish] {} ({}): {} since selection — not replayed", + row.object_key, + row.purpose, + current.state.as_str() + ); + continue; + } + } + Ok(None) => continue, + Err(e) => { + log::warn!( + "[artifact republish] {}: state re-read failed ({e}) — holding", + row.object_key + ); + continue; + } + } // THE SET IS THE ROW'S, NOT OURS. An id the catalog cannot re-derive // means this device does not know how to reach that set; the bytes stay // owed, and are sent nowhere else. @@ -328,4 +376,74 @@ mod tests { "members hold the current generation" ); } + /// A ROW SUPERSEDED *AFTER* SELECTION IS NOT REPLAYED. + /// + /// `a_superseded_generation_is_never_swept` above cannot catch this: the + /// SELECT already filters superseded rows, so it passes whether or not the + /// pass re-checks anything. The dangerous case is the row that was + /// legitimately selected and then died while an EARLIER row of the same + /// batch was in flight — every iteration awaits, and a `dlv.close` + /// committing in that window supersedes this row and publishes its own + /// bytes under the same `.../latest` mirror. + /// + /// Replaying it then puts an older generation back on every member. That + /// damage is permanent AND silent: `upsert_artifact_publication_state` + /// declines to write to a superseded row, so nothing records the + /// divergence, and `list_unpublished_artifacts` skips published rows, so + /// the newer bytes are never re-sent. A closed vault would serve its birth + /// anchor to every trader, forever. + /// + /// The stale batch is handed to `republish_rows` directly, which is exactly + /// what the real pass holds after its own SELECT. + #[tokio::test] + #[serial] + async fn a_row_superseded_after_selection_is_not_replayed_over_the_newer_one() { + let cat = init(); + let sid = cat.sole_set().unwrap().id(); + + // gen0 is frozen and legitimately selected while it is still current. + let d0 = freeze(&sid, "sofi/latest", b"gen0"); + let selected = fpa::list_unpublished_artifacts(8).expect("select"); + assert_eq!(selected.len(), 1, "gen0 was selected while current"); + assert_eq!(selected[0].content_digest, d0); + + // The world moves: a close freezes gen1 under the same mirror key and + // publishes it to quorum. gen0 is now superseded. + let d1 = freeze(&sid, "sofi/latest", b"gen1"); + assert_eq!(republish_unpublished_artifacts().await.unwrap(), 1); + assert_eq!(state_of("sofi/latest", &d1), fpa::ArtifactState::Published); + assert_eq!(state_of("sofi/latest", &d0), fpa::ArtifactState::Superseded); + assert_eq!( + fake_fleet::stored("test-1", "sofi/latest").as_deref(), + Some(&b"gen1"[..]) + ); + + // Now the in-flight pass reaches its stale row. + let published = republish_rows(selected).await.expect("stale pass"); + assert_eq!(published, 0, "a dead row publishes nothing"); + + // THE FLEET STILL HOLDS THE NEWER GENERATION, on every member. + for m in ["test-1", "test-2", "test-3"] { + assert_eq!( + fake_fleet::stored(m, "sofi/latest").as_deref(), + Some(&b"gen1"[..]), + "{m} must not have been rolled back to gen0" + ); + } + // And gen0's bytes were never sent at all after it died — asserted on + // the wire, not only on the resulting state, because a PUT that landed + // and was then overwritten would leave the same final state. + let gen0 = *blake3::hash(b"gen0").as_bytes(); + assert_eq!( + fake_fleet::put_log() + .into_iter() + .filter(|(_, k, dg)| k == "sofi/latest" && *dg == gen0) + .count(), + 0, + "the superseded bytes were never put on the wire" + ); + // The newer row is untouched — still published, still the latest. + assert_eq!(state_of("sofi/latest", &d1), fpa::ArtifactState::Published); + assert!(fpa::is_artifact_published("sofi/latest").unwrap()); + } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs index 9681f4721..724a8d667 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs @@ -95,17 +95,6 @@ pub struct KeyedPutFanout { pub total: u32, } -impl KeyedPutFanout { - /// Member ids whose acceptance counted. - pub fn accepting_member_ids(&self) -> Vec<&str> { - self.outcomes - .iter() - .filter(|o| o.accepted) - .map(|o| o.member_id.as_str()) - .collect() - } -} - /// One member's answer to a settlement-slot claim submission. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MemberClaimResult { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/frozen_publication_artifact.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/frozen_publication_artifact.rs index 5d998620c..fac88814a 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/frozen_publication_artifact.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/frozen_publication_artifact.rs @@ -207,18 +207,6 @@ pub fn record_accepting_member( Ok(()) } -/// Drop a member's observation for `object_key` (a read-back that used to match -/// no longer does). -pub fn clear_accepting_member(object_key: &str, member_id: &str) -> Result<()> { - let binding = get_connection()?; - let conn = binding.lock().unwrap_or_else(|p| p.into_inner()); - conn.execute( - "DELETE FROM frozen_publication_artifact_members WHERE object_key = ?1 AND member_id = ?2", - params![object_key, member_id], - )?; - Ok(()) -} - /// Members whose current observation for `object_key` is EXACTLY /// `content_digest`. A member holding different bytes under the key never /// counts. @@ -411,8 +399,6 @@ mod tests { 2, "the stale-digest member does not count" ); - clear_accepting_member("k/q", "m3").unwrap(); - assert_eq!(count_accepting_members("k/q", &d).unwrap(), 1); } #[test] From a6e74c43a58ec82d50d17737524603c4c6fa0112 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:46:16 -0400 Subject: [PATCH 2/3] fix(register): attribution gates every acceptance, and a claim can only be loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the settlement-slot claim path. Attribution. A member answering Refused while naming OUR digest as the value it holds is holding our bytes, and that arm counted it without asking who answered — the one acceptance path that skipped the check its siblings apply. Quorum means a majority of DISTINCT members hold these exact bytes, so a node replying under another's id could contribute twice to a majority that does not exist. The current node never phrases a re-ack this way; the arm exists for a node that would, which is exactly the node whose attribution cannot be taken on trust. Refusals stay unattributed on purpose: counting one makes the claim fail closed as Contested, so it can only make us more conservative. The fake fleet gains that phrasing so the case can be reached. Dropping the check turns the new test red with accepted: 2 of 3 — one node counted twice. FrozenClaimEnvelope. SPHINCS+ signing here is deterministic, so a rebuilt claim is byte-identical to a replayed one: nothing on the wire, in a digest, or in a test can tell which happened. The guarantee therefore has to be that the machinery is absent rather than merely unused. The type has one constructor — load, which reads bytes already retained — and claim_settlement_slot accepts nothing else. A caller with a signing key but no retained bytes cannot obtain one at all, and making recovery re-sign would mean changing this type rather than quietly editing a call site. frozen_claim_envelope now reads back what it just retained instead of wrapping the bytes in hand, so a retention that silently failed surfaces before the claim goes out rather than at recovery. The tests go through storage exactly as production does: a test-only constructor taking raw bytes would hand them the very capability the type exists to withhold. can_sign asks whether signing is possible without binding a key, so a caller that must not hold signing material while doing the work that precedes signing no longer has to. --- .../dsm_sdk/src/sdk/settlement_slot.rs | 203 +++++++++++++++--- .../dsm_sdk/src/sdk/signing_authority.rs | 14 ++ .../dsm_sdk/src/sdk/storage_io.rs | 20 +- 3 files changed, 206 insertions(+), 31 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/settlement_slot.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/settlement_slot.rs index c6505f3fa..f702f55ea 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/settlement_slot.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/settlement_slot.rs @@ -51,7 +51,7 @@ use dsm::dlv::settlement_slot_claim::{ claim_envelope_digest, decode_and_verify_settlement_slot_claim, sign_settlement_slot_claim, - SettlementSlotClaimBody, + SettlementSlotClaimBody, VerifiedSettlementSlotClaim, }; use crate::sdk::storage_set::StorageSet; @@ -141,6 +141,73 @@ impl std::fmt::Display for SlotClaimError { impl std::error::Error for SlotClaimError {} +/// A settlement-slot claim envelope that came OUT OF DURABLE STORAGE. +/// +/// This type is the recovery boundary, and it exists because no observation +/// downstream can enforce the rule it enforces. SPHINCS+ signing here is +/// deterministic (`R = H(sk_prf || m)`), so a rebuilt claim is byte-identical +/// to a replayed one: nothing on the wire, in a digest, or in a test can tell +/// which happened. The guarantee therefore has to be that the machinery is +/// ABSENT rather than merely unused. +/// +/// So there is exactly one constructor — [`FrozenClaimEnvelope::load`], which +/// reads bytes that are already retained — and [`claim_settlement_slot`] +/// accepts nothing else. A caller holding one of these cannot have built it, +/// and a caller with a signing key but no retained bytes cannot obtain one at +/// all. Making recovery re-sign a claim would mean changing this type, not +/// quietly editing a call site while every test stays green. +pub(crate) struct FrozenClaimEnvelope { + bytes: Vec, + verified: VerifiedSettlementSlotClaim, +} + +impl FrozenClaimEnvelope { + /// The ONLY constructor: the envelope retained for this slot, if any. + /// + /// Verified on the way out, so a caller cannot act on retained bytes that + /// no longer decode or no longer describe the slot they were asked for. + pub(crate) fn load( + vault_id: &[u8; 32], + parent_sequence: u64, + x: &[u8; 32], + ) -> Result, dsm::types::error::DsmError> { + use crate::storage::client_db::settlement_slot_claim_local as local; + let Some(bytes) = local::get_frozen_claim(vault_id, parent_sequence, x).map_err(|e| { + dsm::types::error::DsmError::storage( + format!("frozen slot claim lookup: {e}"), + None::, + ) + })? + else { + return Ok(None); + }; + let verified = decode_and_verify_settlement_slot_claim(&bytes).map_err(|e| { + dsm::types::error::DsmError::invalid_operation(format!( + "retained slot claim is not a valid envelope: {e}" + )) + })?; + if verified.body.vault_id != *vault_id + || verified.body.parent_sequence != parent_sequence + || verified.body.x != *x + { + return Err(dsm::types::error::DsmError::invalid_operation( + "retained slot claim describes a different slot — refusing", + )); + } + Ok(Some(Self { bytes, verified })) + } + + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// The set this claim was frozen against — the client half of the + /// three-way storage-set equality a resumed close re-establishes. + pub(crate) fn storage_set_id(&self) -> [u8; 32] { + self.verified.body.storage_set_id + } +} + /// The claim envelope for `(vault_id, parent_sequence, x)` under `storage_set_id`, /// signed by this device's canonical signing authority — RETAINED durably the /// first time it is built, and returned byte-identically thereafter. Retries @@ -150,30 +217,15 @@ pub(crate) fn frozen_claim_envelope( parent_sequence: u64, x: &[u8; 32], storage_set_id: &[u8; 32], -) -> Result, dsm::types::error::DsmError> { +) -> Result { use crate::storage::client_db::settlement_slot_claim_local as local; - if let Some(bytes) = local::get_frozen_claim(vault_id, parent_sequence, x).map_err(|e| { - dsm::types::error::DsmError::storage( - format!("frozen slot claim lookup: {e}"), - None::, - ) - })? { - // The retained bytes must still describe this exact slot and set. - let v = decode_and_verify_settlement_slot_claim(&bytes).map_err(|e| { - dsm::types::error::DsmError::invalid_operation(format!( - "retained slot claim is not a valid envelope: {e}" - )) - })?; - if v.body.vault_id != *vault_id - || v.body.parent_sequence != parent_sequence - || v.body.x != *x - || v.body.storage_set_id != *storage_set_id - { + if let Some(retained) = FrozenClaimEnvelope::load(vault_id, parent_sequence, x)? { + if retained.storage_set_id() != *storage_set_id { return Err(dsm::types::error::DsmError::invalid_operation( - "retained slot claim describes a different slot or set — refusing", + "retained slot claim describes a different set — refusing", )); } - return Ok(bytes); + return Ok(retained); } let pk = crate::sdk::signing_authority::current_public_key()?; let sk = crate::sdk::signing_authority::current_secret_key()?; @@ -198,7 +250,16 @@ pub(crate) fn frozen_claim_envelope( None::, ) })?; - Ok(bytes) + // Read it back rather than wrapping the bytes we just signed, so that what + // is submitted is ALWAYS what storage holds — on the first attempt exactly + // as on every later one. A retention that silently failed to persist + // surfaces here, before the claim goes out, instead of at recovery. + FrozenClaimEnvelope::load(vault_id, parent_sequence, x)?.ok_or_else(|| { + dsm::types::error::DsmError::storage( + "frozen slot claim did not persist — refusing to claim from memory", + None::, + ) + }) } /// Submit the exact `frozen_envelope` to every member of `set` and decide: @@ -211,11 +272,15 @@ pub(crate) fn frozen_claim_envelope( /// value; everything before is reversible by stopping. pub(crate) async fn claim_settlement_slot( set: &StorageSet, - frozen_envelope: &[u8], + frozen_envelope: &FrozenClaimEnvelope, vault_id: &[u8; 32], parent_sequence: u64, x: &[u8; 32], ) -> Result { + // Only a [`FrozenClaimEnvelope`] can be submitted, and the only way to hold + // one is to have loaded it from storage — so no caller, on any path, can + // hand this function a claim it just built. + let frozen_envelope = frozen_envelope.as_bytes(); // The envelope must describe the slot we are claiming and this set. let v = decode_and_verify_settlement_slot_claim(frozen_envelope) .map_err(|_| SlotClaimError::EnvelopeMismatch)?; @@ -240,10 +305,16 @@ pub(crate) async fn claim_settlement_slot( let mut refused_by = 0u32; let mut details: Vec = Vec::new(); for o in &fanout.outcomes { + // ATTRIBUTION IS A PRECONDITION OF COUNTING, on every path that counts. + // Quorum means a majority of DISTINCT members hold these exact bytes, + // and an outcome only says anything about a member if that member + // named ITSELF in the reply. One node answering under another's id + // would otherwise contribute twice to a majority that does not exist. + let attributed = o.echoed_node_id.as_deref() == Some(o.member_id.as_str()); match &o.result { crate::sdk::storage_node_sdk::MemberClaimResult::Accepted | crate::sdk::storage_node_sdk::MemberClaimResult::HeldIdentical => { - if o.echoed_node_id.as_deref() == Some(o.member_id.as_str()) { + if attributed { accepted += 1; } else { details.push(format!( @@ -254,10 +325,24 @@ pub(crate) async fn claim_settlement_slot( } crate::sdk::storage_node_sdk::MemberClaimResult::Refused { held_digest } => { if held_digest.as_deref() == Some(&our_digest[..]) { - // A member that reports OUR digest as "held" is holding our - // bytes (a re-ack expressed oddly); count it. - accepted += 1; + // A member reporting OUR digest as "held" is holding our + // bytes — a re-ack expressed oddly. It is an ACCEPTANCE, so + // it carries the same attribution requirement as one; the + // current node never phrases a re-ack this way, and this + // arm exists so that a node which did could not slip an + // unattributed count past the check its siblings apply. + if attributed { + accepted += 1; + } else { + details.push(format!( + "{}: holds our claim but echoed {:?} — not counted", + o.member_id, o.echoed_node_id + )); + } } else { + // A refusal is NOT attributed on purpose: counting it makes + // this claim fail closed as Contested, so an unattributed + // one can only make us more conservative, never less. refused_by += 1; } } @@ -327,8 +412,16 @@ mod tests { .expect("a three-member set") } - fn envelope(sk: &[u8], pk: &[u8], set: &StorageSet, seq: u64, x: u8) -> Vec { - sign_settlement_slot_claim( + /// Sign a claim, RETAIN it, and hand back the loaded envelope. + /// + /// The tests go through storage exactly as production does, because + /// `claim_settlement_slot` accepts only a [`FrozenClaimEnvelope`] and the + /// only way to hold one is to load it. A test-only constructor taking raw + /// bytes would hand these tests the very capability the type exists to + /// withhold, and the boundary would then be enforced everywhere except + /// where it is exercised. + fn envelope(sk: &[u8], pk: &[u8], set: &StorageSet, seq: u64, x: u8) -> FrozenClaimEnvelope { + let bytes = sign_settlement_slot_claim( &SettlementSlotClaimBody { vault_id: [0x11; 32], parent_sequence: seq, @@ -338,7 +431,17 @@ mod tests { }, sk, ) - .expect("sign") + .expect("sign"); + crate::storage::client_db::settlement_slot_claim_local::put_frozen_claim( + &[0x11; 32], + seq, + &[x; 32], + &bytes, + ) + .expect("retain"); + FrozenClaimEnvelope::load(&[0x11; 32], seq, &[x; 32]) + .expect("load") + .expect("retained") } /// Two contestants, three members: at most one reaches quorum, and the @@ -437,6 +540,46 @@ mod tests { ); } + /// A RE-ACK PHRASED AS A REFUSAL IS STILL AN ACCEPTANCE, so it carries the + /// same attribution requirement as one. + /// + /// A member answering `Refused` while naming OUR digest as the value it + /// holds is holding our bytes. Counting it is correct — and counting it + /// without checking who answered is not. Quorum means a majority of + /// DISTINCT members hold these exact bytes; one node replying under + /// another's id would otherwise contribute twice to a majority that does + /// not exist, on the one acceptance path that never checked. + /// + /// The current node never phrases a re-ack this way. That is the reason to + /// test it: the arm exists for a node that would, and such a node is + /// precisely the one whose attribution cannot be taken on trust. + #[tokio::test] + #[serial] + async fn a_refusal_holding_our_own_digest_still_needs_attribution() { + let set = init(); + let (pk, sk) = dsm::crypto::sphincs::generate_sphincs_keypair().unwrap(); + let a = envelope(&sk, &pk, &set, 7, 0xA1); + + // First pass: every member takes our bytes, so every later answer is a + // re-ack over the identical envelope. + claim_settlement_slot(&set, &a, &[0x11; 32], 7, &[0xA1; 32]) + .await + .expect("the first claim reaches quorum"); + + // The retry: test-1 re-acks in the refusal phrasing AND names test-2 as + // itself; test-3 is unreachable. Only test-2's own re-ack may count. + fake_fleet::set_refuse_phrasing("test-1"); + fake_fleet::set_echo("test-1", Some("test-2")); + fake_fleet::fail_member("test-3"); + let r = claim_settlement_slot(&set, &a, &[0x11; 32], 7, &[0xA1; 32]) + .await + .expect_err("an unattributed re-ack must not complete a quorum"); + assert!( + matches!(r, SlotClaimError::StorageUnavailable { accepted: 1, .. }), + "{r}" + ); + } + /// An envelope for another slot or another set is refused before any /// member is contacted. #[tokio::test] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/signing_authority.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/signing_authority.rs index 7db43e526..e682a1ef3 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/signing_authority.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/signing_authority.rs @@ -54,6 +54,20 @@ pub(crate) fn current_secret_key() -> Result, DsmError> { Ok(derive_current_signing_keypair()?.secret_key().to_vec()) } +/// Whether signing is possible right now, WITHOUT handing back a key. +/// +/// For callers that need to decide "is there any point starting this work?" +/// but must not hold signing material while doing the part that precedes +/// signing. Binding a key just to test for its presence puts the capability in +/// scope for everything that follows, which is exactly what some boundaries +/// exist to prevent. +pub(crate) fn can_sign() -> bool { + match derive_current_signing_keypair() { + Ok(kp) => !kp.public_key().is_empty() && !kp.secret_key().is_empty(), + Err(_) => false, + } +} + // --------------------------------------------------------------------------- // Test helpers. // diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_io.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_io.rs index d14dcae5f..dca388a90 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_io.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_io.rs @@ -201,6 +201,10 @@ pub(crate) mod fake_fleet { failing: HashSet, /// member_id -> the node id it echoes (default: its own member id) echo_override: HashMap>, + /// members that phrase a re-ack as `Refused { held_digest: }` + /// instead of `HeldIdentical` — a shape the current node never emits, + /// which is exactly why the claim path must not take it on trust. + refuse_phrasing: HashSet, /// every (member_id, key, digest-of-bytes) PUT that was attempted, in order put_log: Vec<(String, String, [u8; 32])>, } @@ -224,6 +228,11 @@ pub(crate) mod fake_fleet { state().failing.remove(member_id); } + /// Make `member_id` answer a re-ack as `Refused` carrying our own digest. + pub(crate) fn set_refuse_phrasing(member_id: &str) { + state().refuse_phrasing.insert(member_id.to_string()); + } + /// Make `member_id` echo `echoes` (e.g. another member's id, or `None`). pub(crate) fn set_echo(member_id: &str, echoes: Option<&str>) { state() @@ -313,6 +322,7 @@ pub(crate) mod fake_fleet { continue; }; let slot = (v.body.vault_id, v.body.parent_sequence); + let st_refuse_phrasing = st.refuse_phrasing.contains(&m.member_id); let reg = st.registers.entry(m.member_id.clone()).or_default(); // Read the held digest OUT before deciding, so the write-once insert // does not overlap the read borrow. @@ -322,7 +332,15 @@ pub(crate) mod fake_fleet { reg.insert(slot, (envelope.to_vec(), v.envelope_digest)); MemberClaimResult::Accepted } - Some(d) if d == v.envelope_digest => MemberClaimResult::HeldIdentical, + Some(d) if d == v.envelope_digest => { + if st_refuse_phrasing { + MemberClaimResult::Refused { + held_digest: Some(d.to_vec()), + } + } else { + MemberClaimResult::HeldIdentical + } + } Some(d) => MemberClaimResult::Refused { held_digest: Some(d.to_vec()), }, From 5ef447a6b870ef470fb70abcf42a680a8e872278 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:20:22 -0400 Subject: [PATCH 3/3] fix(dlv): the resume pass holds no signing capability, and the envelope has one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call-site half of the FrozenClaimEnvelope boundary. resume_close_intents no longer binds a signing key. It asks can_sign() to decide whether there is any point starting, and the commit half — which genuinely must sign, because the terminal proofs did not exist when the close was interrupted — moves to finish_prepared_close. Keeping the key out of the claim half's scope is what makes "the parent claim is never re-signed" a property of the code rather than of the current author's discipline. The claim comes from FrozenClaimEnvelope::load, whose only input is the slot it names; nothing on this path can build or sign one. dlv_close_intent.claim_bytes is dropped, column included. The envelope lived in two durable places that could disagree, and settlement_slot_claim_local is the one that FrozenClaimEnvelope reads. Schema v5 is introduced within this stack and main is still on 4, so reshaping it here owes no version bump. The replay test's doc claimed more than the test observes. SPHINCS+ signing here is deterministic, so a rebuilt claim is byte-identical to a replayed one and no wire-level assertion can separate them. The doc now says what the digests do prove — every attempt carried ONE envelope, the one frozen with the intent — and states that provenance is enforced structurally instead. --- .../dsm_sdk/src/handlers/dlv_routes.rs | 144 +++++++++++++----- .../src/storage/client_db/dlv_close_intent.rs | 31 ++-- .../dsm_sdk/src/storage/client_db/mod.rs | 1 - 3 files changed, 123 insertions(+), 53 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs index 87e68e047..68a61f98f 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs @@ -1751,15 +1751,14 @@ impl AppRouterImpl { if intents.is_empty() { return Ok(0); } - let (owner_pk, owner_sk) = match ( - crate::sdk::signing_authority::current_public_key(), - crate::sdk::signing_authority::current_secret_key(), - ) { - (Ok(pk), Ok(sk)) if !pk.is_empty() && !sk.is_empty() => (pk, sk), - // Locked wallet: the terminal proofs cannot be signed. Leave every - // intent exactly as it is and retry on a later pass. - _ => return Ok(0), - }; + // A locked wallet cannot sign the terminal proofs, so there is nothing + // to finish; leave every intent exactly as it is and retry later. Note + // this ASKS whether signing is possible without binding the keys — + // nothing below this line holds a signing key, and that is the point of + // the split with `finish_prepared_close`. + if !crate::sdk::signing_authority::can_sign() { + return Ok(0); + } let mut finished = 0u32; for intent in intents { let vault_b32 = crate::util::text_id::encode_base32_crockford(&intent.vault_id); @@ -1821,18 +1820,27 @@ impl AppRouterImpl { else { continue; }; + // THE FROZEN ENVELOPE, FROM STORAGE. This is the only claim this + // pass can submit: `FrozenClaimEnvelope` has no constructor that + // takes bytes or keys, so nothing below can build one. + let claim = match crate::sdk::settlement_slot::FrozenClaimEnvelope::load( + &intent.vault_id, + intent.parent_sequence, + &intent.x_close, + ) { + Ok(Some(c)) => c, + Ok(None) => { + abandon("no frozen claim envelope is retained for this close"); + continue; + } + Err(_) => { + abandon("the frozen claim envelope no longer verifies"); + continue; + } + }; // THE THREE-WAY S EQUALITY: the frozen claim, the vault's // birth-bound lineage, and the local record must all name one set. - let claim_set_id = - match dsm::dlv::settlement_slot_claim::decode_and_verify_settlement_slot_claim( - &intent.claim_bytes, - ) { - Ok(v) => v.body.storage_set_id, - Err(_) => { - abandon("the frozen claim envelope no longer verifies"); - continue; - } - }; + let claim_set_id = claim.storage_set_id(); if claim_set_id != composed.storage_set_id || record.storage_set_id != composed.storage_set_id { @@ -1851,10 +1859,10 @@ impl AppRouterImpl { }; let claim_set = claim_set.clone(); - // Re-run the claim with the SAME bytes. + // Re-run the claim with the retained envelope. match crate::sdk::settlement_slot::claim_settlement_slot( &claim_set, - &intent.claim_bytes, + &claim, &intent.vault_id, intent.parent_sequence, &intent.x_close, @@ -1898,7 +1906,7 @@ impl AppRouterImpl { *h.finalize().as_bytes() }; match self - .commit_canonical_close( + .finish_prepared_close( &intent.vault_id, intent.parent_sequence, new_sequence, @@ -1908,8 +1916,6 @@ impl AppRouterImpl { op, live.reserve_a, live.reserve_b, - &owner_pk, - &owner_sk, ) .await { @@ -1923,6 +1929,51 @@ impl AppRouterImpl { Ok(finished) } + /// The COMMIT half of a resumed close: replay the frozen operation and sign + /// the terminal publication set. + /// + /// Split from [`Self::resume_close_intents`] so that the claim half holds + /// no signing key at all. Terminal proofs genuinely have to be signed here + /// — they did not exist when the close was interrupted — but the parent + /// claim must never be, and keeping the key out of that scope is what makes + /// "never" a property of the code rather than of the current author's + /// discipline. + #[allow(clippy::too_many_arguments)] + async fn finish_prepared_close( + &self, + vault_id: &[u8; 32], + parent_sequence: u64, + new_sequence: u64, + pair: &dsm::types::device_state::VaultStatePair, + storage_set_id: &[u8; 32], + close_commitment: &[u8; 32], + op: dsm::types::operations::Operation, + reserve_a: u64, + reserve_b: u64, + ) -> Result<(), String> { + let (owner_pk, owner_sk) = match ( + crate::sdk::signing_authority::current_public_key(), + crate::sdk::signing_authority::current_secret_key(), + ) { + (Ok(pk), Ok(sk)) if !pk.is_empty() && !sk.is_empty() => (pk, sk), + _ => return Err("signing authority unavailable (wallet locked)".to_string()), + }; + self.commit_canonical_close( + vault_id, + parent_sequence, + new_sequence, + pair, + storage_set_id, + close_commitment, + op, + reserve_a, + reserve_b, + &owner_pk, + &owner_sk, + ) + .await + } + /// `dlv.close` — the owner withdraws ALL remaining liquidity and retires the /// vault. /// @@ -2156,7 +2207,7 @@ impl AppRouterImpl { // The register claim, signed once and RETAINED — retries replay these // exact bytes. - let claim_bytes = match crate::sdk::settlement_slot::frozen_claim_envelope( + let claim = match crate::sdk::settlement_slot::frozen_claim_envelope( &vault_id, parent_sequence, &x_close, @@ -2174,7 +2225,6 @@ impl AppRouterImpl { state: intent_db::CloseIntentState::PreparedClose, op_bytes: op.to_bytes(), x_close, - claim_bytes: claim_bytes.clone(), pointer_key: pointer_key.clone(), pointer_bytes: pointer_bytes.clone(), storage_set_id, @@ -2198,7 +2248,7 @@ impl AppRouterImpl { // ── CLAIM THE PARENT ───────────────────────────────────────────────── match crate::sdk::settlement_slot::claim_settlement_slot( &claim_set, - &claim_bytes, + &claim, &vault_id, parent_sequence, &x_close, @@ -5612,11 +5662,25 @@ mod funded_creation_tests { /// moves, and the intent stays PREPARED rather than being abandoned — /// abandoning a transient failure would strand the vault forever. /// - /// When the fleet comes back the resume pass replays the SAME frozen claim - /// envelope (a re-signed one would read as a different claimant and could - /// lose its own slot), commits the canonical close, and publishes the - /// terminal set. The digest assertions are what prove "replayed", not - /// "rebuilt". + /// When the fleet comes back the resume pass submits the SAME claim + /// envelope, commits the canonical close, and publishes the terminal set. + /// + /// What the digest assertions prove, precisely: every claim attempt before + /// and after the outage carried ONE envelope, and it is the one frozen with + /// the intent. That is the property the register compares — a claimant is + /// its exact bytes, so an envelope differing in `x`, in the storage set, in + /// the claimant key, or merely in field order would lose the slot it + /// already held. + /// + /// What they deliberately do NOT prove: that the bytes were read from disk + /// rather than reconstructed. SPHINCS+ signing here is deterministic + /// (`R = H(sk_prf || m)`, dsm-sphincs `sig_randomizer`), so rebuilding the + /// same body with the same key yields byte-identical bytes and no + /// wire-level assertion can separate the two. That rule is enforced + /// STRUCTURALLY instead: `claim_settlement_slot` accepts only a + /// `FrozenClaimEnvelope`, whose single constructor loads already-retained + /// bytes, so the resume path has no way to build or sign one. Claiming this + /// test proves provenance would be claiming more than it observes. #[test] #[serial] fn an_interrupted_close_is_completed_by_the_resume_pass_with_identical_bytes() { @@ -5674,7 +5738,15 @@ mod funded_creation_tests { crate::storage::client_db::dlv_close_intent::CloseIntentState::PreparedClose, "an unreachable fleet is transient: the close stays PREPARED, never abandoned" ); - let frozen_claim = intent.claim_bytes.clone(); + // The retained envelope, from the one place that holds it. + let frozen_claim = + crate::storage::client_db::settlement_slot_claim_local::get_frozen_claim( + &vault_id, + 0, + &intent.x_close, + ) + .expect("retention read") + .expect("the close retained its claim envelope before going out"); // The fleet returns; the sweep finishes what the owner started. for m in &members { @@ -5706,8 +5778,10 @@ mod funded_creation_tests { crate::storage::client_db::dlv_close_intent::CloseIntentState::CanonicalCloseCommitted, ); - // THE CLAIM WAS REPLAYED, NOT REBUILT: the bytes the resume submitted - // are the bytes frozen before the interruption. + // ONE ENVELOPE ACROSS THE OUTAGE, and it is the frozen one. The + // register identifies a claimant BY these bytes, so this is the + // property that decides whether the resumed close keeps the slot it + // already holds. let claim_digests: std::collections::BTreeSet<[u8; 32]> = crate::sdk::storage_io::fake_fleet::put_log() .into_iter() diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/dlv_close_intent.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/dlv_close_intent.rs index dbae6be93..4ef51e332 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/dlv_close_intent.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/dlv_close_intent.rs @@ -74,8 +74,6 @@ pub struct CloseIntent { pub op_bytes: Vec, /// The deterministic close commitment naming this close in the slot. pub x_close: [u8; 32], - /// The frozen `SettlementSlotClaimV1` envelope (register bytes). - pub claim_bytes: Vec, /// The discovery pointer this close publishes, and where. pub pointer_key: String, pub pointer_bytes: Vec, @@ -85,22 +83,25 @@ pub struct CloseIntent { } /// Write the intent before anything external happens. Idempotent per -/// `(vault_id, parent_sequence)`: a retry of the same close reuses the FIRST -/// bytes — re-signing would produce a claim the register reads as a different -/// claimant. +/// `(vault_id, parent_sequence)`: a retry of the same close reuses the first +/// row. +/// +/// The claim envelope is deliberately NOT stored here. It lives in exactly one +/// place — `settlement_slot_claim_local` — reachable only through +/// `FrozenClaimEnvelope::load`, so there is never a second copy that could +/// disagree with it or be submitted in its place. pub fn put_intent_with_conn(conn: &Connection, intent: &CloseIntent) -> Result<()> { conn.execute( "INSERT OR IGNORE INTO dlv_close_intent - (vault_id, parent_sequence, state, op_bytes, x_close, claim_bytes, + (vault_id, parent_sequence, state, op_bytes, x_close, pointer_key, pointer_bytes, storage_set_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ intent.vault_id.as_slice(), intent.parent_sequence as i64, intent.state.as_str(), intent.op_bytes, intent.x_close.as_slice(), - intent.claim_bytes, intent.pointer_key, intent.pointer_bytes, intent.storage_set_id.as_slice(), @@ -157,15 +158,14 @@ fn row_to_intent(r: &rusqlite::Row<'_>) -> rusqlite::Result { state: CloseIntentState::from_str(&r.get::<_, String>(2)?), op_bytes: r.get(3)?, x_close: fixed(r.get::<_, Vec>(4)?), - claim_bytes: r.get(5)?, - pointer_key: r.get(6)?, - pointer_bytes: r.get(7)?, - storage_set_id: fixed(r.get::<_, Vec>(8)?), - insertion_ordinal: r.get(9)?, + pointer_key: r.get(5)?, + pointer_bytes: r.get(6)?, + storage_set_id: fixed(r.get::<_, Vec>(7)?), + insertion_ordinal: r.get(8)?, }) } -const COLS: &str = "vault_id, parent_sequence, state, op_bytes, x_close, claim_bytes, \ +const COLS: &str = "vault_id, parent_sequence, state, op_bytes, x_close, \ pointer_key, pointer_bytes, storage_set_id, insertion_ordinal"; pub fn get_intent(vault_id: &[u8; 32], parent_sequence: u64) -> Result> { @@ -213,7 +213,6 @@ mod tests { state: CloseIntentState::PreparedClose, op_bytes: b"signed-op".to_vec(), x_close: [0xC1; 32], - claim_bytes: b"claim-envelope".to_vec(), pointer_key: "sofi/vault-pending/V/1/X".to_string(), pointer_bytes: b"pointer".to_vec(), storage_set_id: [0x6B; 32], @@ -234,11 +233,9 @@ mod tests { put_intent(&a).unwrap(); let mut b = a.clone(); b.op_bytes = b"DIFFERENT".to_vec(); - b.claim_bytes = b"different-claim".to_vec(); put_intent(&b).unwrap(); let got = get_intent(&[0x11; 32], 3).unwrap().expect("row"); assert_eq!(got.op_bytes, b"signed-op".to_vec()); - assert_eq!(got.claim_bytes, b"claim-envelope".to_vec()); assert_eq!(got.state, CloseIntentState::PreparedClose); } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs index 8536e6961..c046892a7 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs @@ -541,7 +541,6 @@ fn create_schema(conn: &Connection) -> Result<()> { 'canonical_close_committed','abandoned')), op_bytes BLOB NOT NULL, x_close BLOB NOT NULL, - claim_bytes BLOB NOT NULL, pointer_key TEXT NOT NULL, pointer_bytes BLOB NOT NULL, storage_set_id BLOB NOT NULL,