diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs index cdbca31d..17d4973f 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs @@ -150,21 +150,27 @@ impl StateMachine { // Mirror the verbatim State for the validation-tooling compat shims. // NOT read by current_state(); see the field doc. self.compat_shim_state = Some(state.clone()); - if self.device_state.is_none() { - let mut ds = crate::types::device_state::DeviceState::new( - [0u8; 32], - state.device_info.device_id, - state.device_info.public_key.clone(), - 1024, - ); - // Seed SMT root with the State's hash for legacy compat. + // Re-seed an EXISTING head only. This function does not know the + // genesis authority root `G`, so it must not manufacture a head that + // claims one. + // + // It used to build `DeviceState::new([0u8; 32], ...)` whenever no head + // existed. That zero is not "unset" to any reader — `genesis_digest()` + // returns it as a genesis root like any other. Because genesis install + // calls this BEFORE `write_genesis_device_head`, the fabricated + // zero-root head is the one that got persisted, and the ERA faucet's + // authority evidence — which re-derives the real seed-rooted `v3.g` — + // fail-closed against zeros on every freshly created wallet. The check + // was right; the state was fabricated. + // + // Every production caller either holds `G` and writes the head itself + // immediately after (`install_v2_genesis`, + // `initialize_with_genesis_state`, `create_genesis_with_passive_contributors` + // all call `write_genesis_device_head`), or already has a head + // (`migrate_token_balance_keys`). None needs a pre-genesis head, so + // nothing legitimate is lost by refusing to invent one. + if let Some(ds) = self.device_state.as_mut() { ds.bootstrap_legacy_root(state_hash); - self.device_state = Some(ds); - } else { - // Re-seed with new state hash for tests that swap state. - if let Some(ds) = self.device_state.as_mut() { - ds.bootstrap_legacy_root(state_hash); - } } } @@ -562,6 +568,17 @@ mod state_machine_tests { }; let mut state_machine = StateMachine::new(); + // Install the head EXPLICITLY, carrying the real genesis root. This used + // to come free from `set_state`, which fabricated a head with a + // `[0u8; 32]` genesis whenever none existed — the production defect that + // fail-closed the ERA faucet's authority check. A test that needs a head + // now says so, and says which root it has. + state_machine.set_device_head(crate::types::device_state::DeviceState::new( + genesis_state.hash, + device_id, + genesis_state.device_info.public_key.clone(), + 1024, + )); state_machine.set_state(genesis_state); let dev_id = device_id; @@ -582,6 +599,14 @@ mod state_machine_tests { let mut machine = StateMachine::new(); let (initial_state, _pk, _sk) = create_test_genesis_state_with_keypair(); let dev_id = initial_state.device_info.device_id; + // Explicit head with the real genesis root — see the note in + // `test_first_post_genesis_transition_is_allowed`. + machine.set_device_head(crate::types::device_state::DeviceState::new( + initial_state.hash, + dev_id, + initial_state.device_info.public_key.clone(), + 1024, + )); machine.set_state(initial_state); // SMT-advance mechanics test: non-balance op, no deltas (see conservation guard). diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs index c918b434..5001851c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs @@ -1210,6 +1210,27 @@ impl DeviceState { self.genesis } + /// Install the canonical genesis authority root `G` on an ALREADY + /// CONSTRUCTED head. + /// + /// Only the constructor used to write `genesis`, which made + /// `CoreSDK::write_genesis_device_head` silently unable to honour its own + /// contract: it takes the existing head when one is present, so on that + /// branch `genesis` kept whatever the head was built with. Genesis install + /// always hits that branch — `StateMachine::set_state` materialises a head + /// first — so every freshly created wallet ended up with a head whose + /// `genesis` was the `[0u8; 32]` that `set_state` invented. Every consumer + /// reading `genesis_digest()` as the authority root then compared against + /// zeros: the ERA faucet's authority evidence re-derived the real seed-rooted + /// `v3.g` and fail-closed on every device, correctly. + /// + /// This is the narrow repair for that: a head that HAS the canonical root + /// can be told it. It does not make the root optional and does not add a + /// second notion of `G` — there is one, the seed-derived `v3.g`. + pub fn set_genesis_digest(&mut self, genesis: [u8; 32]) { + self.genesis = genesis; + } + /// Device identifier. pub fn devid(&self) -> [u8; 32] { self.devid diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs index 5b27e94b..6aa16636 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs @@ -424,6 +424,71 @@ pub(crate) fn handle_create_genesis_v2_query(q: AppQuery) -> AppResult { "createGenesisV2", ); + // 5d. IDENTITY PUBLICATION — drive it in THIS session. + // + // This route used to do neither half of what `system.genesis` does: it never + // wrote a publication row and never called `publish_identity_now`. On the v2 + // path publication was therefore driven only by `retry_pending_publications` + // at SDK init, so a wallet created in a session stayed in + // `publication_pending` until the app was RESTARTED — the user watching + // "PUBLISHING IDENTITY…" had no way forward, and a restart is not something + // a real user can be asked to do. (5c publishes the device TREE; that is a + // different object and does not mark the identity published.) + // + // Two halves, in order. The row FIRST, so a crash between here and the + // publish still leaves something for the startup retry to find — today the + // retry only works because `backfill_publication_rows_for_local_identities` + // reconstructs the row this route never wrote. Then the publish itself, + // SPAWNED: genesis never blocks on the network (same contract as 5c), and a + // slow or unreachable fleet must not hold the response. Failure is + // non-fatal — local genesis stays durable, the row stays unpublished, and + // the startup retry remains the backstop rather than the only driver. + { + let dev_b32 = device_id_b32.clone(); + let g_b32 = crate::util::text_id::encode_base32_crockford(&g); + let pk_b32 = crate::util::text_id::encode_base32_crockford(&ak_pk); + if let Err(e) = crate::storage::client_db::publication::upsert_publication_state( + &dev_b32, + &g_b32, + crate::storage::client_db::publication::PublicationState::LocalGenesisCommitted, + 0, + "", + ) { + log::warn!( + "system.createGenesisV2: failed to record local-genesis publication state: {e}" + ); + } + crate::runtime::get_runtime().spawn(async move { + match crate::sdk::identity_publication::publish_identity_now( + &dev_b32, &pk_b32, &g_b32, + ) + .await + { + Ok(report) if report.is_published() => log::info!( + "system.createGenesisV2: identity PUBLISHED for device={} ({}/{} verified, quorum {})", + &dev_b32[..8.min(dev_b32.len())], + report.verified, + report.total_nodes, + report.required + ), + Ok(report) => log::warn!( + "system.createGenesisV2: identity NOT published for device={} ({}/{} verified, \ + quorum {}) — local genesis is durable and startup will retry. failures: {:?}", + &dev_b32[..8.min(dev_b32.len())], + report.verified, + report.total_nodes, + report.required, + report.failures + ), + Err(e) => log::warn!( + "system.createGenesisV2: identity publication failed for device={}: {e} \ + — local genesis is durable and startup will retry", + &dev_b32[..8.min(dev_b32.len())] + ), + } + }); + } + // Success rail (mirrors finalize_bootstrap_core): complete → ok. The wallet_ready screen // transition itself rides the fresh session snapshot Kotlin publishes after this response. emit(LifecycleKind::GenesisKindSecuringComplete, 0); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs index 1b0e2337..69bcd5cf 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs @@ -661,6 +661,16 @@ impl CoreSDK { /// (§2.2) and is also used as the legacy SMT-root anchor for the /// initial head so `verify_state` checks against the genesis hash work /// before any relationship advance has fired. + /// + /// `genesis` is set UNCONDITIONALLY, on both branches. It used to be written + /// only by the constructor in the `unwrap_or_else`, so a pre-existing head + /// kept whatever root it was built with — and that is the branch genesis + /// install always takes, because `StateMachine::set_state` materialises a + /// head (with a `[0u8; 32]` genesis) before this runs. The result was a + /// persisted head whose authority root was zeros while `AppState` held the + /// correct `v3.g`, and every reader of `genesis_digest()` compared against + /// zeros. Honouring the contract on both branches is the fix; nothing + /// downstream is taught to tolerate a zero root. fn write_genesis_device_head(&self, genesis_hash: [u8; 32]) -> Result<(), DsmError> { use crate::storage::client_db::update_bcr_device_head; let mut head = self.device_head().unwrap_or_else(|| { @@ -671,6 +681,7 @@ impl CoreSDK { 1024, ) }); + head.set_genesis_digest(genesis_hash); if head.legacy_anchor().is_none() { head.bootstrap_legacy_root(genesis_hash); } @@ -679,7 +690,14 @@ impl CoreSDK { format!("genesis head-cache write failed: {e}"), None::, ) - }) + })?; + // Install it IN MEMORY as well. `StateMachine::set_state` no longer + // manufactures a head (it does not know `G` and must not invent one), so + // this is now the only thing that gives the state machine a head at + // genesis — and callers read `device_head()` straight after install. + // Creating it here is the point: this function has the canonical root. + self.state_machine.lock().set_device_head(head); + Ok(()) } /// Initialize CoreSDK with default device identity @@ -892,7 +910,29 @@ impl CoreSDK { } /// Deterministic in-process genesis (for tests/bootstrap only) + /// + /// Refuses to run over an identity that already HAS a canonical head. + /// + /// The genesis this builds is synthetic: a zero-entropy `State` whose + /// `compute_hash()` is handed to `write_genesis_device_head` as if it were + /// `G`. `AppRouterImpl::new` calls this unconditionally on EVERY router + /// build, right after `new_with_device` has restored the real head from + /// `bcr_device_heads`. Once `write_genesis_device_head` became authoritative + /// on both branches, that meant the synthetic hash overwrote the restored + /// seed-derived `v3.g` — in memory AND in the persisted row — on every + /// freshly created wallet. The ERA faucet's authority evidence then + /// re-derived the true G and fail-closed, correctly, against the fabricated + /// one. + /// + /// A head that is already present carries the only legitimate authority + /// root; there is nothing for a synthetic genesis to do, so this returns + /// without touching state or the head. It still builds a head where none + /// exists (test fixtures, and the headless identity paths whose authority + /// is an open protocol question — see TRACE-2026-09-12-006). pub fn initialize_with_genesis_state(&self) -> Result<(), DsmError> { + if self.device_head().is_some() { + return Ok(()); + } let initial_entropy = [0u8; 32]; let mut genesis_state = State::new_genesis(initial_entropy, self.device_info.clone()); // Precompute and embed the hash so tests and callers see a non-empty hash field @@ -4534,6 +4574,191 @@ mod tests { } } + /// THE PRODUCTION DEFECT, REPRODUCED AT ITS ORDERING. + /// + /// `install_v2_genesis` calls `set_state` BEFORE `write_genesis_device_head`. + /// `set_state` used to materialise a head with a `[0u8; 32]` genesis, and + /// `write_genesis_device_head` only wrote `genesis` on its construct-new + /// branch — so the existing-head branch (the one genesis install ALWAYS + /// takes) left the persisted head claiming a zero authority root while + /// `AppState` held the real `v3.g`. Every reader of `genesis_digest()` then + /// compared against zeros; the ERA faucet's authority evidence re-derived + /// the true seed-rooted G and fail-closed on every freshly created wallet. + /// + /// This pins the existing-head branch specifically, because that is the one + /// that shipped broken. + #[test] + #[serial] + fn genesis_install_writes_the_canonical_root_on_the_existing_head_branch() { + unsafe { std::env::set_var("DSM_SDK_TEST_MODE", "1") }; + crate::storage::client_db::reset_database_for_tests(); + crate::storage::client_db::init_database().expect("init db"); + let sdk = test_sdk(); + + // A distinctive, NON-ZERO canonical root, so a fabricated zero cannot + // pass by coincidence. + let canonical_g = [0xA7u8; 32]; + let mut genesis_state = + dsm::core::identity::genesis::GenesisState::new().expect("genesis state"); + genesis_state.hash = canonical_g; + genesis_state.device_id = Some(sdk.device_info.device_id); + genesis_state.signing_key.public_key = sdk.device_info.public_key.clone(); + + // FORCE THE BROKEN BRANCH. Layer 2 stops `set_state` manufacturing a + // head, so install would now take the construct-new path — which was + // never broken. A head must already be present for this test to pin + // what actually shipped: `write_genesis_device_head` adopting an + // existing head and leaving its root untouched. This models any head + // that exists before the canonical root is known, which is exactly the + // state `set_state` used to leave behind. + sdk.set_device_head_for_testing(dsm::types::device_state::DeviceState::new( + [0u8; 32], + sdk.device_info.device_id, + sdk.device_info.public_key.clone(), + 1024, + )); + assert_eq!( + sdk.device_head() + .expect("precondition head") + .genesis_digest(), + [0u8; 32], + "precondition: the existing head carries a zero root" + ); + + let returned = sdk.install_v2_genesis(&genesis_state).expect("install"); + assert_eq!(returned, canonical_g, "install must return the canonical G"); + + let head = sdk + .device_head() + .expect("a head exists after genesis install"); + assert_ne!( + head.genesis_digest(), + [0u8; 32], + "the head must not carry a fabricated zero authority root" + ); + assert_eq!( + head.genesis_digest(), + canonical_g, + "the head's genesis root must BE the canonical seed-derived G" + ); + + // And it must survive the persist/reload the faucet actually reads through. + let reloaded = crate::storage::client_db::load_bcr_device_head(&sdk.device_info.device_id) + .expect("head reload") + .expect("a persisted head"); + assert_eq!( + reloaded.genesis_digest(), + canonical_g, + "the PERSISTED head must carry the canonical G — this is what readers load" + ); + } + + /// Layer 2: `set_state` must not invent an authority root it does not know. + /// With no head installed it leaves `device_state` absent rather than + /// manufacturing `DeviceState::new([0u8; 32], ..)`. + #[test] + #[serial] + fn set_state_does_not_fabricate_a_zero_genesis_head() { + let dev = DeviceInfo::from_hashed_label("test_device_no_fabricate", vec![2u8; 32]); + let mut sm = dsm::core::state_machine::StateMachine::new(); + let state = dsm::types::state_types::State::new_genesis([9u8; 32], dev); + sm.set_state(state); + assert!( + sm.device_head().is_none(), + "set_state does not know G and must not manufacture a head claiming one" + ); + } + + /// THE ROUTER-BUILD CLOBBER, REPRODUCED IN PRODUCTION ORDER. + /// + /// `system.createGenesisV2` writes the canonical head, then hot-swaps in a + /// full `AppRouterImpl`, whose constructor builds a SECOND `CoreSDK` + /// (`new_with_device` restores that head from `bcr_device_heads`) and then + /// calls `initialize_with_genesis_state()` unconditionally. That call builds + /// a synthetic zero-entropy genesis and hands its hash to the now- + /// authoritative `write_genesis_device_head`, overwriting the restored + /// `v3.g`. On device this was the ERA faucet's + /// "re-derived G does not match this device's stored genesis id". + /// + /// This drives exactly that sequence across two SDK instances sharing one + /// DB, and requires the canonical root to survive the router build both in + /// memory and in the persisted row. + #[test] + #[serial] + fn router_build_does_not_overwrite_a_restored_canonical_genesis_root() { + unsafe { std::env::set_var("DSM_SDK_TEST_MODE", "1") }; + crate::storage::client_db::reset_database_for_tests(); + crate::storage::client_db::init_database().expect("init db"); + + // 1. createGenesisV2's own CoreSDK installs the canonical root. + let genesis_sdk = test_sdk(); + let canonical_g = [0x5Cu8; 32]; + let mut genesis_state = + dsm::core::identity::genesis::GenesisState::new().expect("genesis state"); + genesis_state.hash = canonical_g; + genesis_state.device_id = Some(genesis_sdk.device_info.device_id); + genesis_state.signing_key.public_key = genesis_sdk.device_info.public_key.clone(); + genesis_sdk + .install_v2_genesis(&genesis_state) + .expect("install canonical genesis"); + + // 2. The router's CoreSDK: same device, restores the persisted head. + let router_sdk = CoreSDK::new_with_device(genesis_sdk.device_info.clone()) + .expect("router core restores from db"); + assert_eq!( + router_sdk + .device_head() + .expect("restored head") + .genesis_digest(), + canonical_g, + "precondition: the router's CoreSDK restored the canonical root" + ); + + // 3. What AppRouterImpl::new does next, unconditionally. + router_sdk + .initialize_with_genesis_state() + .expect("router genesis init"); + + assert_eq!( + router_sdk + .device_head() + .expect("head after router build") + .genesis_digest(), + canonical_g, + "the router build must not replace the canonical root with a synthetic genesis" + ); + let persisted = + crate::storage::client_db::load_bcr_device_head(&genesis_sdk.device_info.device_id) + .expect("reload") + .expect("a persisted head"); + assert_eq!( + persisted.genesis_digest(), + canonical_g, + "the PERSISTED root must survive the router build — this is what the faucet reads" + ); + } + + /// Control for the guard: where NO head exists, `initialize_with_genesis_state` + /// must still produce one. Test fixtures and the headless identity paths rely + /// on that, and the guard must not silently turn it into a no-op. + #[test] + #[serial] + fn initialize_with_genesis_state_still_builds_a_head_when_none_exists() { + unsafe { std::env::set_var("DSM_SDK_TEST_MODE", "1") }; + crate::storage::client_db::reset_database_for_tests(); + crate::storage::client_db::init_database().expect("init db"); + let sdk = test_sdk(); + assert!( + sdk.device_head().is_none(), + "precondition: fresh SDK has no head" + ); + sdk.initialize_with_genesis_state().expect("genesis init"); + assert!( + sdk.device_head().is_some(), + "with no head present the bootstrap path must still create one" + ); + } + /// Stage 4 Slice 3 (signal a): the offline-bearer "no appliance" error must speak v2 — name the /// anchor device the user connects — and never the deleted v1 "Path-B" concept. This message /// rides into the failed-transfer event the frontend friendly-maps. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/identity_publication.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/identity_publication.rs index bd4aae21..a00b541e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/identity_publication.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/identity_publication.rs @@ -71,14 +71,21 @@ pub async fn publish_identity_now( }; if report.is_published() { - if let Err(e) = upsert_publication_state( + match upsert_publication_state( device_id_b32, genesis_hash_b32, PublicationState::Published, report.required, "", ) { - log::warn!("identity_publication: failed to persist Published state: {e}"); + // The write is what makes the identity ready, so the session + // refresh goes out beside it. Publication runs in the background + // after genesis; the host computed the session phase while this + // was still pending, and nothing else tells it to recompute. + Ok(()) => push_session_refresh(), + Err(e) => { + log::warn!("identity_publication: failed to persist Published state: {e}") + } } } else { let summary = report @@ -105,6 +112,21 @@ fn record_pending(device_id_b32: &str, genesis_hash_b32: &str, required: u32, er } } +/// Ask the host to republish the session snapshot. +/// +/// `dsm-wallet-refresh` is the topic the Android host treats as a session hint: +/// it re-runs `publishSessionState`, which recomputes the phase from the +/// persisted publication row this module just wrote. +#[cfg(all(target_os = "android", feature = "jni"))] +fn push_session_refresh() { + if let Err(e) = crate::jni::event_dispatch::post_event_to_webview("dsm-wallet-refresh", &[]) { + log::warn!("identity_publication: session refresh dispatch failed: {e}"); + } +} + +#[cfg(not(all(target_os = "android", feature = "jni")))] +fn push_session_refresh() {} + /// Whether this device's identity is ready to use — i.e. a quorum of nodes has /// been read-back verified. A durable local genesis record does NOT satisfy /// this. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/kyber_identity.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/kyber_identity.rs index c1a9c5f5..9ced7994 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/kyber_identity.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/kyber_identity.rs @@ -18,7 +18,11 @@ //! persisting the Kyber key, closing any key-substitution path. The ~29 KB //! SPHINCS+ signature rides in the registry / device-info record, never the QR. //! -//! The Kyber SECRET key is never read, transmitted, or reconstructed here. +//! The Kyber SECRET key is never transmitted or persisted here. It is also +//! never read from storage — the one place a secret exists in this module is +//! the cold-cache recovery in [`build_local_kyber_identity_binding`], which +//! re-derives the keypair solely to take its public half and drops the secret +//! at the end of that expression. use dsm::crypto::{blake3::domain_hash, kyber, sphincs}; use dsm::types::error::DsmError; @@ -52,8 +56,30 @@ fn as_array_32(bytes: &[u8], what: &str) -> Result<[u8; 32], DsmError> { /// Build this device's Kyber identity binding for registry publication. /// Returns `(kyber_public_key, binding_sig)`. Fails closed when the wallet is -/// locked (no AK secret) or the local Kyber public key is uninitialised or -/// malformed. The Kyber secret key is never touched. +/// locked (no AK secret) or the canonical Kyber key material is unavailable. +/// +/// The process-global `bridge::LOCAL_KYBER_PUBKEY` is a CACHE, never a +/// precondition. It is installed at router build, which on a first-run device +/// happens BEFORE genesis exists — so `WalletSDK::new()` has no genesis state, +/// the install is skipped, and the slot stays cold for the rest of that +/// session. Genesis then publishes its device tree and immediately tries to +/// publish the identity, which read the cold slot and failed with "local Kyber +/// public key not installed". The device parked in `PublicationPending` with +/// only the STARTUP retry able to clear it: every first-run device required an +/// app restart to finish publishing, and a real user just watched "PUBLISHING +/// IDENTITY…" forever. +/// +/// So a cold slot is recovered here from the canonical source rather than +/// refused: `current_smaster()` + `DSM/kyber\0`, the SAME derivation +/// `WalletSDK::init_device_keys` uses to populate `{device_id}_device_kyber_pk`, +/// so the recovered key is byte-identical to the keystore's. It is NOT the +/// random pre-genesis shell keypair that path falls back to — if the canonical +/// material is genuinely unavailable (no seed, no genesis, wallet locked) this +/// fails closed. Nothing is synthesised or substituted. +/// +/// The Kyber SECRET key is never transmitted or persisted here; the recovery +/// path re-derives the keypair only to take its public half, and drops the +/// secret immediately. pub fn build_local_kyber_identity_binding() -> Result<(Vec, Vec), DsmError> { let device_id = as_array_32( &AppState::get_device_id() @@ -65,9 +91,21 @@ pub fn build_local_kyber_identity_binding() -> Result<(Vec, Vec), DsmErr .ok_or_else(|| DsmError::InvalidState("genesis_hash not initialised".into()))?, "genesis_hash", )?; - let kyber_pk = crate::bridge::local_kyber_pubkey() - .filter(|k| !k.is_empty()) - .ok_or_else(|| DsmError::InvalidState("local Kyber public key not installed".into()))?; + let kyber_pk = match crate::bridge::local_kyber_pubkey().filter(|k| !k.is_empty()) { + Some(pk) => pk, + None => { + // Cold cache — recover the canonical key, then warm the slot so the + // rest of the session takes the fast path. + let smaster = crate::init::current_smaster()?; + let (pk, _sk) = kyber::generate_kyber_keypair_from_entropy(&smaster, "DSM/kyber\0")?; + log::info!( + "[kyber_identity] local Kyber public key cache was cold; recovered the canonical \ + key from Smaster and installed it (no restart required)" + ); + crate::bridge::install_local_kyber_pubkey(pk.clone()); + pk + } + }; if kyber_pk.len() != kyber::public_key_bytes() { return Err(DsmError::invalid_parameter(format!( "local Kyber public key must be {} bytes (ML-KEM-768), got {}", @@ -122,6 +160,94 @@ pub fn verify_kyber_identity_binding( mod tests { use super::*; + /// THE FIRST-RUN CONDITION, REPRODUCED. On a fresh device the router is + /// built before genesis exists, so `WalletSDK::new()` cannot hand over a + /// Kyber key and `bridge::LOCAL_KYBER_PUBKEY` stays cold for that whole + /// session. Genesis then commits and immediately builds the identity + /// binding to publish. Before the fix that read the cold slot and failed + /// with "local Kyber public key not installed", parking every first-run + /// device in `PublicationPending` until the app was RESTARTED. + /// + /// This test holds the cache cold and asserts the binding still builds, in + /// the same process, with no router rebuild and no restart — and that the + /// key it recovered is the canonical one, not a fresh random keypair. + #[test] + #[serial_test::serial] + fn binding_builds_with_a_cold_cache_and_recovers_the_canonical_key() { + std::env::set_var("DSM_SDK_TEST_MODE", "1"); + let device_id = vec![0x11u8; 32]; + let genesis = vec![0x22u8; 32]; + crate::sdk::app_state::AppState::set_identity_info( + device_id.clone(), + vec![0x33u8; 32], + genesis.clone(), + vec![0x44u8; 32], + ); + crate::sdk::recovery_sdk::RecoverySDK::set_cached_wallet_seed_for_testing( + b"DSM/test/cold-kyber-cache-seed".to_vec(), + ); + + // What the wallet keystore WOULD hold: the same Smaster derivation + // `WalletSDK::init_device_keys` uses. This is the canonical answer. + let smaster = crate::init::current_smaster().expect("smaster from seed + genesis"); + let (canonical_pk, _sk) = + kyber::generate_kyber_keypair_from_entropy(&smaster, "DSM/kyber\0") + .expect("canonical kyber derivation"); + + // Cold cache: empty is treated as absent by the getter's filter. + crate::bridge::install_local_kyber_pubkey(Vec::new()); + assert!( + crate::bridge::local_kyber_pubkey() + .filter(|k| !k.is_empty()) + .is_none(), + "precondition: the cache must be cold" + ); + + let (pk, sig) = build_local_kyber_identity_binding() + .expect("a cold cache must NOT block identity binding"); + + assert_eq!( + pk, canonical_pk, + "the recovered key must be the canonical Smaster-derived one, never a fresh keypair" + ); + assert_eq!( + crate::bridge::local_kyber_pubkey().expect("cache warmed"), + canonical_pk, + "the fallback must install exactly the canonical key as the cache" + ); + + // The binding actually verifies against the device's own AK — proving + // we produced a publishable artifact, not merely a non-error. + let ak_pk = crate::sdk::signing_authority::current_public_key().expect("AK public key"); + let did = as_array_32(&device_id, "device_id").expect("32"); + let g = as_array_32(&genesis, "genesis").expect("32"); + verify_kyber_identity_binding(&did, &g, &pk, &sig, &ak_pk) + .expect("the binding built from a cold cache must verify"); + } + + /// The honest residual: recovery is not a licence to invent. With no wallet + /// seed cached there is no canonical Kyber material, and the binding must + /// fail closed rather than fall back to the random pre-genesis shell + /// keypair `init_device_keys` uses for its own unpublished shell. + #[test] + #[serial_test::serial] + fn a_cold_cache_without_canonical_material_fails_closed() { + std::env::set_var("DSM_SDK_TEST_MODE", "1"); + crate::sdk::app_state::AppState::set_identity_info( + vec![0x55u8; 32], + vec![0x66u8; 32], + vec![0x77u8; 32], + vec![0x88u8; 32], + ); + crate::sdk::recovery_sdk::RecoverySDK::clear_cached_wallet_seed_for_testing(); + crate::bridge::install_local_kyber_pubkey(Vec::new()); + + let err = build_local_kyber_identity_binding() + .expect_err("no canonical material must fail closed, not fabricate a key"); + let msg = format!("{err:?}"); + assert!(!msg.is_empty(), "the refusal must carry a reason: {msg}"); + } + /// B4 CACHE TRACE, second half: verification is RECOMPUTED from the binding /// every call, never read from a stored verdict. /// diff --git a/tools/vertical_validation/src/implementation_traces.rs b/tools/vertical_validation/src/implementation_traces.rs index 55555356..400fe609 100644 --- a/tools/vertical_validation/src/implementation_traces.rs +++ b/tools/vertical_validation/src/implementation_traces.rs @@ -223,8 +223,7 @@ fn trace_state_machine_transfer_chain( .insert(sender_key, Balance::from_state(100, state.hash)); refresh_state_hash(&mut state); - let mut machine = StateMachine::new(); - machine.set_state(state.clone()); + let mut machine = machine_with_declared_genesis(&state, seed_bytes); let steps = [1u64, 2, 3, 4]; for (idx, amount) in steps.iter().enumerate() { @@ -279,8 +278,7 @@ fn trace_state_machine_signature_rejection( refresh_state_hash(&mut state); let original_hash = state.hash().expect("original hash"); - let mut machine = StateMachine::new(); - machine.set_state(state.clone()); + let mut machine = machine_with_declared_genesis(&state, seed_bytes); let mut op = build_signed_transfer(sk, &state, vec![9; 8], 10, b"ERA".to_vec(), vec![0xCD; 32]); if let Operation::Transfer { signature, .. } = &mut op { @@ -330,10 +328,8 @@ fn trace_state_machine_fork_divergence( refresh_state_hash(&mut state); let prev_hash = state.hash().expect("fork parent hash"); - let mut machine_a = StateMachine::new(); - machine_a.set_state(state.clone()); - let mut machine_b = StateMachine::new(); - machine_b.set_state(state.clone()); + let mut machine_a = machine_with_declared_genesis(&state, seed_bytes); + let mut machine_b = machine_with_declared_genesis(&state, seed_bytes); let op_a = build_signed_transfer(sk, &state, vec![1; 8], 1, b"ERA".to_vec(), vec![0xD1; 32]); let op_b = build_signed_transfer(sk, &state, vec![2; 8], 2, b"ERA".to_vec(), vec![0xD2; 32]); @@ -2265,6 +2261,27 @@ fn compute_djte_next_tip( domain_hash_bytes(dsm::common::domain_tags::TAG_DJTE_DLV_TIP, &buf) } +/// A trace's machine with a device head rooted at the genesis the trace +/// itself declares. +/// +/// `StateMachine::set_state` no longer manufactures a head: it does not know +/// the genesis authority root and must not invent one (a fabricated zero +/// root is what broke every first-run wallet's authority evidence). These +/// traces build their state with `State::new_genesis(seed, ..)`, so the seed +/// IS the root they declare — the head is installed with it explicitly, then +/// `set_state` re-seeds the legacy root exactly as before. +fn machine_with_declared_genesis(state: &State, genesis: &[u8; 32]) -> StateMachine { + let mut machine = StateMachine::new(); + machine.set_device_head(dsm::types::device_state::DeviceState::new( + *genesis, + state.device_info.device_id, + state.device_info.public_key.clone(), + 1024, + )); + machine.set_state(state.clone()); + machine +} + fn create_test_state(seed_bytes: &[u8; 32], pk: &[u8]) -> State { let device_id: [u8; 32] = *domain_hash(dsm::common::domain_tags::TAG_DSM_TEST_DEVICE, seed_bytes).as_bytes();