for CorruptKind {
+ fn from(e: RehydrateRowError) -> Self {
+ match e {
+ RehydrateRowError::MissingManifest => CorruptKind::MissingManifest,
+ RehydrateRowError::MalformedXpub => CorruptKind::MalformedXpub,
+ RehydrateRowError::DecodeError(s) => CorruptKind::DecodeError(s),
+ }
+ }
+}
+
/// Errors that can occur in platform wallet operations
#[derive(Debug, thiserror::Error)]
pub enum PlatformWalletError {
#[error("Wallet creation failed: {0}")]
WalletCreation(String),
+ /// The persisted wallet has UTXOs to restore but no funds-bearing
+ /// account in its reconstructed account collection to hold them.
+ /// Fail-closed rather than reconstructing a silent zero balance —
+ /// the no-silent-zero mandate. Carries only the (public) wallet id
+ /// and the dropped-UTXO count, never key material.
+ #[error(
+ "rehydration topology unsupported for wallet {}: {utxo_count} persisted UTXO(s) but no funds-bearing account",
+ hex::encode(wallet_id)
+ )]
+ RehydrationTopologyUnsupported {
+ /// The wallet whose topology could not hold the persisted UTXOs.
+ wallet_id: [u8; 32],
+ /// How many persisted UTXOs would have been silently dropped.
+ utxo_count: usize,
+ },
+
+ /// The deep-index discovery probes did not mirror the account's real
+ /// address pools 1:1 during rehydration, so applying probe depths by
+ /// position would index the wrong pool. Fail-closed instead of risking
+ /// a misattributed derivation — the probes are built directly from the
+ /// same `address_pools()` enumeration, so a mismatch is a structural
+ /// invariant break, not user-reachable.
+ #[error(
+ "rehydration pool/probe mismatch: expected {expected} address pool(s) to mirror the discovery probes, found {found}"
+ )]
+ RehydrationPoolMismatch {
+ /// Number of discovery probes built from `address_pools()`.
+ expected: usize,
+ /// Number of real address pools from `address_pools_mut()`.
+ found: usize,
+ },
+
#[error("Wallet not found: {0}")]
WalletNotFound(String),
diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs
index 9ac256e8730..f39a8126a5d 100644
--- a/packages/rs-platform-wallet/src/events.rs
+++ b/packages/rs-platform-wallet/src/events.rs
@@ -16,9 +16,34 @@ use arc_swap::ArcSwap;
pub use dash_spv::EventHandler;
pub use key_wallet_manager::WalletEvent;
+use crate::manager::load_outcome::SkipReason;
use crate::manager::platform_address_sync::PlatformAddressSyncSummary;
#[cfg(feature = "shielded")]
use crate::manager::shielded_sync::ShieldedSyncPassSummary;
+use crate::wallet::platform_wallet::WalletId;
+
+/// Platform-wallet lifecycle event surfaced to app handlers.
+///
+/// Distinct from the SPV `EventHandler` stream — these are
+/// platform-specific notifications the app may react to (toast,
+/// telemetry) without threading return values through every call site.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PlatformEvent {
+ /// A persisted wallet was skipped during
+ /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)
+ /// because its persisted row was corrupt (a structural decode /
+ /// projection failure). The load path is seedless, so the only
+ /// reason is [`SkipReason::CorruptPersistedRow`].
+ ///
+ /// Carries the (public, non-secret) wallet id and the structural
+ /// [`SkipReason`]; never any secret byte.
+ WalletSkippedOnLoad {
+ /// The skipped wallet's id.
+ wallet_id: WalletId,
+ /// Why it was skipped — always a corrupt persisted row.
+ reason: SkipReason,
+ },
+}
/// Extension of [`EventHandler`] for platform-wallet consumers.
///
@@ -44,6 +69,33 @@ pub trait PlatformEventHandler: EventHandler {
#[cfg(feature = "shielded")]
fn on_shielded_sync_completed(&self, _summary: &ShieldedSyncPassSummary) {}
+ /// Fired once per wallet that
+ /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)
+ /// skipped because its persisted row was corrupt.
+ ///
+ /// Prefer this concrete overload over [`on_platform_event`](Self::on_platform_event)
+ /// — it matches the handler pattern used everywhere else on this trait and
+ /// removes the enum-dispatch indirection. The default implementation
+ /// delegates to `on_platform_event` so existing implementations that only
+ /// override `on_platform_event` continue to receive the event without any
+ /// changes.
+ fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) {
+ self.on_platform_event(&PlatformEvent::WalletSkippedOnLoad {
+ wallet_id,
+ reason: reason.clone(),
+ });
+ }
+
+ /// Fired once per wallet that
+ /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)
+ /// skipped because its persisted row was corrupt.
+ ///
+ /// **Deprecated in favour of [`on_wallet_skipped_on_load`](Self::on_wallet_skipped_on_load)**,
+ /// which follows the concrete-handler pattern used elsewhere on this trait.
+ /// The default implementation is a no-op; override
+ /// `on_wallet_skipped_on_load` for new code.
+ fn on_platform_event(&self, _event: &PlatformEvent) {}
+
/// Fired periodically during a shielded sync pass — once per
/// completed chunk inside `sync_shielded_notes`. Carries the
/// cumulative count of encrypted notes scanned so far in the
@@ -142,6 +194,31 @@ impl PlatformEventManager {
}
}
+ /// Dispatch a wallet-skipped-on-load notification to every handler.
+ ///
+ /// Not on the SPV hot path — called at most once per wallet during
+ /// a single `load_from_persistor` pass. Prefer this over
+ /// [`on_platform_event`](Self::on_platform_event) for new call sites;
+ /// it avoids heap-allocating a `PlatformEvent` wrapper and follows the
+ /// concrete-handler pattern used throughout this manager.
+ pub fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) {
+ let handlers = self.handlers.load();
+ for h in handlers.iter() {
+ h.on_wallet_skipped_on_load(wallet_id, reason);
+ }
+ }
+
+ /// Dispatch a [`PlatformEvent`] to every handler.
+ ///
+ /// Not on the SPV hot path — called at most once per wallet during
+ /// a single `load_from_persistor` pass.
+ pub fn on_platform_event(&self, event: &PlatformEvent) {
+ let handlers = self.handlers.load();
+ for h in handlers.iter() {
+ h.on_platform_event(event);
+ }
+ }
+
/// Dispatch a shielded sync progress event to every handler.
///
/// Called from inside `sync_shielded_notes`'s chunk loop, once
diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs
index 289a71378fd..e9ddfb9c665 100644
--- a/packages/rs-platform-wallet/src/lib.rs
+++ b/packages/rs-platform-wallet/src/lib.rs
@@ -22,7 +22,7 @@ pub mod spv;
pub mod wallet;
pub use error::PlatformWalletError;
-pub use events::{PlatformEventHandler, PlatformEventManager};
+pub use events::{PlatformEvent, PlatformEventHandler, PlatformEventManager};
pub use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType;
// Surface the upstream `DerivedAddress` event payload through this
// crate so downstream FFI consumers (rs-platform-wallet-ffi) can
@@ -40,6 +40,7 @@ pub use manager::identity_sync::{
DEFAULT_SYNC_INTERVAL_SECS as IDENTITY_SYNC_DEFAULT_INTERVAL_SECS,
MAX_TOKENS_PER_BALANCE_BATCH as IDENTITY_SYNC_MAX_TOKENS_PER_BATCH,
};
+pub use manager::load_outcome::{LoadOutcome, SkipReason};
pub use manager::platform_address_sync::{
PlatformAddressSyncManager, PlatformAddressSyncSummary, WalletSyncOutcome,
DEFAULT_SYNC_INTERVAL_SECS,
diff --git a/packages/rs-platform-wallet/src/manager/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs
index 8730398f978..a467d910467 100644
--- a/packages/rs-platform-wallet/src/manager/identity_sync.rs
+++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs
@@ -160,10 +160,6 @@ where
persister: Arc,
/// Cancel token for the background loop, if running.
background_cancel: StdMutex>,
- /// Monotonically increasing generation counter. Incremented each
- /// time `start()` installs a new cancel token so the exiting
- /// thread can tell whether its token is still current.
- background_generation: AtomicU64,
interval_secs: AtomicU64,
is_syncing: AtomicBool,
/// Set by [`quiesce`](Self::quiesce) to gate new passes while it
@@ -204,7 +200,6 @@ where
sdk,
persister,
background_cancel: StdMutex::new(None),
- background_generation: AtomicU64::new(0),
interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS),
is_syncing: AtomicBool::new(false),
quiescing: AtomicBool::new(false),
@@ -401,7 +396,6 @@ where
}
let cancel = CancellationToken::new();
*guard = Some(cancel.clone());
- let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1;
drop(guard);
let handle = tokio::runtime::Handle::current();
@@ -424,12 +418,8 @@ where
}
}
- // Only clear the slot if no newer start() has
- // installed a replacement token since we launched.
if let Ok(mut guard) = this.background_cancel.lock() {
- if this.background_generation.load(Ordering::Acquire) == my_gen {
- *guard = None;
- }
+ *guard = None;
}
});
})
diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs
index 8e7af9be1c7..267c2c00a04 100644
--- a/packages/rs-platform-wallet/src/manager/load.rs
+++ b/packages/rs-platform-wallet/src/manager/load.rs
@@ -3,8 +3,11 @@
use std::collections::BTreeMap;
use std::sync::Arc;
+use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+
use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence};
use crate::error::PlatformWalletError;
+use crate::manager::load_outcome::{LoadOutcome, SkipReason};
use crate::wallet::core::WalletBalance;
use crate::wallet::identity::IdentityManager;
use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId};
@@ -13,23 +16,42 @@ use crate::wallet::PlatformWallet;
use super::PlatformWalletManager;
impl PlatformWalletManager {
- /// Load the full [`ClientStartState`] from the configured persister
- /// and rehydrate the manager's `wallet_manager` and `wallets` maps.
+ /// Restore every persisted wallet as a **watch-only** entry — no
+ /// signing key material is derived here. The persister hands back a
+ /// keyless reconstruction snapshot; each wallet is rebuilt via
+ /// [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only)
+ /// from its [`AccountRegistrationEntry`](crate::changeset::AccountRegistrationEntry)
+ /// manifest, the keyless core-state projection is applied, and the
+ /// result is registered into the manager.
+ ///
+ /// The load path never touches the seed, so it performs no wrong-seed
+ /// check. Signing happens later, on demand, via the configured
+ /// [`MnemonicResolverHandle`].
///
- /// For each persisted wallet this builds a `PlatformWalletInfo` from
- /// the snapshot (core wallet info, identity manager, tracked asset
- /// locks) and inserts the `(Wallet, PlatformWalletInfo)` pair into
- /// the inner [`WalletManager`]. A matching [`PlatformWallet`] handle
- /// is then constructed and registered in `self.wallets`.
+ /// # Skip vs hard-fail
///
- /// If the snapshot includes platform-address provider state, each
- /// per-wallet slice is handed to
- /// [`PlatformAddressWallet::initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted);
- /// wallets missing from that slice get a fresh
- /// [`PlatformAddressWallet::initialize`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize).
+ /// - **Per-row decode/projection failure** (empty manifest, malformed
+ /// xpub, duplicate `account_type`, …): the wallet is **skipped** —
+ /// never inserted into `wallet_manager` / `self.wallets`, recorded
+ /// in [`LoadOutcome::skipped`] with a structural
+ /// [`SkipReason::CorruptPersistedRow`], and a
+ /// [`PlatformEvent::WalletSkippedOnLoad`] is emitted. One bad row
+ /// never aborts the others; the call still returns `Ok`.
+ /// - **Whole-load failure** (persister I/O, programmer error, the
+ /// no-silent-zero topology check in
+ /// [`apply_persisted_core_state`](super::rehydrate::apply_persisted_core_state)):
+ /// `Err(_)` — every wallet inserted earlier in this pass is
+ /// rolled back. Skipped wallets never entered the maps so the
+ /// rollback path never sees them.
///
- /// [`WalletManager`]: key_wallet_manager::WalletManager
- pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> {
+ /// Platform-address provider state is restored per wallet via
+ /// [`initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted),
+ /// or a fresh
+ /// [`initialize`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize)
+ /// when the snapshot carries no slice for it.
+ ///
+ /// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle
+ pub async fn load_from_persistor(&self) -> Result {
let ClientStartState {
mut platform_addresses,
wallets,
@@ -46,47 +68,70 @@ impl PlatformWalletManager {
let persister_dyn: Arc = Arc::clone(&self.persister) as _;
- // Track every wallet successfully inserted into
- // `wallet_manager` and `self.wallets` during this call so the
- // batch is transactional: if any later iteration fails (id
- // mismatch, `initialize_from_persisted` error), we walk back
- // every prior insert before bailing. Without this, a clean
- // retry would collide on `WalletManager::insert_wallet`
- // returning `WalletAlreadyExists` for every previously-loaded
- // wallet — half-poisoning the manager until the process
- // restarts. The orphan state is observable across the FFI
- // boundary with no Swift-side reset path, so transactional
- // semantics matter for this hydration API.
+ // Transactional batch: every wallet inserted into
+ // `wallet_manager` / `self.wallets` is tracked so a later hard
+ // error walks back every prior insert. Skipped wallets never
+ // enter either map, so the rollback path never sees them.
let mut inserted_in_manager: Vec = Vec::new();
let mut inserted_in_wallets: Vec = Vec::new();
let mut load_error: Option = None;
+ let mut outcome = LoadOutcome::default();
'load: for (expected_wallet_id, wallet_state) in wallets {
let ClientWalletStartState {
- wallet,
- wallet_info,
+ network,
+ birth_height,
+ account_manifest,
+ core_state,
identity_manager,
unused_asset_locks,
+ contacts,
+ identity_keys,
} = wallet_state;
- // Flatten the (account → outpoint → lock) map into the flat
- // OutPoint → TrackedAssetLock map that `PlatformWalletInfo`
- // holds today.
+ // Build the watch-only wallet from the keyless manifest. A
+ // structural decode failure skips this row (per-row
+ // resilience) — it never aborts the batch and never inserts
+ // a degraded placeholder.
+ let wallet = match super::rehydrate::build_watch_only_wallet(
+ network,
+ expected_wallet_id,
+ &account_manifest,
+ ) {
+ Ok(w) => w,
+ Err(row_err) => {
+ let reason = SkipReason::CorruptPersistedRow {
+ kind: row_err.into(),
+ };
+ outcome.skipped.push((expected_wallet_id, reason.clone()));
+ self.event_manager
+ .on_wallet_skipped_on_load(expected_wallet_id, &reason);
+ continue 'load;
+ }
+ };
+
+ // Mint the managed-info skeleton from the watch-only wallet,
+ // then apply the keyless persisted core state (UTXOs, sync
+ // watermarks, per-account balances). A wallet with persisted
+ // UTXOs but no funds account hard-fails here rather than
+ // reconstructing a silent zero balance.
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height);
+ if let Err(e) = super::rehydrate::apply_persisted_core_state(
+ &mut wallet_info,
+ &account_manifest,
+ &core_state,
+ ) {
+ load_error = Some(e);
+ break 'load;
+ }
+
+ // Flatten the (account → outpoint → lock) map.
let mut tracked_asset_locks = BTreeMap::new();
for (_account_index, account_locks) in unused_asset_locks {
tracked_asset_locks.extend(account_locks);
}
let balance = Arc::new(WalletBalance::new());
- // Mirror the inner `ManagedWalletInfo.balance` (already
- // recomputed from the freshly-loaded UTXO set on the FFI
- // side via `update_balance`) into the lock-free `Arc` the
- // UI reads. Without this, `wallet.balance()` reports zero
- // for restored wallets even though the per-account totals
- // and the inner `core_wallet.balance` are correct.
- // `WalletBalance::set` is `pub(crate)`, which is why this
- // step has to live inside `platform_wallet` rather than
- // the FFI loader.
let core_balance = &wallet_info.balance;
balance.set(
core_balance.confirmed(),
@@ -94,17 +139,19 @@ impl PlatformWalletManager {
core_balance.immature(),
core_balance.locked(),
);
+ // Build the identity manager from the (id, balance,
+ // revision) skeleton, then layer the persisted PUBLIC
+ // contacts + identity keys onto it — the same routing the
+ // runtime changeset-replay path uses.
+ let mut identity_manager = IdentityManager::from(identity_manager);
+ identity_manager.apply_contacts_and_keys(contacts, identity_keys, network);
let platform_info = PlatformWalletInfo {
core_wallet: wallet_info,
balance: Arc::clone(&balance),
- identity_manager: IdentityManager::from(identity_manager),
+ identity_manager,
tracked_asset_locks,
};
- // Insert into `wallet_manager` first so we have a wallet
- // handle to validate against. Track success in
- // `inserted_in_manager` so the batch-rollback at the
- // bottom can unwind on any later-iteration failure.
let wallet_id = {
let mut wm = self.wallet_manager.write().await;
match wm.insert_wallet(wallet, platform_info) {
@@ -120,15 +167,6 @@ impl PlatformWalletManager {
};
inserted_in_manager.push(wallet_id);
- if wallet_id != expected_wallet_id {
- load_error = Some(PlatformWalletError::WalletCreation(format!(
- "Persisted wallet id {} does not match recomputed id {}",
- hex::encode(expected_wallet_id),
- hex::encode(wallet_id)
- )));
- break 'load;
- }
-
let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone(
&self.spv_manager,
)));
@@ -142,10 +180,6 @@ impl PlatformWalletManager {
broadcaster,
);
- // Initialize the platform-address provider. If the snapshot
- // carried a slice for this wallet, restore it directly;
- // otherwise do a fresh scan from the live wallet manager.
- // Failures break to the rollback path below.
if let Some(persisted) = platform_addresses.remove(&wallet_id) {
if let Err(e) = platform_wallet
.platform()
@@ -167,13 +201,10 @@ impl PlatformWalletManager {
wallets_guard.insert(wallet_id, platform_wallet);
drop(wallets_guard);
inserted_in_wallets.push(wallet_id);
+ outcome.loaded.push(wallet_id);
}
if let Some(err) = load_error {
- // Walk back every wallet committed in this call so the
- // manager state matches what it was before. Order:
- // remove from `self.wallets` first (UI surface), then
- // from the inner `wallet_manager`.
if !inserted_in_wallets.is_empty() {
let mut wallets_guard = self.wallets.write().await;
for id in &inserted_in_wallets {
@@ -189,6 +220,6 @@ impl PlatformWalletManager {
return Err(err);
}
- Ok(())
+ Ok(outcome)
}
}
diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs
new file mode 100644
index 00000000000..b71b28fe406
--- /dev/null
+++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs
@@ -0,0 +1,77 @@
+//! Aggregate result of [`load_from_persistor`].
+//!
+//! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor
+
+use crate::wallet::platform_wallet::WalletId;
+
+/// Why a persisted wallet row was skipped during a load pass.
+///
+/// Load is **watch-only** (no seed material involved): signing keys are
+/// derived later, on demand, via the [`MnemonicResolverHandle`] sign
+/// path. A skip therefore means the persisted row itself was unusable —
+/// a per-row decode/structural failure that fails one wallet without
+/// aborting the batch. The only reason is
+/// [`CorruptPersistedRow`](Self::CorruptPersistedRow): the load path
+/// never touches the seed, so it cannot skip for a wrong or unavailable
+/// seed. Variants carry no key material (SECRETS.md SEC-REQ-2.0.1).
+///
+/// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle
+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
+pub enum SkipReason {
+ /// The persisted row could not be reconstructed: a structural decode
+ /// failure on the keyless account manifest or core-state projection.
+ /// `kind` distinguishes the failure mode without leaking row bytes.
+ #[error("persisted wallet row corrupt: {kind}")]
+ CorruptPersistedRow {
+ /// Structural family of the decode/projection failure.
+ kind: CorruptKind,
+ },
+}
+
+/// Structural family of [`SkipReason::CorruptPersistedRow`].
+///
+/// The variants are deliberately coarse — a finer split would require
+/// the persister to round-trip backend error context that may carry
+/// row-derived bytes. Apps drive their UI from the *family*, not from
+/// the inner message.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum CorruptKind {
+ /// The wallet row exists but has no usable `AccountRegistrationEntry`
+ /// manifest to rebuild the account collection from.
+ MissingManifest,
+ /// One or more manifest `account_xpub` bytes failed to parse as a
+ /// well-formed extended public key.
+ MalformedXpub,
+ /// Any other structural decode / projection failure surfaced by the
+ /// persister. The string is a structural projection — never a raw
+ /// row byte slice or a hex-encoded key.
+ DecodeError(String),
+}
+
+impl std::fmt::Display for CorruptKind {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::MissingManifest => f.write_str("missing account manifest"),
+ Self::MalformedXpub => f.write_str("malformed account xpub"),
+ Self::DecodeError(s) => write!(f, "decode error: {s}"),
+ }
+ }
+}
+
+/// Aggregate, synchronous view of one
+/// [`load_from_persistor`](super::PlatformWalletManager::load_from_persistor)
+/// pass.
+///
+/// `Ok(LoadOutcome)` with a non-empty `skipped` is **success** — a
+/// per-row decode failure on one wallet is recorded and the batch
+/// continues. The `Err` arm is reserved for whole-load failures
+/// (persister I/O, programmer error). The load path is watch-only and
+/// never touches the seed, so no wrong-seed outcome appears here.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct LoadOutcome {
+ /// Wallets fully reconstructed and registered, in load order.
+ pub loaded: Vec,
+ /// Wallets skipped because their persisted row was corrupt, in load
+ /// order.
+ pub skipped: Vec<(WalletId, SkipReason)>,
+}
diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs
index 3d04ca086d0..265b525277f 100644
--- a/packages/rs-platform-wallet/src/manager/mod.rs
+++ b/packages/rs-platform-wallet/src/manager/mod.rs
@@ -3,7 +3,9 @@
pub mod accessors;
pub mod identity_sync;
mod load;
+pub mod load_outcome;
pub mod platform_address_sync;
+pub mod rehydrate;
#[cfg(feature = "shielded")]
pub mod shielded_sync;
mod wallet_lifecycle;
@@ -72,14 +74,18 @@ pub struct PlatformWalletManager {
#[cfg(feature = "shielded")]
pub(super) shielded_coordinator:
Arc>>>,
- /// Shared `PlatformEventManager` — held on the manager so
- /// `configure_shielded` can install a per-chunk progress handler
- /// onto the freshly-created `NetworkShieldedCoordinator` that
- /// forwards into `on_shielded_sync_progress`. Sub-managers
+ /// Shared `PlatformEventManager`, retained on the manager for the
+ /// two callers that fan out platform-wallet events directly:
+ /// `load_from_persistor` surfaces per-wallet
+ /// [`PlatformEvent`](crate::events::PlatformEvent) skip
+ /// notifications to the app handler, and (under the `shielded`
+ /// feature) `configure_shielded` installs a per-chunk progress
+ /// handler onto the freshly-created `NetworkShieldedCoordinator`
+ /// that forwards into `on_shielded_sync_progress`. Sub-managers
/// (`SpvRuntime`, `PlatformAddressSyncManager`, etc.) hold their
- /// own clones already, so `configure_shielded` is the only reader of
- /// this retained handle — hence it is `shielded`-gated.
- #[cfg(feature = "shielded")]
+ /// own clones already. Retained unconditionally because
+ /// `load_from_persistor` reads it regardless of the `shielded`
+ /// feature.
pub(super) event_manager: Arc,
pub(super) persister: Arc,
/// Cancellation token + join handle for the wallet-event adapter
@@ -161,7 +167,6 @@ impl PlatformWalletManager {
shielded_sync_manager: shielded_sync,
#[cfg(feature = "shielded")]
shielded_coordinator,
- #[cfg(feature = "shielded")]
event_manager,
persister,
event_adapter_cancel,
diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs
new file mode 100644
index 00000000000..61a3694d520
--- /dev/null
+++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs
@@ -0,0 +1,1323 @@
+//! Watch-only wallet reconstruction + persisted core-state application.
+//!
+//! Load is **seedless** (see [`load_from_persistor`]). For each
+//! persisted wallet we build a watch-only [`Wallet`] from its keyless
+//! `AccountRegistrationEntry` manifest, then apply the keyless
+//! core-state projection on top. No seed, no signing-key derivation.
+//!
+//! Because load never touches the seed, it performs no wrong-seed check.
+//! A sign-time wrong-seed gate is deferred to separate FFI work and is
+//! not part of this path.
+//!
+//! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor
+
+use key_wallet::account::account_collection::AccountCollection;
+use key_wallet::account::Account;
+use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+use key_wallet::wallet::Wallet;
+use key_wallet::Network;
+
+use crate::changeset::AccountRegistrationEntry;
+use crate::error::{PlatformWalletError, RehydrateRowError};
+
+/// Build a watch-only [`Wallet`] from the keyless account manifest.
+///
+/// Each `AccountRegistrationEntry` becomes an [`Account::from_xpub`]
+/// (watch-only) keyed to `expected_wallet_id`; the assembled
+/// [`AccountCollection`] is handed to [`Wallet::new_watch_only`] under
+/// the same id. No key material crosses this function.
+///
+/// Returns [`RehydrateRowError`] when the row is structurally unusable
+/// (caller maps it onto a per-row [`SkipReason`]).
+pub(super) fn build_watch_only_wallet(
+ network: Network,
+ expected_wallet_id: [u8; 32],
+ manifest: &[AccountRegistrationEntry],
+) -> Result {
+ if manifest.is_empty() {
+ return Err(RehydrateRowError::MissingManifest);
+ }
+ let mut accounts = AccountCollection::new();
+ for entry in manifest {
+ let account = Account::from_xpub(
+ Some(expected_wallet_id),
+ entry.account_type,
+ entry.account_xpub,
+ network,
+ )
+ .map_err(|_| RehydrateRowError::MalformedXpub)?;
+ accounts
+ .insert(account)
+ .map_err(|e| RehydrateRowError::DecodeError(e.to_string()))?;
+ }
+ Ok(Wallet::new_watch_only(
+ network,
+ expected_wallet_id,
+ accounts,
+ ))
+}
+
+/// Apply the keyless persisted core-state projection onto a
+/// freshly-minted `ManagedWalletInfo` skeleton.
+///
+/// # Parameters
+///
+/// - `wallet_info`: the skeleton to hydrate in place.
+/// - `manifest`: keyless account manifest (one entry per registered
+/// account). Each entry carries an `account_type` → `account_xpub`
+/// mapping used by [`extend_pools_for_restored_utxos`] to derive
+/// addresses for restored UTXOs. If an account's `account_type` is
+/// absent from the manifest, deep-index derivation is skipped for that
+/// account (no xpub → no derivation possible); already-derived in-window
+/// addresses are still marked used.
+/// - `core`: the persisted core-state changeset to apply.
+///
+/// # Reconstructed (safety-critical-correct)
+///
+/// - **Wallet balance** (`wallet_info.balance`, the no-silent-zero
+/// guarantee): every persisted UTXO is restored and the per-account
+/// + wallet totals are recomputed via `update_balance()`. A UTXO
+/// carrying a block height is marked confirmed so it lands in the
+/// `confirmed` bucket; the wallet total is exact regardless.
+/// - **UTXO set**: every unspent persisted outpoint is restored into a
+/// funds-bearing account of the wallet (whatever topology it has —
+/// BIP44, BIP32, CoinJoin, DashPay).
+/// - **Address-pool depth**: each pool is forward-derived to cover
+/// restored UTXOs at deep derivation indices, then the gap window is
+/// refilled beyond the deepest restored index so the per-address view
+/// reconciles with the wallet total.
+/// - **Sync watermarks**: `synced_height` / `last_processed_height`.
+///
+/// # Reconstructed when the persister supplies it
+///
+/// - **`last_applied_chain_lock`**: restored from `core` when the
+/// supplied [`CoreChangeSet`](crate::changeset::CoreChangeSet) carries
+/// it (the FFI/iOS persister round-trips the value Swift held), so the
+/// asset-lock-resume CL-from-metadata fallback (`proof.rs`) fires at
+/// launch instead of waiting for SPV. The SQLite storage path has no
+/// V001 column for it yet (dashpay/platform#3968), so there it is
+/// absent from `core` and stays `None` until SPV re-applies a fresh
+/// chainlock on the first post-restart sync.
+///
+/// # Deferred to the first post-load `sync` (safe re-warm)
+///
+/// - **Per-account UTXO attribution**: `core_utxos.account_index` is
+/// written as `0` at persist time, so per-account bucketing is not
+/// recoverable from disk; UTXOs are restored against the wallet's
+/// first funds-bearing account and re-attributed on the next scan.
+/// The *wallet total* is unaffected (it is a sum across all funds
+/// accounts).
+/// - **Deep-index address visibility**: each chain's pool scan stops
+/// after [`MAX_REHYDRATION_DERIVATION_INDEX`] or after `gap_limit`
+/// consecutive non-matching indices past the deepest resolved index.
+/// The horizon only advances when an unspent UTXO anchors a match, so a
+/// UTXO address can be left unresolved in two distinct cases: (1) it is
+/// genuinely foreign (a different account's key routed here, or corrupt),
+/// and (2) it is a *legitimately-owned but deep-and-sparse* address —
+/// owned by this account, yet sitting past the first `gap_limit` window
+/// with no nearer unspent UTXO to walk the horizon out to it. Both cases
+/// are counted and logged via `tracing::warn!` and re-warm on the next
+/// full sync. The wallet *total* stays exact (every UTXO is summed
+/// regardless of pool visibility); only the per-address view is
+/// incomplete until that sync. This is the accepted behavior of the
+/// horizon-walk algorithm — see [`extend_pools_for_restored_utxos`].
+/// - **Per-UTXO `is_coinbase` / `is_instantlocked` / `is_trusted`
+/// flags**: not columns in `core_utxos`; conservatively defaulted
+/// (non-coinbase, confirmed-by-height) and refreshed on the next
+/// scan. Coinbase-maturity nuance re-warms on sync.
+/// - **Transaction-record history**: rebuilt by the next scan; not a
+/// balance input.
+///
+/// # Errors
+///
+/// [`PlatformWalletError::RehydrationTopologyUnsupported`] if there are
+/// persisted UTXOs to restore but the reconstructed account collection
+/// has **no** funds-bearing account to hold them. Fail-closed rather
+/// than reconstructing a silent zero balance (the no-silent-zero
+/// mandate). An empty UTXO set is always `Ok`.
+///
+/// This never touches key material.
+pub fn apply_persisted_core_state(
+ wallet_info: &mut ManagedWalletInfo,
+ manifest: &[AccountRegistrationEntry],
+ core: &crate::changeset::CoreChangeSet,
+) -> Result<(), PlatformWalletError> {
+ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
+
+ // Captured before the mutable account borrow below so it can flow into
+ // pool-extension diagnostics without re-borrowing `wallet_info`.
+ let wallet_id = wallet_info.wallet_id;
+
+ // Sync watermarks first so `update_balance`'s maturity check sees
+ // the restored tip.
+ if let Some(h) = core.last_processed_height {
+ wallet_info.metadata.last_processed_height =
+ wallet_info.metadata.last_processed_height.max(h);
+ }
+ if let Some(h) = core.synced_height {
+ wallet_info.metadata.synced_height = wallet_info.metadata.synced_height.max(h);
+ }
+
+ // Restore the highest applied chainlock when the persister carries it
+ // (FFI path) so the asset-lock proof CL-from-metadata fallback fires at launch.
+ if let Some(cl) = &core.last_applied_chain_lock {
+ wallet_info.metadata.last_applied_chain_lock = Some(cl.clone());
+ }
+
+ // Restore the UTXO set. Persisted attribution is lost at write time
+ // (account_index is always 0), so route every restored UTXO to the
+ // wallet's first funds-bearing account *of any topology* (BIP44,
+ // BIP32, CoinJoin, DashPay) — the wallet total is a sum across all
+ // funds accounts and stays exact. A wallet with persisted UTXOs but
+ // no funds account at all cannot be represented: fail closed rather
+ // than silently reconstruct a zero balance.
+ let unspent: Vec<&key_wallet::Utxo> = core
+ .new_utxos
+ .iter()
+ .filter(|u| !core.spent_utxos.iter().any(|s| s.outpoint == u.outpoint))
+ .collect();
+ if !unspent.is_empty() {
+ match wallet_info
+ .accounts
+ .all_funding_accounts_mut()
+ .into_iter()
+ .next()
+ {
+ Some(account) => {
+ for utxo in &unspent {
+ account.utxos.insert(utxo.outpoint, (*utxo).clone());
+ }
+ // Eager derivation covers only `0..=gap_limit`; extend each
+ // chain to cover restored UTXOs at deeper indices.
+ extend_pools_for_restored_utxos(account, manifest, &unspent, wallet_id)?;
+ }
+ None => {
+ return Err(PlatformWalletError::RehydrationTopologyUnsupported {
+ wallet_id,
+ utxo_count: unspent.len(),
+ });
+ }
+ }
+ }
+
+ // Recompute per-account + wallet balance from the restored set.
+ // After this, a non-zero persisted balance is non-zero here — a
+ // silent zero would be a hard FAIL of the rehydration contract.
+ wallet_info.update_balance();
+ Ok(())
+}
+
+/// Upper bound on forward derivation while resolving a restored UTXO
+/// address to its derivation index. Addresses that don't resolve within
+/// this many indices (e.g. they belong to a different funds account whose
+/// UTXOs were routed here, or are corrupt) are left for the next full
+/// rescan to re-warm — generous enough to cover any realistic per-account
+/// derivation depth. The common (single funds account) path terminates at
+/// the true high-water mark well before this and never reaches the cap.
+const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000;
+
+/// Soft threshold past which a single chain's discovery scan is treated as
+/// abnormally deep and worth a `tracing::warn!`. Real funds chains anchor
+/// well below this; reaching it means either a corrupt / foreign-heavy UTXO
+/// set walking the horizon out, or an approach toward the hard
+/// [`MAX_REHYDRATION_DERIVATION_INDEX`] ceiling — both worth surfacing.
+const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000;
+
+/// Extend `account`'s address pools so every resolved UTXO address is
+/// derived at its exact `(chain, index)` slot and marked used, then refill
+/// the gap window beyond — following the sync path's `mark_used` →
+/// `maintain_gap_limit` sequence. Each chain is scanned independently,
+/// stopping once no unresolved address matches within a `gap_limit`-sized
+/// window past the deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`]
+/// is the hard ceiling. Addresses that don't resolve from this account's
+/// xpub — foreign keys, multi-account mismatch, or legitimately-owned but
+/// deep-and-sparse slots with no nearer unspent UTXO to anchor the horizon —
+/// are counted and logged via `tracing::warn!`; they re-warm on the next
+/// full sync. Every restored address the pools *do* hold (in-window or
+/// deep-resolved) is marked used so a funded address is never handed out as
+/// a fresh receive address.
+///
+/// Tested with Standard BIP44 topology (External + Internal pools) and
+/// CoinJoin topology (single External pool). The per-chain probe loop has no
+/// topology-specific branches, so the non-hardened single-pool type
+/// (`Absent`) follows the same code path with a different relative derivation
+/// path. `AbsentHardened` pools cannot be derived from a public xpub at all —
+/// hardened child derivation needs the private key — so under watch-only
+/// rehydration their addresses never resolve and always defer to the next
+/// sync (shared code path, but the outcome is "unresolved").
+///
+/// # Errors
+///
+/// [`PlatformWalletError::RehydrationPoolMismatch`] if the discovery probes
+/// don't mirror the real pools 1:1 (a structural invariant break, not
+/// user-reachable). Fail-closed rather than apply a probe depth to the wrong
+/// pool by position.
+///
+/// Never touches key material — the xpub is the keyless account public key.
+fn extend_pools_for_restored_utxos(
+ account: &mut key_wallet::managed_account::ManagedCoreFundsAccount,
+ manifest: &[AccountRegistrationEntry],
+ restored: &[&key_wallet::Utxo],
+ wallet_id: [u8; 32],
+) -> Result<(), PlatformWalletError> {
+ use key_wallet::managed_account::address_pool::{AddressPool, KeySource};
+ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
+ use std::collections::{BTreeSet, HashSet};
+
+ let account_type = account.managed_account_type().to_account_type();
+
+ // The funds account carries no key material; recover its watch-only xpub
+ // from the keyless manifest by account type. Without it we cannot derive
+ // deeper, but can still mark already-derived (in-window) addresses used.
+ let key_source = manifest
+ .iter()
+ .find(|e| e.account_type == account_type)
+ .map(|e| KeySource::Public(e.account_xpub));
+
+ // Probe pools mirror each real pool's chain 1:1 so the index search
+ // derives into throwaway state (real pools keep their own exact depth)
+ // and the resolved depth can be applied back by position. Re-deriving
+ // each probe from index 0 is an accepted, bounded one-time-load cost
+ // (per chain capped at MAX_REHYDRATION_DERIVATION_INDEX); rehydration
+ // runs once per wallet at startup, never on a hot path.
+ let mut probes: Vec<(AddressPool, BTreeSet)> = account
+ .managed_account_type()
+ .address_pools()
+ .iter()
+ .map(|p| {
+ (
+ AddressPool::new_without_generation(
+ p.base_path.clone(),
+ p.pool_type,
+ p.gap_limit,
+ p.network,
+ ),
+ BTreeSet::new(),
+ )
+ })
+ .collect();
+
+ // Deep-index discovery (requires the xpub): resolve restored addresses the
+ // eager derivation didn't already cover, recording the matching index per
+ // chain. Each chain advances independently and stops once no unresolved
+ // address resolves within gap_limit indices past its deepest match
+ // (preventing a full scan when the UTXO set carries foreign addresses);
+ // MAX_REHYDRATION_DERIVATION_INDEX is the hard ceiling regardless.
+ if let Some(key_source) = key_source.as_ref() {
+ let mut unresolved: HashSet = {
+ let pools = account.managed_account_type().address_pools();
+ restored
+ .iter()
+ .map(|u| u.address.clone())
+ .filter(|addr| !pools.iter().any(|p| p.contains_address(addr)))
+ .collect()
+ };
+
+ for (probe, matched) in probes.iter_mut() {
+ if unresolved.is_empty() {
+ break;
+ }
+ let chain_gap = probe.gap_limit;
+ let mut deepest_resolved: Option = None;
+ let mut index: u32 = 0;
+
+ loop {
+ // Horizon: gap_limit past the deepest match, or the initial
+ // gap_limit window when nothing has resolved yet.
+ let horizon = deepest_resolved
+ .map(|d| d.saturating_add(chain_gap))
+ .unwrap_or(chain_gap);
+
+ if index > horizon || index > MAX_REHYDRATION_DERIVATION_INDEX {
+ break;
+ }
+
+ if let Some(addr) = ensure_derived(probe, key_source, index) {
+ if unresolved.remove(&addr) {
+ matched.insert(index);
+ deepest_resolved = Some(index);
+ }
+ }
+
+ if unresolved.is_empty() {
+ break;
+ }
+
+ index = index.saturating_add(1);
+ }
+
+ // Surface an abnormally deep scan once per chain (outside the loop
+ // — never log inside the per-index walk).
+ if index > REHYDRATION_DEEP_SCAN_WARN_INDEX {
+ tracing::warn!(
+ wallet_id = %hex::encode(wallet_id),
+ account_type = ?account_type,
+ pool_type = ?probe.pool_type,
+ deepest_resolved = ?deepest_resolved,
+ scanned_to = index.saturating_sub(1),
+ "rehydration: chain discovery scanned abnormally deep — \
+ likely a foreign-heavy or sparse UTXO set"
+ );
+ }
+ }
+
+ // Still-unresolved addresses are either foreign (a different account's
+ // key routed here, or corrupt) or legitimately-owned but deep-and-sparse
+ // (past the first gap window with no nearer unspent UTXO to anchor the
+ // horizon). Either way they re-warm on the next full sync; the wallet
+ // total is exact regardless.
+ if !unresolved.is_empty() {
+ tracing::warn!(
+ wallet_id = %hex::encode(wallet_id),
+ account_type = ?account_type,
+ unresolved_count = unresolved.len(),
+ "rehydration: UTXO address(es) unresolved for this account xpub \
+ — will re-warm on next sync; balance total is exact"
+ );
+ }
+ }
+
+ // No explicit aggregate-derivation cap is needed: a funds account exposes
+ // a fixed, small number of chains (Standard = 2, others = 1), each already
+ // capped at MAX_REHYDRATION_DERIVATION_INDEX, so total derivation is bounded
+ // by chains × MAX with no unbounded growth — an aggregate cap would either
+ // equal that natural bound (no-op) or clip a legitimate deep multi-chain
+ // wallet. The per-chain ceiling plus the deep-scan warn above are the
+ // proportionate guard against a corrupt/foreign-heavy UTXO set.
+
+ // Apply discovered depths and mark restored addresses used. `probes` is
+ // built directly from `address_pools()`, so it mirrors `address_pools_mut()`
+ // 1:1 and in chain order; verify that invariant before zipping by position.
+ let mut pools = account.managed_account_type_mut().address_pools_mut();
+ if pools.len() != probes.len() {
+ return Err(PlatformWalletError::RehydrationPoolMismatch {
+ expected: probes.len(),
+ found: pools.len(),
+ });
+ }
+ for (pool, (probe, matched)) in pools.iter_mut().zip(probes.iter()) {
+ // `iter_mut()` over `Vec<&mut AddressPool>` yields `&mut &mut _`;
+ // reborrow once so the pool flows into `ensure_derived` cleanly.
+ let pool: &mut AddressPool = pool;
+ debug_assert_eq!(
+ pool.pool_type, probe.pool_type,
+ "probe/pool chain order must match for by-position depth apply"
+ );
+
+ // Derive up to the deepest discovered index so its address exists in
+ // the real pool before we mark it used.
+ if let Some(&deepest) = matched.iter().next_back() {
+ if let Some(key_source) = key_source.as_ref() {
+ if ensure_derived(pool, key_source, deepest).is_none() {
+ tracing::warn!(
+ wallet_id = %hex::encode(wallet_id),
+ account_type = ?account_type,
+ pool_type = ?pool.pool_type,
+ index = deepest,
+ "rehydration: failed to derive resolved index into pool; \
+ deferring its address to the next sync"
+ );
+ }
+ }
+ }
+
+ // Mark every restored address this pool now holds as used — covers both
+ // deep-resolved addresses (just derived) and in-window addresses the
+ // discovery scan never visits. Without this an already-derived but
+ // funded address keeps `used = false` and could be handed out as a fresh
+ // receive address. `mark_used` is a no-op for addresses not in this
+ // pool, so an underived (foreign / sparse) index is never marked.
+ let mut marked_any = false;
+ for u in restored {
+ if pool.mark_used(&u.address) {
+ marked_any = true;
+ }
+ }
+
+ // Refill the gap window past the deepest used index (needs the xpub).
+ if marked_any {
+ if let Some(key_source) = key_source.as_ref() {
+ if let Err(e) = pool.maintain_gap_limit(key_source) {
+ tracing::warn!(
+ wallet_id = %hex::encode(wallet_id),
+ account_type = ?account_type,
+ pool_type = ?pool.pool_type,
+ error = %e,
+ "rehydration: gap-limit maintenance failed; pool window \
+ may be short until the next sync"
+ );
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Ensure `pool` has derived through `index` (generating only the missing
+/// tail), and return that index's address. `None` only on a derivation
+/// error.
+fn ensure_derived(
+ pool: &mut key_wallet::managed_account::address_pool::AddressPool,
+ key_source: &key_wallet::managed_account::address_pool::KeySource,
+ index: u32,
+) -> Option {
+ let needs_more = match pool.highest_generated {
+ Some(highest) => highest < index,
+ None => true,
+ };
+ if needs_more {
+ let start = pool.highest_generated.map(|h| h + 1).unwrap_or(0);
+ pool.generate_addresses(index - start + 1, key_source, true)
+ .ok()?;
+ }
+ pool.address_at_index(index)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use key_wallet::wallet::initialization::WalletAccountCreationOptions;
+
+ fn manifest_for(w: &Wallet) -> Vec {
+ w.accounts
+ .all_accounts()
+ .into_iter()
+ .map(|a| AccountRegistrationEntry {
+ account_type: a.account_type,
+ account_xpub: a.account_xpub,
+ })
+ .collect()
+ }
+
+ #[test]
+ fn watch_only_rebuild_round_trips_manifest_and_id() {
+ let seed = [3u8; 64];
+ let w = Wallet::from_seed_bytes(
+ seed,
+ Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let id = w.compute_wallet_id();
+ let manifest = manifest_for(&w);
+
+ let restored = build_watch_only_wallet(Network::Testnet, id, &manifest).unwrap();
+ assert_eq!(restored.wallet_id, id);
+ assert_eq!(restored.compute_wallet_id(), id);
+ // Every manifest account survives the round trip (count, types).
+ let restored_types: Vec<_> = restored
+ .accounts
+ .all_accounts()
+ .into_iter()
+ .map(|a| a.account_type)
+ .collect();
+ let manifest_types: Vec<_> = manifest.iter().map(|e| e.account_type).collect();
+ assert_eq!(restored_types.len(), manifest_types.len());
+ for t in &manifest_types {
+ assert!(restored_types.contains(t));
+ }
+ }
+
+ #[test]
+ fn empty_manifest_is_missing_manifest() {
+ let err = build_watch_only_wallet(Network::Testnet, [0u8; 32], &[])
+ .expect_err("empty manifest must be MissingManifest");
+ assert!(matches!(err, RehydrateRowError::MissingManifest));
+ }
+
+ /// Regression: after restart-in-place the watch-only pools eagerly
+ /// cover only `0..=gap_limit`, but persisted UTXOs can sit at deeper
+ /// derivation indices. Rehydration must extend each chain's pool to its
+ /// deepest restored index so the per-address view reconciles with the
+ /// wallet total instead of undercounting.
+ ///
+ /// Index layout (gap_limit = 30):
+ /// - external idx 3: within eager window (not in `unresolved`), balance included
+ /// - external idx 30: first index past eager window; anchors the initial scan
+ /// window and extends it to idx 60
+ /// - external idx 50: within extended window (50 < 60), resolved
+ /// - internal idx 30: within initial scan window, resolved
+ ///
+ /// Standard BIP44 topology (External + Internal pools) is exercised.
+ /// Asserts that maintain_gap_limit fills beyond the deepest resolved.
+ #[test]
+ fn rehydration_extends_pools_to_cover_deep_index_utxos() {
+ use dashcore::blockdata::transaction::txout::TxOut;
+ use dashcore::{OutPoint, Txid};
+ use key_wallet::bip32::DerivationPath;
+ use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT;
+ use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource};
+ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+ use key_wallet::{Address, Utxo};
+ use std::collections::HashSet;
+
+ let seed = [7u8; 64];
+ let wallet = Wallet::from_seed_bytes(
+ seed,
+ Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let manifest = manifest_for(&wallet);
+
+ // Mint the watch-only skeleton (pools cover only the eager gap
+ // window) and resolve the first funds account's keyless xpub.
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+ let funds_type = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .first()
+ .unwrap()
+ .managed_account_type()
+ .to_account_type();
+ let xpub = manifest
+ .iter()
+ .find(|e| e.account_type == funds_type)
+ .map(|e| e.account_xpub)
+ .expect("funds account xpub");
+
+ // Derive addresses on each chain from the same account xpub the
+ // pools use; `base_path` is record-keeping only and does not affect
+ // the derived address, so DerivationPath::master() is fine here.
+ let derive = |pool_type, index: u32| -> Address {
+ let mut p = AddressPool::new_without_generation(
+ DerivationPath::master(),
+ pool_type,
+ DEFAULT_EXTERNAL_GAP_LIMIT,
+ Network::Testnet,
+ );
+ p.generate_addresses(index + 1, &KeySource::Public(xpub), true)
+ .unwrap();
+ p.address_at_index(index).unwrap()
+ };
+
+ // idx 3: within eager window (0..=29) — covered by init, NOT in
+ // unresolved. Contributes to balance but needs no pool extension.
+ let shallow_recv = derive(AddressPoolType::External, 3);
+ // idx 30: first past eager window; falls in initial scan window
+ // (horizon = gap_limit = 30 on a chain with no prior matches).
+ // Anchors the external probe and extends horizon to 60.
+ let mid_recv = derive(AddressPoolType::External, 30);
+ // idx 50: within the extended window (50 < 30+30=60), resolved.
+ let deep_recv = derive(AddressPoolType::External, 50);
+ // idx 30: within the internal chain's initial scan window (<=30).
+ let deep_change = derive(AddressPoolType::Internal, 30);
+
+ let utxo = |addr: Address, value: u64, n: u8| Utxo {
+ outpoint: OutPoint {
+ txid: Txid::from([n; 32]),
+ vout: 0,
+ },
+ txout: TxOut {
+ value,
+ script_pubkey: addr.script_pubkey(),
+ },
+ address: addr,
+ height: 1,
+ is_coinbase: false,
+ is_confirmed: true,
+ is_instantlocked: false,
+ is_locked: false,
+ is_trusted: false,
+ };
+ let new_utxos = vec![
+ utxo(shallow_recv, 1_000, 1),
+ utxo(mid_recv.clone(), 10_000, 2),
+ utxo(deep_recv.clone(), 20_000, 3),
+ utxo(deep_change.clone(), 300_000, 4),
+ ];
+ let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum();
+ let core = crate::changeset::CoreChangeSet {
+ new_utxos,
+ last_processed_height: Some(1),
+ synced_height: Some(1),
+ ..Default::default()
+ };
+
+ apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap();
+
+ // The wallet total is exact regardless (a sum over the UTXO set).
+ assert_eq!(wallet_info.balance.total(), expected_total);
+
+ // The per-address view joins pool addresses to UTXOs; every
+ // resolved UTXO address must now be derived into a pool.
+ let funds = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .next()
+ .unwrap();
+ let pool_addresses: HashSet = funds
+ .managed_account_type()
+ .address_pools()
+ .iter()
+ .flat_map(|p| p.addresses.values().map(|i| i.address.clone()))
+ .collect();
+ let visible: u64 = funds
+ .utxos
+ .values()
+ .filter(|u| pool_addresses.contains(&u.address))
+ .map(|u| u.value())
+ .sum();
+ assert_eq!(
+ visible, expected_total,
+ "all UTXO addresses (including deep-index) must be derived into their pools"
+ );
+
+ // Each deep address resolves to its exact derivation slot.
+ let pools = funds.managed_account_type().address_pools();
+ let external = pools.iter().find(|p| p.is_external()).unwrap();
+ let internal = pools.iter().find(|p| p.is_internal()).unwrap();
+ assert_eq!(external.address_at_index(30).as_ref(), Some(&mid_recv));
+ assert_eq!(external.address_at_index(50).as_ref(), Some(&deep_recv));
+ assert_eq!(internal.address_at_index(30).as_ref(), Some(&deep_change));
+
+ // maintain_gap_limit must refill BEYOND the deepest restored
+ // index so the gap window is actually exercised, not just the restore.
+ // Deepest external resolved = idx 50; gap window must reach >= 50+30=80.
+ let expected_min_gen = 50 + DEFAULT_EXTERNAL_GAP_LIMIT;
+ assert!(
+ external.highest_generated >= Some(expected_min_gen),
+ "maintain_gap_limit must extend external pool to >= {} (got {:?})",
+ expected_min_gen,
+ external.highest_generated,
+ );
+ }
+
+ /// A UTXO whose address is not derivable from this account's
+ /// xpub (foreign key, multi-account mismatch) must not cause a panic or
+ /// hang. The total balance is exact (the UTXO is in the set regardless),
+ /// but the foreign address is absent from the pool so per-address
+ /// visibility is reduced. `tracing::warn!` fires for the unresolved count.
+ #[test]
+ fn rehydration_unresolvable_address_is_deferred_not_panics() {
+ use dashcore::blockdata::transaction::txout::TxOut;
+ use dashcore::{OutPoint, Txid};
+ use key_wallet::bip32::DerivationPath;
+ use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT;
+ use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource};
+ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+ use key_wallet::{Address, Utxo};
+ use std::collections::HashSet;
+
+ let seed = [13u8; 64];
+ let wallet = Wallet::from_seed_bytes(
+ seed,
+ Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let manifest = manifest_for(&wallet);
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+
+ let funds_type = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .first()
+ .unwrap()
+ .managed_account_type()
+ .to_account_type();
+ let xpub = manifest
+ .iter()
+ .find(|e| e.account_type == funds_type)
+ .map(|e| e.account_xpub)
+ .expect("funds account xpub");
+
+ // Normal UTXO at external index 3 (within eager window, pool-visible).
+ let normal_addr = {
+ let mut p = AddressPool::new_without_generation(
+ DerivationPath::master(),
+ AddressPoolType::External,
+ DEFAULT_EXTERNAL_GAP_LIMIT,
+ Network::Testnet,
+ );
+ p.generate_addresses(4, &KeySource::Public(xpub), true)
+ .unwrap();
+ p.address_at_index(3).unwrap()
+ };
+
+ // Foreign address: derive from a completely different wallet seed so
+ // it cannot be resolved from this wallet's xpub.
+ let foreign_addr = {
+ let fw = Wallet::from_seed_bytes(
+ [99u8; 64],
+ Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let fw_info = ManagedWalletInfo::from_wallet(&fw, 1);
+ fw_info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .next()
+ .unwrap()
+ .managed_account_type()
+ .address_pools()
+ .first()
+ .unwrap()
+ .address_at_index(0)
+ .unwrap()
+ };
+ assert_ne!(
+ normal_addr, foreign_addr,
+ "test fixture: foreign address must differ from normal"
+ );
+
+ let utxo = |addr: Address, value: u64, n: u8| Utxo {
+ outpoint: OutPoint {
+ txid: Txid::from([n; 32]),
+ vout: 0,
+ },
+ txout: TxOut {
+ value,
+ script_pubkey: addr.script_pubkey(),
+ },
+ address: addr,
+ height: 1,
+ is_coinbase: false,
+ is_confirmed: true,
+ is_instantlocked: false,
+ is_locked: false,
+ is_trusted: false,
+ };
+
+ let normal_val = 100_000u64;
+ let foreign_val = 200_000u64;
+ let expected_total = normal_val + foreign_val;
+
+ let core = crate::changeset::CoreChangeSet {
+ new_utxos: vec![
+ utxo(normal_addr, normal_val, 1),
+ utxo(foreign_addr, foreign_val, 2),
+ ],
+ last_processed_height: Some(1),
+ synced_height: Some(1),
+ ..Default::default()
+ };
+
+ // Must not panic. tracing::warn! fires for the unresolved count.
+ apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap();
+
+ // Total balance is exact — foreign UTXO is in the set regardless.
+ assert_eq!(
+ wallet_info.balance.total(),
+ expected_total,
+ "total must include foreign UTXO even though it is unresolved"
+ );
+
+ // Per-address visible: only the normal UTXO is in the pool.
+ let funds = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .next()
+ .unwrap();
+ let pool_addresses: HashSet = funds
+ .managed_account_type()
+ .address_pools()
+ .iter()
+ .flat_map(|p| p.addresses.values().map(|i| i.address.clone()))
+ .collect();
+ let visible: u64 = funds
+ .utxos
+ .values()
+ .filter(|u| pool_addresses.contains(&u.address))
+ .map(|u| u.value())
+ .sum();
+ assert_eq!(
+ visible, normal_val,
+ "only the non-foreign UTXO is pool-visible; foreign deferred to re-warm"
+ );
+ assert!(
+ visible < expected_total,
+ "foreign UTXO is deferred — per-address visible < total"
+ );
+ }
+
+ /// CoinJoin topology (single External pool, no Internal chain).
+ /// Verifies that `extend_pools_for_restored_utxos` handles a single-pool
+ /// account at a deep derivation index (idx 30, just past the eager window).
+ #[test]
+ fn rehydration_coinjoin_single_pool_deep_index() {
+ use dashcore::blockdata::transaction::txout::TxOut;
+ use dashcore::{OutPoint, Txid};
+ use key_wallet::managed_account::address_pool::{AddressPool, KeySource};
+ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+ use key_wallet::Utxo;
+ use std::collections::BTreeSet;
+
+ // CoinJoin-only wallet: no BIP44, one CoinJoin account at index 0.
+ let mut cj_set = BTreeSet::new();
+ cj_set.insert(0u32);
+ let opts = WalletAccountCreationOptions::SpecificAccounts(
+ BTreeSet::new(),
+ BTreeSet::new(),
+ cj_set,
+ BTreeSet::new(),
+ BTreeSet::new(),
+ None,
+ );
+ let seed = [11u8; 64];
+ let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, opts).unwrap();
+ assert!(
+ !wallet.accounts.coinjoin_accounts.is_empty(),
+ "fixture must have a CoinJoin account"
+ );
+
+ let manifest = manifest_for(&wallet);
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+
+ // Extract pool metadata before the mutable borrow of wallet_info.
+ let (funds_type, pool_base_path, pool_type_val, pool_gap_limit) = {
+ let funds = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .next()
+ .expect("CoinJoin account must be the only funds account");
+ let ft = funds.managed_account_type().to_account_type();
+ let pools = funds.managed_account_type().address_pools();
+ // CoinJoin has a single pool (External).
+ assert_eq!(
+ pools.len(),
+ 1,
+ "CoinJoin topology: must have exactly one pool"
+ );
+ let p = &pools[0];
+ (ft, p.base_path.clone(), p.pool_type, p.gap_limit)
+ };
+
+ let xpub = manifest
+ .iter()
+ .find(|e| e.account_type == funds_type)
+ .map(|e| e.account_xpub)
+ .expect("CoinJoin xpub must be in manifest");
+
+ // Derive the CoinJoin address at index 30 (first past the eager
+ // window 0..=29) using the real pool's base_path and pool_type.
+ let mut probe = AddressPool::new_without_generation(
+ pool_base_path,
+ pool_type_val,
+ pool_gap_limit,
+ Network::Testnet,
+ );
+ probe
+ .generate_addresses(31, &KeySource::Public(xpub), true)
+ .unwrap();
+ let deep_cj_addr = probe.address_at_index(30).unwrap();
+
+ let utxo_val = 7_777u64;
+ let utxo = Utxo {
+ outpoint: OutPoint {
+ txid: Txid::from([7u8; 32]),
+ vout: 0,
+ },
+ txout: TxOut {
+ value: utxo_val,
+ script_pubkey: deep_cj_addr.script_pubkey(),
+ },
+ address: deep_cj_addr.clone(),
+ height: 1,
+ is_coinbase: false,
+ is_confirmed: true,
+ is_instantlocked: false,
+ is_locked: false,
+ is_trusted: false,
+ };
+
+ let core = crate::changeset::CoreChangeSet {
+ new_utxos: vec![utxo],
+ last_processed_height: Some(1),
+ synced_height: Some(1),
+ ..Default::default()
+ };
+
+ apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap();
+
+ // Balance is exact.
+ assert_eq!(
+ wallet_info.balance.total(),
+ utxo_val,
+ "CoinJoin deep-index balance must be exact"
+ );
+
+ // The CoinJoin pool was extended to include the deep-index address.
+ let funds_post = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .next()
+ .unwrap();
+ let cj_pool = &funds_post.managed_account_type().address_pools()[0];
+ assert_eq!(
+ cj_pool.address_at_index(30).as_ref(),
+ Some(&deep_cj_addr),
+ "CoinJoin pool must be extended to cover deep-index address at idx 30"
+ );
+ }
+
+ /// In-window restored UTXO: an address already covered by the eager
+ /// derivation (idx 3, inside `0..=gap_limit-1`) must still be marked
+ /// `used` during rehydration. The discovery scan never visits in-window
+ /// addresses, so without an explicit mark pass a funded address would keep
+ /// `used = false` and could later be handed out as a fresh receive address.
+ #[test]
+ fn rehydration_marks_in_window_restored_address_used() {
+ use dashcore::blockdata::transaction::txout::TxOut;
+ use dashcore::{OutPoint, Txid};
+ use key_wallet::bip32::DerivationPath;
+ use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT;
+ use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource};
+ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+ use key_wallet::{Address, Utxo};
+
+ let wallet = Wallet::from_seed_bytes(
+ [5u8; 64],
+ Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let manifest = manifest_for(&wallet);
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+
+ let funds_type = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .first()
+ .unwrap()
+ .managed_account_type()
+ .to_account_type();
+ let xpub = manifest
+ .iter()
+ .find(|e| e.account_type == funds_type)
+ .map(|e| e.account_xpub)
+ .expect("funds account xpub");
+
+ // External idx 3 — inside the eager window, so NOT in the discovery set.
+ let in_window: Address = {
+ let mut p = AddressPool::new_without_generation(
+ DerivationPath::master(),
+ AddressPoolType::External,
+ DEFAULT_EXTERNAL_GAP_LIMIT,
+ Network::Testnet,
+ );
+ p.generate_addresses(4, &KeySource::Public(xpub), true)
+ .unwrap();
+ p.address_at_index(3).unwrap()
+ };
+
+ let core = crate::changeset::CoreChangeSet {
+ new_utxos: vec![Utxo {
+ outpoint: OutPoint {
+ txid: Txid::from([1u8; 32]),
+ vout: 0,
+ },
+ txout: TxOut {
+ value: 12_345,
+ script_pubkey: in_window.script_pubkey(),
+ },
+ address: in_window.clone(),
+ height: 1,
+ is_coinbase: false,
+ is_confirmed: true,
+ is_instantlocked: false,
+ is_locked: false,
+ is_trusted: false,
+ }],
+ last_processed_height: Some(1),
+ synced_height: Some(1),
+ ..Default::default()
+ };
+
+ apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap();
+
+ let funds = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .next()
+ .unwrap();
+ let pools = funds.managed_account_type().address_pools();
+ let external = pools.iter().find(|p| p.is_external()).unwrap();
+ let info = external
+ .address_info(&in_window)
+ .expect("in-window address must be present in the pool");
+ assert!(
+ info.used,
+ "in-window restored UTXO address must be marked used"
+ );
+ assert!(
+ external.used_indices.contains(&3),
+ "used_indices must record the in-window slot"
+ );
+ assert_eq!(
+ external.highest_used,
+ Some(3),
+ "highest_used must reflect the in-window slot"
+ );
+ }
+
+ /// Documented limitation (solution b): a legitimately-owned but
+ /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx
+ /// <= 30 — is left unresolved because the discovery horizon (gap_limit
+ /// past the deepest match) never advances far enough to reach it. The
+ /// wallet total stays exact; only the per-address view is incomplete
+ /// until the next sync (a `tracing::warn!` records the deferral).
+ #[test]
+ fn rehydration_deep_sparse_utxo_left_unresolved_total_exact() {
+ use dashcore::blockdata::transaction::txout::TxOut;
+ use dashcore::{OutPoint, Txid};
+ use key_wallet::bip32::DerivationPath;
+ use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT;
+ use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource};
+ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+ use key_wallet::{Address, Utxo};
+ use std::collections::HashSet;
+
+ let wallet = Wallet::from_seed_bytes(
+ [21u8; 64],
+ Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let manifest = manifest_for(&wallet);
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+
+ let funds_type = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .first()
+ .unwrap()
+ .managed_account_type()
+ .to_account_type();
+ let xpub = manifest
+ .iter()
+ .find(|e| e.account_type == funds_type)
+ .map(|e| e.account_xpub)
+ .expect("funds account xpub");
+
+ // External idx 45 — past the eager window AND past the initial scan
+ // window (horizon = gap_limit = 30 with no nearer match to extend it).
+ let sparse_deep: Address = {
+ let mut p = AddressPool::new_without_generation(
+ DerivationPath::master(),
+ AddressPoolType::External,
+ DEFAULT_EXTERNAL_GAP_LIMIT,
+ Network::Testnet,
+ );
+ p.generate_addresses(46, &KeySource::Public(xpub), true)
+ .unwrap();
+ p.address_at_index(45).unwrap()
+ };
+
+ let value = 500_000u64;
+ let core = crate::changeset::CoreChangeSet {
+ new_utxos: vec![Utxo {
+ outpoint: OutPoint {
+ txid: Txid::from([4u8; 32]),
+ vout: 0,
+ },
+ txout: TxOut {
+ value,
+ script_pubkey: sparse_deep.script_pubkey(),
+ },
+ address: sparse_deep.clone(),
+ height: 1,
+ is_coinbase: false,
+ is_confirmed: true,
+ is_instantlocked: false,
+ is_locked: false,
+ is_trusted: false,
+ }],
+ last_processed_height: Some(1),
+ synced_height: Some(1),
+ ..Default::default()
+ };
+
+ apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap();
+
+ // The wallet total is exact regardless (a sum over the UTXO set).
+ assert_eq!(wallet_info.balance.total(), value);
+
+ let funds = wallet_info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .next()
+ .unwrap();
+ let pools = funds.managed_account_type().address_pools();
+ let external = pools.iter().find(|p| p.is_external()).unwrap();
+ assert!(
+ !external.contains_address(&sparse_deep),
+ "deep-sparse idx 45 must be left unresolved (absent from the pool)"
+ );
+
+ // Per-address view: the deep-sparse UTXO is not pool-visible yet.
+ let pool_addresses: HashSet = pools
+ .iter()
+ .flat_map(|p| p.addresses.values().map(|i| i.address.clone()))
+ .collect();
+ let visible: u64 = funds
+ .utxos
+ .values()
+ .filter(|u| pool_addresses.contains(&u.address))
+ .map(|u| u.value())
+ .sum();
+ assert_eq!(
+ visible, 0,
+ "the deep-sparse UTXO is deferred — not pool-visible until next sync"
+ );
+ assert!(visible < value, "per-address visible < exact total");
+ }
+
+ /// Topology guard: a wallet with persisted UTXOs but NO funds-bearing
+ /// account cannot hold them — fail closed with
+ /// `RehydrationTopologyUnsupported` (reporting the persisted count) rather
+ /// than reconstruct a silent zero balance.
+ #[test]
+ fn rehydration_utxos_without_funds_account_errors() {
+ use dashcore::address::Payload;
+ use dashcore::blockdata::transaction::txout::TxOut;
+ use dashcore::hashes::Hash;
+ use dashcore::{OutPoint, PubkeyHash, Txid};
+ use key_wallet::account::AccountType;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+ use key_wallet::{Address, Utxo};
+ use std::collections::BTreeSet;
+
+ // Keys-only wallet: a single IdentityRegistration account, no funds.
+ let opts = WalletAccountCreationOptions::SpecificAccounts(
+ BTreeSet::new(),
+ BTreeSet::new(),
+ BTreeSet::new(),
+ BTreeSet::new(),
+ BTreeSet::new(),
+ Some(vec![AccountType::IdentityRegistration]),
+ );
+ let wallet = Wallet::from_seed_bytes([23u8; 64], Network::Testnet, opts).unwrap();
+ let manifest = manifest_for(&wallet);
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+ assert!(
+ wallet_info.accounts.all_funding_accounts().is_empty(),
+ "fixture must have NO funds-bearing account"
+ );
+
+ let addr = Address::new(
+ Network::Testnet,
+ Payload::PubkeyHash(PubkeyHash::from_byte_array([9u8; 20])),
+ );
+ let core = crate::changeset::CoreChangeSet {
+ new_utxos: vec![Utxo {
+ outpoint: OutPoint {
+ txid: Txid::from([2u8; 32]),
+ vout: 0,
+ },
+ txout: TxOut {
+ value: 800_000,
+ script_pubkey: addr.script_pubkey(),
+ },
+ address: addr,
+ height: 1,
+ is_coinbase: false,
+ is_confirmed: true,
+ is_instantlocked: false,
+ is_locked: false,
+ is_trusted: false,
+ }],
+ last_processed_height: Some(1),
+ synced_height: Some(1),
+ ..Default::default()
+ };
+
+ let err = apply_persisted_core_state(&mut wallet_info, &manifest, &core)
+ .expect_err("must fail closed when no funds account can hold the UTXOs");
+ match err {
+ PlatformWalletError::RehydrationTopologyUnsupported { utxo_count, .. } => {
+ assert_eq!(utxo_count, 1, "utxo_count must match the persisted set");
+ }
+ other => panic!("expected RehydrationTopologyUnsupported, got {other:?}"),
+ }
+ }
+
+ /// Companion to the topology guard: the same keys-only wallet with an
+ /// EMPTY persisted UTXO set is `Ok` — there is nothing to hold, so the
+ /// guard does not trip.
+ #[test]
+ fn rehydration_no_funds_account_empty_utxos_ok() {
+ use key_wallet::account::AccountType;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+ use std::collections::BTreeSet;
+
+ let opts = WalletAccountCreationOptions::SpecificAccounts(
+ BTreeSet::new(),
+ BTreeSet::new(),
+ BTreeSet::new(),
+ BTreeSet::new(),
+ BTreeSet::new(),
+ Some(vec![AccountType::IdentityRegistration]),
+ );
+ let wallet = Wallet::from_seed_bytes([24u8; 64], Network::Testnet, opts).unwrap();
+ let manifest = manifest_for(&wallet);
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+ assert!(wallet_info.accounts.all_funding_accounts().is_empty());
+
+ let core = crate::changeset::CoreChangeSet {
+ last_processed_height: Some(1),
+ synced_height: Some(1),
+ ..Default::default()
+ };
+ apply_persisted_core_state(&mut wallet_info, &manifest, &core)
+ .expect("empty UTXO set must be Ok even with no funds account");
+ }
+
+ /// Regression: a `last_applied_chain_lock` carried in the persisted
+ /// `CoreChangeSet` must be restored onto the rehydrated wallet
+ /// metadata. Without it, the asset-lock-resume CL-from-metadata
+ /// fallback (`proof.rs`) cannot fire at app launch and a pre-restart
+ /// chain-locked asset lock can't produce a proof until SPV re-applies
+ /// a fresh chainlock. Fails (`None != Some`) if the apply step drops it.
+ #[test]
+ fn rehydration_restores_last_applied_chain_lock() {
+ use dashcore::ephemerealdata::chain_lock::ChainLock;
+ use dashcore::hashes::Hash;
+ use dashcore::BlockHash;
+ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
+
+ let wallet = Wallet::from_seed_bytes(
+ [5u8; 64],
+ Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let manifest = manifest_for(&wallet);
+ let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1);
+ assert!(
+ wallet_info.metadata.last_applied_chain_lock.is_none(),
+ "fresh watch-only skeleton starts with no chain lock"
+ );
+
+ let cl = ChainLock {
+ block_height: 123_456,
+ block_hash: BlockHash::from_byte_array([7u8; 32]),
+ signature: [9u8; 96].into(),
+ };
+ let core = crate::changeset::CoreChangeSet {
+ last_applied_chain_lock: Some(cl.clone()),
+ ..Default::default()
+ };
+
+ apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap();
+
+ assert_eq!(
+ wallet_info.metadata.last_applied_chain_lock.as_ref(),
+ Some(&cl),
+ "persisted last_applied_chain_lock must be restored onto wallet metadata"
+ );
+ }
+}
diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs
index 482674b4322..b38812bfc50 100644
--- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs
+++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs
@@ -141,14 +141,6 @@ pub struct ShieldedSyncManager {
coordinator_slot: Arc>>>,
/// Cancel token for the background loop, if running.
background_cancel: StdMutex>,
- /// Monotonically increasing generation counter. Bumped on every
- /// `start()` so the exiting thread can tell whether its
- /// generation is still the active one before clearing
- /// `background_cancel`. Without this, a `stop()` → `start()`
- /// overlap lets the prior thread's cleanup strip the new
- /// generation's token, leaving the new loop running but
- /// untrackable via `is_running()`.
- background_generation: AtomicU64,
interval_secs: AtomicU64,
is_syncing: AtomicBool,
/// Set by [`quiesce`](Self::quiesce) to gate new passes while it
@@ -171,7 +163,6 @@ impl ShieldedSyncManager {
event_manager,
coordinator_slot,
background_cancel: StdMutex::new(None),
- background_generation: AtomicU64::new(0),
interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS),
is_syncing: AtomicBool::new(false),
quiescing: AtomicBool::new(false),
@@ -228,10 +219,6 @@ impl ShieldedSyncManager {
}
let cancel = CancellationToken::new();
*guard = Some(cancel.clone());
- // Bump the generation while we still hold the slot lock so
- // the load below in any prior thread's cleanup observes
- // `current_gen != my_gen` ordered against this token swap.
- let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1;
drop(guard);
let handle = tokio::runtime::Handle::current();
@@ -261,16 +248,8 @@ impl ShieldedSyncManager {
}
}
- // Only clear `background_cancel` if the active
- // generation is still ours. Without this guard a
- // tight `stop()` → `start()` reschedule has the
- // exiting thread overwrite the *new* generation's
- // token, leaving the new loop running but
- // unreflectable via `is_running()` / `stop()`.
- if this.background_generation.load(Ordering::Acquire) == my_gen {
- if let Ok(mut guard) = this.background_cancel.lock() {
- *guard = None;
- }
+ if let Ok(mut guard) = this.background_cancel.lock() {
+ *guard = None;
}
});
})
diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs
index 8c690543ee0..099b77f97fd 100644
--- a/packages/rs-platform-wallet/src/wallet/apply.rs
+++ b/packages/rs-platform-wallet/src/wallet/apply.rs
@@ -148,93 +148,17 @@ impl PlatformWalletInfo {
}
}
- // 2b. Identity keys. Runs after the scalar identity pass so
- // the owning ManagedIdentity is guaranteed to exist before
- // we layer keys into it. Upserts land first, then removals,
- // matching the discipline used across the rest of this
- // function. Orphan entries (owner not in the wallet) are
- // logged and skipped by the per-entry apply helpers.
- if let Some(keys_cs) = identity_keys {
- let crate::changeset::IdentityKeysChangeSet { upserts, removed } = keys_cs;
- // Thread the wallet network through so the key-apply
- // path can reproduce DIP-9 derivation paths for any
- // entry that carries `(wallet_id, derivation_indices)`.
- let network = wallet.network;
- for (_key, entry) in upserts {
- self.identity_manager
- .apply_identity_key_entry(entry, network);
- }
- for (identity_id, key_id) in removed {
- self.identity_manager
- .apply_identity_key_removal(&identity_id, key_id);
- }
- }
-
- // 3. Contacts. Each entry routes to its owning ManagedIdentity by
- // `(owner, contact)` key; orphans (owner not in the wallet)
- // are logged and skipped. Trivial map ops (sent / incoming
- // insert and remove) are inlined here — no helper earns its
- // name for a single `insert` / `shift_remove` call. Only
- // `apply_established_contact` is a method because it has
- // real logic (drops both pending sides per the contract).
- if let Some(contact_cs) = contacts {
- let crate::changeset::ContactChangeSet {
- sent_requests,
- removed_sent,
- incoming_requests,
- removed_incoming,
- established,
- } = contact_cs;
-
- for (key, entry) in sent_requests {
- match self.identity_manager.managed_identity_mut(&key.owner_id) {
- Some(managed) => {
- managed
- .sent_contact_requests
- .insert(entry.request.recipient_id, entry.request);
- }
- None => tracing::warn!(
- owner = %key.owner_id,
- "skipping sent contact request during apply: owner identity not in wallet"
- ),
- }
- }
- for (key, entry) in incoming_requests {
- match self.identity_manager.managed_identity_mut(&key.owner_id) {
- Some(managed) => {
- managed
- .incoming_contact_requests
- .insert(entry.request.sender_id, entry.request);
- }
- None => tracing::warn!(
- owner = %key.owner_id,
- "skipping incoming contact request during apply: owner identity not in wallet"
- ),
- }
- }
- for key in removed_sent {
- if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) {
- managed.sent_contact_requests.remove(&key.recipient_id);
- }
- }
- for key in removed_incoming {
- if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) {
- managed.incoming_contact_requests.remove(&key.sender_id);
- }
- }
- // Established promotions — drop any matching pending
- // entries on both sides per the auto-establishment contract.
- for (key, established) in established {
- match self.identity_manager.managed_identity_mut(&key.owner_id) {
- Some(managed) => {
- managed.apply_established_contact(established);
- }
- None => tracing::warn!(
- owner = %key.owner_id,
- "skipping established contact during apply: owner identity not in wallet"
- ),
- }
- }
+ // 2b/3. Identity keys + contacts. Keys are layered before
+ // contacts so a contact entry never lands before its
+ // owner's keys; orphans are logged and skipped. Single
+ // source of truth shared with the persister rehydration
+ // path (`load_from_persistor`).
+ if identity_keys.is_some() || contacts.is_some() {
+ self.identity_manager.apply_contacts_and_keys(
+ contacts.unwrap_or_default(),
+ identity_keys.unwrap_or_default(),
+ wallet.network,
+ );
}
// 3b. DashPay profile/payment overlays. Applied AFTER identities
diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs
index 7d04f29c538..09dd930c0cc 100644
--- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs
+++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs
@@ -15,7 +15,7 @@
//! [`IdentityManager::apply_identity_key_entry`].
use super::{IdentityLocation, IdentityManager};
-use crate::changeset::{IdentityEntry, IdentityKeyEntry};
+use crate::changeset::{ContactChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet};
use crate::wallet::identity::state::managed_identity::ManagedIdentity;
use dpp::identity::accessors::IdentityGettersV0;
use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0;
@@ -181,4 +181,85 @@ impl IdentityManager {
managed.identity.public_keys_mut().remove(&key_id);
}
}
+
+ /// Layer a [`ContactChangeSet`] + [`IdentityKeysChangeSet`] onto the
+ /// already-restored managed identities.
+ ///
+ /// Single source of truth for the contact / identity-key routing —
+ /// shared by the runtime changeset-replay path
+ /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset))
+ /// and the persister rehydration path
+ /// ([`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)).
+ /// Identity keys are applied first so a contact entry never lands
+ /// before its owner's keys; orphan entries (owner not in the
+ /// wallet) are logged and skipped, never fatal. `removed_*` are
+ /// honoured for the replay path; the rehydration feed leaves them
+ /// empty.
+ pub(crate) fn apply_contacts_and_keys(
+ &mut self,
+ contacts: ContactChangeSet,
+ identity_keys: IdentityKeysChangeSet,
+ network: Network,
+ ) {
+ let IdentityKeysChangeSet { upserts, removed } = identity_keys;
+ for (_key, entry) in upserts {
+ self.apply_identity_key_entry(entry, network);
+ }
+ for (identity_id, key_id) in removed {
+ self.apply_identity_key_removal(&identity_id, key_id);
+ }
+
+ let ContactChangeSet {
+ sent_requests,
+ removed_sent,
+ incoming_requests,
+ removed_incoming,
+ established,
+ } = contacts;
+ for (key, entry) in sent_requests {
+ match self.managed_identity_mut(&key.owner_id) {
+ Some(managed) => {
+ managed
+ .sent_contact_requests
+ .insert(entry.request.recipient_id, entry.request);
+ }
+ None => tracing::warn!(
+ owner = %key.owner_id,
+ "skipping sent contact request: owner identity not in wallet"
+ ),
+ }
+ }
+ for (key, entry) in incoming_requests {
+ match self.managed_identity_mut(&key.owner_id) {
+ Some(managed) => {
+ managed
+ .incoming_contact_requests
+ .insert(entry.request.sender_id, entry.request);
+ }
+ None => tracing::warn!(
+ owner = %key.owner_id,
+ "skipping incoming contact request: owner identity not in wallet"
+ ),
+ }
+ }
+ for key in removed_sent {
+ if let Some(managed) = self.managed_identity_mut(&key.owner_id) {
+ managed.sent_contact_requests.remove(&key.recipient_id);
+ }
+ }
+ for key in removed_incoming {
+ if let Some(managed) = self.managed_identity_mut(&key.owner_id) {
+ managed.incoming_contact_requests.remove(&key.sender_id);
+ }
+ }
+ for (key, established) in established {
+ match self.managed_identity_mut(&key.owner_id) {
+ Some(managed) => managed.apply_established_contact(established),
+ None => tracing::warn!(
+ owner = %key.owner_id,
+ "skipping established contact: owner identity not in wallet"
+ ),
+ }
+ }
+ }
}
diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs
new file mode 100644
index 00000000000..292b6bf6f29
--- /dev/null
+++ b/packages/rs-platform-wallet/tests/rehydration_load.rs
@@ -0,0 +1,277 @@
+//! Item E — `load_from_persistor` (seedless / watch-only) end-to-end
+//! through a real `PlatformWalletManager`.
+//!
+//! Scope after the seedless rework: load reconstructs every persisted
+//! wallet **watch-only** from its keyless account manifest. The load
+//! path never touches the seed, so it performs no wrong-seed check; a
+//! sign-time gate is deferred separate FFI work and is not part of this
+//! path. Per-row decode failures surface as
+//! [`SkipReason::CorruptPersistedRow`] without aborting the batch.
+//!
+//! RT cases here:
+//! - RT-WO: round-trip — watch-only wallet is registered after reload.
+//! - RT-Corrupt: a row with an empty manifest is skipped with
+//! `MissingManifest`, the other row loads, a `WalletSkippedOnLoad`
+//! event fires, `load` returns `Ok`.
+//! - RT-Z: no key/seed material in any `LoadOutcome` / `SkipReason`
+//! surface (the structural-only contract).
+
+use std::sync::{Arc, Mutex};
+
+use key_wallet::wallet::initialization::WalletAccountCreationOptions;
+use key_wallet::wallet::Wallet;
+use platform_wallet::changeset::{
+ AccountRegistrationEntry, ClientStartState, ClientWalletStartState, CoreChangeSet,
+ PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence,
+};
+use platform_wallet::events::{EventHandler, PlatformEventHandler};
+use platform_wallet::manager::load_outcome::CorruptKind;
+use platform_wallet::wallet::platform_wallet::WalletId;
+use platform_wallet::{PlatformWalletManager, SkipReason};
+
+// ---- test doubles ----
+
+/// Persister whose `load()` returns a fixed keyless `ClientStartState`.
+struct FixedLoadPersister {
+ state: Mutex >,
+}
+
+impl FixedLoadPersister {
+ fn new() -> Self {
+ Self {
+ state: Mutex::new(None),
+ }
+ }
+ fn set(&self, s: ClientStartState) {
+ *self.state.lock().unwrap() = Some(s);
+ }
+}
+
+impl PlatformWalletPersistence for FixedLoadPersister {
+ fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> {
+ Ok(())
+ }
+ fn flush(&self, _: WalletId) -> Result<(), PersistenceError> {
+ Ok(())
+ }
+ fn load(&self) -> Result {
+ // Rebuild a fresh ClientStartState each call (load may be
+ // called twice for the recoverability sub-case).
+ let guard = self.state.lock().unwrap();
+ match guard.as_ref() {
+ None => Ok(ClientStartState::default()),
+ Some(s) => {
+ let mut out = ClientStartState::default();
+ for (id, w) in &s.wallets {
+ out.wallets.insert(
+ *id,
+ ClientWalletStartState {
+ network: w.network,
+ birth_height: w.birth_height,
+ account_manifest: w.account_manifest.clone(),
+ core_state: w.core_state.clone(),
+ identity_manager: Default::default(),
+ unused_asset_locks: Default::default(),
+ contacts: Default::default(),
+ identity_keys: Default::default(),
+ },
+ );
+ }
+ Ok(out)
+ }
+ }
+ }
+}
+
+/// Event handler that records every wallet-skipped-on-load notification.
+#[derive(Default)]
+struct RecordingHandler {
+ skipped: Mutex>,
+}
+impl EventHandler for RecordingHandler {}
+impl PlatformEventHandler for RecordingHandler {
+ fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) {
+ self.skipped
+ .lock()
+ .unwrap()
+ .push((wallet_id, reason.clone()));
+ }
+}
+
+// ---- harness ----
+
+fn manifest_and_id(seed: [u8; 64]) -> (Vec, [u8; 32]) {
+ let w = Wallet::from_seed_bytes(
+ seed,
+ key_wallet::Network::Testnet,
+ WalletAccountCreationOptions::Default,
+ )
+ .unwrap();
+ let manifest = w
+ .accounts
+ .all_accounts()
+ .into_iter()
+ .map(|a| AccountRegistrationEntry {
+ account_type: a.account_type,
+ account_xpub: a.account_xpub,
+ })
+ .collect();
+ (manifest, w.compute_wallet_id())
+}
+
+fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) {
+ let (manifest, id) = manifest_and_id(seed);
+ (
+ id,
+ ClientWalletStartState {
+ network: key_wallet::Network::Testnet,
+ birth_height: 1,
+ account_manifest: manifest,
+ core_state: CoreChangeSet::default(),
+ identity_manager: Default::default(),
+ unused_asset_locks: Default::default(),
+ contacts: Default::default(),
+ identity_keys: Default::default(),
+ },
+ )
+}
+
+async fn manager(
+ persister: Arc,
+ handler: Arc,
+) -> Arc> {
+ let sdk = Arc::new(dash_sdk::Sdk::new_mock());
+ Arc::new(PlatformWalletManager::new(sdk, persister, handler))
+}
+
+// ---- tests ----
+
+/// RT-WO: seedless watch-only round-trip — a persisted wallet loads and
+/// is registered after reload (no signing material needed).
+#[tokio::test]
+async fn rt_wo_watch_only_roundtrip() {
+ let seed = [0x11; 64];
+ let p = Arc::new(FixedLoadPersister::new());
+ let h = Arc::new(RecordingHandler::default());
+ let (id, s) = slice(seed);
+ let mut st = ClientStartState::default();
+ st.wallets.insert(id, s);
+ p.set(st);
+
+ let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await;
+ let outcome = mgr.load_from_persistor().await.expect("Ok");
+
+ assert_eq!(outcome.loaded, vec![id]);
+ assert!(outcome.skipped.is_empty());
+ assert!(
+ mgr.get_wallet(&id).await.is_some(),
+ "watch-only restored wallet must be registered"
+ );
+ assert_eq!(mgr.wallet_ids().await, vec![id]);
+}
+
+/// RT-Corrupt: a corrupt row (empty manifest) is skipped with
+/// `MissingManifest`; the other row loads cleanly; the load returns
+/// `Ok`; exactly one `WalletSkippedOnLoad` event fires for the skipped
+/// row.
+#[tokio::test]
+async fn rt_corrupt_row_skipped_and_other_loads() {
+ let seed_a = [0x31; 64];
+ let seed_b = [0x32; 64];
+ let p = Arc::new(FixedLoadPersister::new());
+ let h = Arc::new(RecordingHandler::default());
+ let (id_a, sa) = slice(seed_a);
+ let (id_b, _sb) = slice(seed_b);
+
+ // B's row is structurally corrupt — empty manifest.
+ let sb_corrupt = ClientWalletStartState {
+ network: key_wallet::Network::Testnet,
+ birth_height: 1,
+ account_manifest: Vec::new(),
+ core_state: CoreChangeSet::default(),
+ identity_manager: Default::default(),
+ unused_asset_locks: Default::default(),
+ contacts: Default::default(),
+ identity_keys: Default::default(),
+ };
+
+ let mut st = ClientStartState::default();
+ st.wallets.insert(id_a, sa);
+ st.wallets.insert(id_b, sb_corrupt);
+ p.set(st);
+
+ let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await;
+ let outcome = mgr
+ .load_from_persistor()
+ .await
+ .expect("Ok despite per-row skip");
+
+ assert!(outcome.loaded.contains(&id_a), "A loads fully");
+ assert!(!outcome.loaded.contains(&id_b), "B is skipped, not loaded");
+ assert_eq!(outcome.skipped.len(), 1);
+ let (skipped_id, skipped_reason) = &outcome.skipped[0];
+ assert_eq!(*skipped_id, id_b);
+ assert!(matches!(
+ skipped_reason,
+ SkipReason::CorruptPersistedRow {
+ kind: CorruptKind::MissingManifest
+ }
+ ));
+ assert!(mgr.get_wallet(&id_a).await.is_some());
+ assert!(
+ mgr.get_wallet(&id_b).await.is_none(),
+ "corrupt row must be ABSENT, not a degraded placeholder"
+ );
+
+ // Exactly one on_wallet_skipped_on_load notification for B.
+ {
+ let skipped = h.skipped.lock().unwrap();
+ assert_eq!(skipped.len(), 1, "exactly one skip notification expected");
+ let (skipped_wallet_id, skipped_reason) = &skipped[0];
+ assert_eq!(*skipped_wallet_id, id_b);
+ assert!(matches!(
+ skipped_reason,
+ SkipReason::CorruptPersistedRow {
+ kind: CorruptKind::MissingManifest
+ }
+ ));
+ }
+}
+
+/// RT-Z: no key/seed material leaks into `LoadOutcome` /
+/// `SkipReason::CorruptPersistedRow` surfaces. The seedless load path
+/// never sees seed bytes so this is mostly a sentinel guard against
+/// future regression where someone embeds row contents in `DecodeError`.
+#[tokio::test]
+async fn rt_z_secret_hygiene_surfaces() {
+ let seed = [0xAB; 64];
+ let p = Arc::new(FixedLoadPersister::new());
+ let h = Arc::new(RecordingHandler::default());
+ let (id, _s) = slice(seed);
+
+ // Corrupt row to force a skip and inspect every public surface.
+ let corrupt = ClientWalletStartState {
+ network: key_wallet::Network::Testnet,
+ birth_height: 1,
+ account_manifest: Vec::new(),
+ core_state: CoreChangeSet::default(),
+ identity_manager: Default::default(),
+ unused_asset_locks: Default::default(),
+ contacts: Default::default(),
+ identity_keys: Default::default(),
+ };
+ let mut st = ClientStartState::default();
+ st.wallets.insert(id, corrupt);
+ p.set(st);
+
+ let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await;
+ let outcome = mgr.load_from_persistor().await.expect("Ok");
+ let dbg = format!("{outcome:?}");
+ // 0xAB seed bytes must not appear hex-rendered anywhere.
+ assert!(!dbg.to_lowercase().contains(&"ab".repeat(10)));
+ // The structural skip reason renders without any row bytes.
+ for (_, reason) in &outcome.skipped {
+ let rendered = format!("{reason} {reason:?}");
+ assert!(!rendered.to_lowercase().contains(&"ab".repeat(10)));
+ }
+}
diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
index 0e433d368ef..2ead77cdbdb 100644
--- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
+++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
@@ -323,15 +323,17 @@ public class PlatformWalletManager: ObservableObject {
///
/// Calls `platform_wallet_manager_load_from_persistor` which fires
/// the Swift-side `on_load_wallet_list_fn` callback. For each
- /// persisted wallet, Rust reconstructs a **watch-only** `Wallet`
- /// plus the wallet's persisted platform-address sync snapshot.
- /// After the FFI returns, we call `platform_wallet_manager_get_wallet`
- /// for each restored id so Swift gets a `ManagedPlatformWallet`
- /// handle.
+ /// persisted wallet, Rust rebuilds a **watch-only** `Wallet` from
+ /// its keyless account manifest (`Wallet::new_watch_only`) and
+ /// applies the persisted platform-address sync snapshot. After the
+ /// FFI returns we call `platform_wallet_manager_get_wallet` for
+ /// each restored id so Swift gets a `ManagedPlatformWallet` handle.
///
- /// Signing operations will fail until a future unlock flow
- /// upgrades a watch-only wallet to a signing wallet via the
- /// mnemonic stored in Keychain.
+ /// Signing happens on demand via the configured
+ /// `MnemonicResolverHandle`: each resolver-fed sign entrypoint
+ /// fail-closed gates the resolved seed against the loaded
+ /// `wallet_id` and surfaces a structural wrong-seed error on
+ /// mismatch (no keys cross that surface).
///
/// Idempotent: if there's no persisted state, does nothing and
/// leaves `self.wallets` untouched. Safe to call before any
@@ -340,7 +342,12 @@ public class PlatformWalletManager: ObservableObject {
public func loadFromPersistor() throws -> [ManagedPlatformWallet] {
try ensureConfigured()
- try platform_wallet_manager_load_from_persistor(handle).check()
+ // Pass nil for `out_outcome` — Swift doesn't currently consume
+ // the per-wallet skip summary (corrupt persisted rows are
+ // logged by Rust at warn level). When Swift starts surfacing
+ // skipped wallets to the UI, pass a `LoadOutcomeFFI` here and
+ // free it with `platform_wallet_load_outcome_free`.
+ try platform_wallet_manager_load_from_persistor(handle, nil).check()
// Ask SwiftData for the list of wallet ids we just told Rust
// to load. We reuse the same container rather than shipping a
From 2f2a74acdfcd16f35f172396cf838f576ee2dbc6 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 29 Jun 2026 12:55:19 +0000
Subject: [PATCH 002/108] feat(platform-wallet-storage): seedless load() +
rehydration readers [#3968, independent on v3.1-dev]
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Storage-crate half of the rehydration work, rebuilt to stand alone on
v3.1-dev: SqlitePersister::load() wiring + per-area readers (accounts,
core_state, identities, asset_locks, contacts, identity_keys) that
reconstruct the keyless ClientWalletStartState.
Independence on v3.1-dev required two deliberate stubs — the reshaped
ClientWalletStartState drops wallet/wallet_info, breaking two base
consumers; both are resolved by #3692 in the dash-evo-tool integration:
- manager/load.rs: whole-body todo!("keyless rehydration lands in #3692")
- ffi/persistence.rs: tail-only todo!("seeded FFI restore path lands in
#3692") — keeps the 8 builder helpers live (no dead_code under
-D warnings) and minimizes the #3692 merge conflict
Cross-crate manager-apply e2e tests in sqlite_core_state_reader.rs are
gated behind a new off-by-default `rehydration-apply` feature (enabled in
the integrated stack); storage-level load_state assertions run standalone.
Co-Authored-By: Claude Opus 4.6
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
---
Cargo.lock | 2 +
.../rs-platform-wallet-ffi/src/persistence.rs | 34 +-
.../.cargo/audit.toml | 26 +
.../rs-platform-wallet-storage/Cargo.toml | 36 +-
packages/rs-platform-wallet-storage/README.md | 43 +-
packages/rs-platform-wallet-storage/SCHEMA.md | 205 ++-
.../rs-platform-wallet-storage/SECRETS.md | 297 ++++-
.../migrations/V001__initial.rs | 101 +-
.../src/bin/platform-wallet-storage.rs | 53 +-
packages/rs-platform-wallet-storage/src/kv.rs | 92 +-
.../rs-platform-wallet-storage/src/lib.rs | 23 +-
.../src/secrets/error.rs | 364 ++++--
.../src/secrets/file/crypto.rs | 97 +-
.../src/secrets/file/format.rs | 363 ++++--
.../src/secrets/file/mod.rs | 1107 +++++++++++------
.../src/secrets/keyring.rs | 89 +-
.../src/secrets/mod.rs | 74 +-
.../src/secrets/secret.rs | 389 ++++--
.../src/secrets/store.rs | 1042 ++++++++++++++--
.../src/secrets/wire/aad.rs | 252 ++++
.../src/secrets/wire/config.rs | 43 +
.../src/secrets/wire/envelope.rs | 1100 ++++++++++++++++
.../src/secrets/wire/kdf.rs | 56 +
.../src/secrets/wire/mod.rs | 36 +
.../src/sqlite/backup.rs | 316 ++---
.../src/sqlite/config.rs | 14 +-
.../src/sqlite/conn.rs | 54 +-
.../src/sqlite/error.rs | 185 +--
.../src/sqlite/kv.rs | 68 +-
.../src/sqlite/migrations.rs | 66 +-
.../src/sqlite/persister.rs | 598 +++++----
.../src/sqlite/schema/accounts.rs | 363 ++++--
.../src/sqlite/schema/asset_locks.rs | 221 +++-
.../src/sqlite/schema/blob.rs | 77 +-
.../src/sqlite/schema/contacts.rs | 145 ++-
.../src/sqlite/schema/core_state.rs | 339 +++--
.../src/sqlite/schema/dashpay.rs | 38 +-
.../src/sqlite/schema/identities.rs | 98 +-
.../src/sqlite/schema/identity_keys.rs | 126 +-
.../src/sqlite/schema/mod.rs | 44 +-
.../src/sqlite/schema/platform_addrs.rs | 97 +-
.../src/sqlite/schema/token_balances.rs | 29 +-
.../schema/{wallet_meta.rs => wallets.rs} | 55 +-
.../src/sqlite/util/safe_cast.rs | 68 +-
.../tests/common/mod.rs | 28 +-
.../tests/secrets_api.rs | 80 ++
.../tests/secrets_default_on_compiles.rs | 5 +-
.../tests/secrets_scan.rs | 18 +-
.../tests/sqlite_account_zero_attribution.rs | 121 ++
.../tests/sqlite_accounts_reader.rs | 125 ++
.../tests/sqlite_asset_locks_filter.rs | 143 +++
.../tests/sqlite_auto_backup.rs | 83 +-
.../tests/sqlite_check_constraints.rs | 102 +-
..._commit_writes_lock_poison_shortcircuit.rs | 108 ++
.../tests/sqlite_compile_time.rs | 29 +-
.../tests/sqlite_contacts_keys_rehydration.rs | 187 +++
.../tests/sqlite_core_state_reader.rs | 392 ++++++
.../tests/sqlite_dashpay_overlay_contract.rs | 109 ++
.../tests/sqlite_delete_buffer_reconcile.rs | 10 +-
.../sqlite_delete_cross_process_exclusion.rs | 16 +-
.../sqlite_delete_partial_commit_window.rs | 162 +++
.../tests/sqlite_delete_real_apply_failure.rs | 73 ++
.../tests/sqlite_delete_wallet.rs | 2 +-
.../tests/sqlite_error_classification.rs | 77 +-
.../tests/sqlite_fk_changeset_ordering.rs | 269 ++++
.../tests/sqlite_foreign_keys.rs | 56 +-
.../tests/sqlite_identity_keys_reader.rs | 150 +++
.../tests/sqlite_load_reconstruction.rs | 244 ++--
.../tests/sqlite_load_wiring.rs | 153 +++
.../tests/sqlite_migrations.rs | 32 +-
.../sqlite_money_column_overflow_on_read.rs | 119 ++
.../tests/sqlite_object_metadata.rs | 152 +--
.../tests/sqlite_open_integrity_check.rs | 4 +-
.../tests/sqlite_persist_roundtrip.rs | 32 +-
.../tests/sqlite_qa_identity_tombstone.rs | 290 +++++
.../tests/sqlite_second_open_guard.rs | 64 +
.../tests/sqlite_structural_hardening.rs | 100 +-
.../tests/sqlite_wallet_db_identity.rs | 168 +++
.../changeset/client_wallet_start_state.rs | 70 +-
.../rs-platform-wallet/src/manager/load.rs | 188 +--
80 files changed, 9711 insertions(+), 3175 deletions(-)
create mode 100644 packages/rs-platform-wallet-storage/.cargo/audit.toml
create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs
create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/config.rs
create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs
create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs
create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs
rename packages/rs-platform-wallet-storage/src/sqlite/schema/{wallet_meta.rs => wallets.rs} (65%)
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs
diff --git a/Cargo.lock b/Cargo.lock
index e296c3aebdb..ca8c6f0e426 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -179,6 +179,7 @@ dependencies = [
"blake2",
"cpufeatures 0.2.17",
"password-hash",
+ "zeroize",
]
[[package]]
@@ -5228,6 +5229,7 @@ dependencies = [
"refinery",
"region",
"rusqlite",
+ "schemars 1.2.1",
"serde",
"serde_json",
"serial_test",
diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs
index 86b5561518c..465ec62c16c 100644
--- a/packages/rs-platform-wallet-ffi/src/persistence.rs
+++ b/packages/rs-platform-wallet-ffi/src/persistence.rs
@@ -3377,29 +3377,17 @@ fn build_wallet_start_state(
// status without rebroadcasting.
let unused_asset_locks = build_unused_asset_locks(entry)?;
- let wallet_state = ClientWalletStartState {
- wallet,
- wallet_info,
- identity_manager,
- unused_asset_locks,
- };
-
- let platform_address_state = if per_account.is_empty()
- && entry.platform_sync_height == 0
- && entry.platform_sync_timestamp == 0
- && entry.platform_last_known_recent_block == 0
- {
- None
- } else {
- Some(platform_wallet::PlatformAddressSyncStartState {
- per_account,
- sync_height: entry.platform_sync_height,
- sync_timestamp: entry.platform_sync_timestamp,
- last_known_recent_block: entry.platform_last_known_recent_block,
- })
- };
-
- Ok((wallet_state, platform_address_state))
+ // Projecting the reconstructed `wallet`/`wallet_info` into the
+ // reshaped keyless `ClientWalletStartState` (account manifest +
+ // `CoreChangeSet` + the keyless contact / identity-key feeds) is the
+ // seeded FFI restore path, which lands in #3692. The storage-only
+ // #3968 branch keeps every reader above wired — `build_unused_asset_locks`
+ // and `build_wallet_identity_bucket` still run, so their `?` error
+ // paths and the per-account projection stay exercised — and stubs
+ // only this final assembly. `_` consumes the two start-state slices
+ // whose sole consumer was the removed struct literal.
+ let _ = (identity_manager, unused_asset_locks);
+ todo!("seeded FFI restore path lands in #3692")
}
/// Translate the `IdentityRestoreEntryFFI` slice carried on a wallet
diff --git a/packages/rs-platform-wallet-storage/.cargo/audit.toml b/packages/rs-platform-wallet-storage/.cargo/audit.toml
new file mode 100644
index 00000000000..6e07a8c19dd
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/.cargo/audit.toml
@@ -0,0 +1,26 @@
+[advisories]
+# Each entry MUST point at a live advisory in this crate's resolved graph
+# and carry a dated rationale + remediation plan. Do not blanket-ignore.
+ignore = [
+ # RUSTSEC-2025-0141 — bincode is unmaintained (informational advisory,
+ # not an exploitable CVE; published 2025-12-16, no patched release).
+ # Acknowledged 2026-06-08.
+ #
+ # Why this is acceptable for now:
+ # - bincode 2.0.1 is the BLOB-codec trust boundary for every
+ # persisted column and backup. The known risk class for an
+ # unmaintained deserializer is unbounded allocation (OOM) on a
+ # crafted/corrupt input.
+ # - In-crate size bounds defang that class: MAX_VALUE_LEN /
+ # BLOB_SIZE_LIMIT_BYTES (kv/blob), the per-secret SecretTooLarge
+ # write cap, and the MAX_VAULT_SIZE_BYTES read ceiling all reject
+ # oversized inputs before bincode ever allocates from them.
+ # - load() is fail-hard: a malformed/over-large row aborts the call
+ # with a typed error rather than silently over-allocating.
+ #
+ # Residual risk: a future bincode defect would go unpatched upstream.
+ # Remediation plan: migrate the BLOB codec to a maintained equivalent
+ # (postcard / bitcode candidates) once the wire format is frozen at
+ # release; revisit this ignore at that time.
+ "RUSTSEC-2025-0141",
+]
diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml
index 43bf9a0fb0f..d61796bc8e0 100644
--- a/packages/rs-platform-wallet-storage/Cargo.toml
+++ b/packages/rs-platform-wallet-storage/Cargo.toml
@@ -61,6 +61,11 @@ chrono = { version = "0.4", default-features = false, features = [
"clock",
], optional = true }
sha2 = { version = "0.10", optional = true }
+# Opt-in `JsonSchema` for `SecretString` (gated by `secret-schemars`).
+# Reuses the workspace-locked 1.2.1. `default-features = false` drops the
+# `derive` feature (we hand-write the impl), matching the crate's existing
+# derive-free schemars usage so the lock gains no `schemars_derive` entry.
+schemars = { version = "1", optional = true, default-features = false }
# Secret-storage deps (gated by the `secrets` feature). RustSec-clean
# pins (Smythe §7); `aes-gcm` is deliberately omitted. `keyring`'s
@@ -80,7 +85,14 @@ keyring-core = { version = "=1.0.0", optional = true }
# was removed from the sqlite arm — those tests grep for `fs2`/`fs4`
# literals in this crate's source/manifest and would re-trigger on the
# older crates. `fd-lock` has no such collision.
-fd-lock = { version = "4.0.4", optional = true }
+# LOCAL-FS ONLY: flock/LockFileEx interlock processes only on local
+# filesystems; over NFS/CIFS the lock does not interlock, so a vault file
+# must not be shared across hosts — steer multi-host to the OS-keyring arm.
+# Exact-pinned (`=`) like the rest of the soundness-critical stack: the
+# `VaultLock` unsafe drop-order argument in `secrets/file/mod.rs` is
+# calibrated to fd-lock 4.0.4's guard internals; any bump must re-verify
+# that the guard releases the OS lock before the backing `RwLock` frees.
+fd-lock = { version = "=4.0.4", optional = true }
# CLI deps (gated by the `cli` feature)
clap = { version = "4", features = ["derive"], optional = true }
@@ -180,6 +192,17 @@ cli = [
# crate without the crypto graph.
secrets = [
"dep:argon2",
+ # Enable argon2's `zeroize` feature so the KDF wipes its sensitive
+ # intermediate state (`initial_hash`/`blockhash`) on drop. In argon2
+ # 0.5.3 this does NOT cover the bulk `Block` matrix — that residual is
+ # documented at `derive_key` in `secrets/file/crypto.rs`. Keep it in
+ # the feature list (not a `default-features = false` rewrite) so
+ # argon2's own default features stay intact.
+ "argon2/zeroize",
+ # bincode is the producer for the Tier-2 envelope wire format and the
+ # three AAD encodings (`Tier2Aad`/`EntryAad`/`VerifyAad`) — see
+ # `secrets/wire/`. `=2.0.1` is the workspace-wide pin.
+ "dep:bincode",
"dep:chacha20poly1305",
# secrets uses serde directly (vault format + crypto envelope derive
# `Serialize`/`Deserialize`); declare the dep here so
@@ -199,6 +222,15 @@ secrets = [
"dep:apple-native-keyring-store",
"dep:windows-native-keyring-store",
]
+# Opt-in `SecretString` serde/schemars impls. Deliberately DEFAULT-OFF
+# even though `secrets` (and, via it, the `serde` dep) are default-on:
+# these gate the IMPLS, not the dep, so the impls are absent unless a
+# consumer explicitly opts in. `secret-serde` requires `secrets` (the type
+# only exists under it). NO `Serialize` is ever provided. `secret-schemars`
+# implies `secret-serde`. (design §5.4 / GAP-001 names / GAP-002 satisfiable
+# default-off.)
+secret-serde = ["secrets", "dep:serde"]
+secret-schemars = ["secret-serde", "dep:schemars"]
# Per-object-type key/value metadata API
# (`platform_wallet_storage::{KvStore, KvError, ObjectId}`) plus the
# SQLite-backed impl. Requires `sqlite` because the only shipped backend
@@ -212,3 +244,5 @@ kv = ["sqlite"]
# convention for "MUST NOT enable from downstream" features
# (https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2).
__test-helpers = ["sqlite"]
+# e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool).
+rehydration-apply = []
diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md
index 34917c10e03..04c4d6057eb 100644
--- a/packages/rs-platform-wallet-storage/README.md
+++ b/packages/rs-platform-wallet-storage/README.md
@@ -103,8 +103,11 @@ flush, 5 s busy timeout, WAL journal, `NORMAL` synchronous, and an
auto-backup dir at `/backups/auto/`.
The trait surface is `store` / `flush` / `load` / `get_core_tx_record`.
-Schema migrations are append-only Rust files under `migrations/`, applied
-via [`refinery`](https://github.com/rust-db/refinery) on every `open`.
+Schema migrations are versioned Rust files under `migrations/`, applied via
+[`refinery`](https://github.com/rust-db/refinery) on every `open`. While the
+crate is unreleased, in-place edits to the sole shipped `V001` are allowed;
+the append-only guarantee (add a new versioned file, never edit a prior one)
+takes effect once the schema is frozen at release.
#### Flush semantics (store / flush)
@@ -138,17 +141,31 @@ so one failed wallet does not hide its siblings.
#### load() reconstruction
-`SqlitePersister::load()` returns the base `ClientStartState` (plain struct,
-two slots — no `#[non_exhaustive]`):
+`SqlitePersister::load()` returns a fully-rehydrated `ClientStartState`
+(plain struct — no `#[non_exhaustive]`). Both slots are populated:
| Slot | Reader | Status |
|---|---|---|
-| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallet_meta::list_ids` wallet universe) | populated |
-| `wallets` | — | empty pending upstream `Wallet::from_persisted` |
-
-The `identities` / `contacts` / `asset_locks` per-area readers exist as
-hardened dormant helpers (`schema:: ::load_state`) but are not wired
-into `load()` — `ClientStartState` carries no slot for them.
+| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallets::list_ids` wallet universe) | populated |
+| `wallets` | per-wallet `schema:: ` readers (see below) | populated |
+
+Each `ClientStartState::wallets` entry is a **keyless** `ClientWalletStartState`
+reconstructed from these per-area readers:
+
+| Field | Reader |
+|---|---|
+| `network` / `birth_height` | `schema::wallets::fetch` |
+| `account_manifest` | `schema::accounts::load_state` |
+| `core_state` | `schema::core_state::load_state` |
+| `identity_manager` | `schema::identities::load_state` |
+| `unused_asset_locks` | `schema::asset_locks::load_unconsumed` (`Consumed`-filtered — spent locks stay on disk but are never resurrected) |
+| `contacts` | `schema::contacts::load_changeset` |
+| `identity_keys` | `schema::identity_keys::load_state` |
+
+The payload carries **no** `Wallet` and no key material — the manager
+rebuilds each wallet watch-only via `Wallet::new_watch_only` from the
+manifest and applies this state; signing keys are derived later on demand
+via the `sign_with_mnemonic_resolver` path.
Loading is **fail-hard**: any row that fails to decode, or a stored
`wallet_id` that is not exactly 32 bytes, aborts the whole call with a typed
@@ -158,9 +175,9 @@ corruption tolerance, no per-row skip, and no partial `Ok` — a corrupt
database surfaces as an error rather than silently losing rows.
The summary `tracing::info!` carries `wallets_seen`, `addresses_loaded`,
-`wallets_rehydrated`, and `wallets_pending_rehydration` (the count of
-wallets that *would* be rehydrated once upstream provides
-`Wallet::from_persisted`).
+`wallets_rehydrated` (the count actually rehydrated this call), and
+`wallets_pending_rehydration` (now always `0` — every seen wallet is
+rehydrated). The only deferred field is listed in `LOAD_UNIMPLEMENTED`.
### KV metadata API
diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md
index 8149fb16e23..ee64b11df45 100644
--- a/packages/rs-platform-wallet-storage/SCHEMA.md
+++ b/packages/rs-platform-wallet-storage/SCHEMA.md
@@ -11,9 +11,9 @@ chain.
## What it stores — and the boundary
The persister stores **public** wallet-state material (UTXOs, transactions,
-account registrations, address pools, identities, identity public keys,
-contacts, asset locks, token balances, DashPay overlays, and
-platform-address sync snapshots) in a SQLite database managed by
+account registrations, identities, identity public keys, contacts, asset
+locks, token balances, DashPay overlays, and platform-address sync
+snapshots) in a SQLite database managed by
[refinery](https://crates.io/crates/refinery) migrations.
**No secrets are stored here.** Mnemonics, seeds, and raw private keys never
@@ -23,7 +23,7 @@ see [SECRETS.md](./SECRETS.md).
## How integrity is kept
-Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. Deleting a `wallet_metadata` row cleans that wallet's metadata along two paths:
+Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. Deleting a `wallets` row cleans that wallet's metadata along two paths:
- **`wallet_id`-scoped meta** (`meta_wallet`, `meta_contact`, `meta_platform_address`) carries a `wallet_id` column, so `cascade_meta_on_wallet_delete` brooms it directly — regardless of the lifecycle state of any typed parent and even for rows written ahead of (or without) a typed parent.
- **identity-scoped meta** (`meta_identity`, `meta_token`) carries no `wallet_id` — only `identity_id` (+ `token_id`). It is cleaned by `cascade_meta_on_identity_delete` (AFTER DELETE ON `identities`), which fires for the wallet's own identities when the FK cascade removes them on a wallet delete.
@@ -37,24 +37,22 @@ Any `meta_*` row whose parent object does not exist — because it was never cre
A future garbage-collection pass is expected to reap orphan metadata — rows with no live parent object older than approximately one week — but no such GC is implemented yet. Callers should not rely on orphan metadata persisting forever, nor assume it will be cleaned up promptly. `meta_global` is intentionally parentless and always survives.
-The 23 tables are split into five domain diagrams below. `WALLET_METADATA` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section.
+The 21 tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section.
## Diagram 1 — Core / L1 (Bitcoin/Dash layer)
-Account registrations, address-pool snapshots, transactions, UTXOs, instant locks, derived addresses, and SPV sync state.
+Account registrations, transactions, UTXOs, instant locks, and SPV sync state.
```mermaid
erDiagram
- WALLET_METADATA ||--o{ ACCOUNT_REGISTRATIONS : "registers"
- WALLET_METADATA ||--o{ ACCOUNT_ADDRESS_POOLS : "snapshots"
- WALLET_METADATA ||--o{ CORE_TRANSACTIONS : "records"
- WALLET_METADATA ||--o{ CORE_UTXOS : "owns"
- WALLET_METADATA ||--o{ CORE_INSTANT_LOCKS : "holds"
- WALLET_METADATA ||--o{ CORE_DERIVED_ADDRESSES : "derives"
- WALLET_METADATA ||--o| CORE_SYNC_STATE : "tracks"
+ WALLETS ||--o{ ACCOUNT_REGISTRATIONS : "registers"
+ WALLETS ||--o{ CORE_TRANSACTIONS : "records"
+ WALLETS ||--o{ CORE_UTXOS : "owns"
+ WALLETS ||--o{ CORE_INSTANT_LOCKS : "holds"
+ WALLETS ||--o| CORE_SYNC_STATE : "tracks"
CORE_TRANSACTIONS ||--o{ CORE_UTXOS : "spends"
- WALLET_METADATA {
+ WALLETS {
BLOB wallet_id PK "32-byte WalletId"
TEXT network "mainnet | testnet | devnet | regtest"
INTEGER birth_height "SPV scan start height"
@@ -62,19 +60,11 @@ erDiagram
ACCOUNT_REGISTRATIONS {
BLOB wallet_id PK
- TEXT account_type PK "standard | coinjoin | identity_registration | ..."
+ TEXT account_type PK "standard_bip44 | standard_bip32 | coinjoin | identity_registration | ..."
INTEGER account_index PK
BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry"
}
- ACCOUNT_ADDRESS_POOLS {
- BLOB wallet_id PK
- TEXT account_type PK
- INTEGER account_index PK
- TEXT pool_type PK "external | internal | absent | absent_hardened"
- BLOB snapshot_blob "bincode-encoded AccountAddressPoolEntry"
- }
-
CORE_TRANSACTIONS {
BLOB wallet_id PK
BLOB txid PK "32-byte Txid"
@@ -102,15 +92,6 @@ erDiagram
BLOB islock_blob "bincode-encoded InstantLock"
}
- CORE_DERIVED_ADDRESSES {
- BLOB wallet_id PK
- TEXT account_type PK
- TEXT address PK "bech32 / Base58 address string"
- INTEGER account_index
- TEXT derivation_path "pool_type/derivation_index"
- INTEGER used "0 | 1"
- }
-
CORE_SYNC_STATE {
BLOB wallet_id PK "one row per wallet"
INTEGER last_processed_height "NULL until first block processed"
@@ -125,17 +106,18 @@ erDiagram
## Diagram 2 — Identities + DashPay (Platform L2 identity tree)
-Platform identities, their public keys, token balances, and DashPay profiles/payments. Identity-owned tables have no direct `wallet_id` column; cascade flows `wallet_metadata → identities → child`.
+Platform identities, their public keys, token balances, and DashPay profiles/payments. Most identity-owned tables have no direct `wallet_id` column and cascade via `wallets → identities → child`; `identity_keys` is the exception — it carries its own `wallet_id` column and two `ON DELETE CASCADE` FKs (one to `wallets`, one to `identities`).
```mermaid
erDiagram
- WALLET_METADATA ||--o{ IDENTITIES : "parents"
+ WALLETS ||--o{ IDENTITIES : "parents"
+ WALLETS ||--o{ IDENTITY_KEYS : "owns"
IDENTITIES ||--o{ IDENTITY_KEYS : "has"
IDENTITIES ||--o{ TOKEN_BALANCES : "holds"
IDENTITIES ||--o| DASHPAY_PROFILES : "has"
IDENTITIES ||--o{ DASHPAY_PAYMENTS_OVERLAY : "overlays"
- WALLET_METADATA {
+ WALLETS {
BLOB wallet_id PK "32-byte WalletId"
TEXT network
INTEGER birth_height
@@ -144,16 +126,18 @@ erDiagram
IDENTITIES {
BLOB identity_id PK "32-byte Platform Identifier"
BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)"
- INTEGER wallet_index "BIP-32 index; NULL for out-of-wallet identities"
+ INTEGER identity_index "BIP-32 index; NULL for out-of-wallet identities"
BLOB entry_blob "bincode-encoded IdentityEntry"
INTEGER tombstoned "0 | 1 (logical delete)"
}
IDENTITY_KEYS {
+ BLOB wallet_id PK "32-byte WalletId"
BLOB identity_id PK
INTEGER key_id PK "KeyID"
BLOB public_key_blob "bincode-encoded IdentityKeyWire (public material only)"
BLOB public_key_hash "20-byte HASH160 of the key"
+ BLOB derivation_blob "reserved typed projection; always NULL today"
}
TOKEN_BALANCES {
@@ -181,10 +165,10 @@ One unified table for all three states of a DashPay contact relationship — the
```mermaid
erDiagram
- WALLET_METADATA ||--o{ CONTACTS : "has"
+ WALLETS ||--o{ CONTACTS : "has"
IDENTITIES ||--o{ CONTACTS : "relates"
- WALLET_METADATA {
+ WALLETS {
BLOB wallet_id PK "32-byte WalletId"
TEXT network
INTEGER birth_height
@@ -221,11 +205,11 @@ Platform P2PKH address pool with its sync watermark, and the asset-lock lifecycl
```mermaid
erDiagram
- WALLET_METADATA ||--o{ PLATFORM_ADDRESSES : "tracks"
- WALLET_METADATA ||--o| PLATFORM_ADDRESS_SYNC : "syncs"
- WALLET_METADATA ||--o{ ASSET_LOCKS : "issues"
+ WALLETS ||--o{ PLATFORM_ADDRESSES : "tracks"
+ WALLETS ||--o| PLATFORM_ADDRESS_SYNC : "syncs"
+ WALLETS ||--o{ ASSET_LOCKS : "issues"
- WALLET_METADATA {
+ WALLETS {
BLOB wallet_id PK "32-byte WalletId"
TEXT network
INTEGER birth_height
@@ -267,7 +251,7 @@ table per [`ObjectId`](./src/kv.rs) variant. `meta_global` has no parent
and survives wallet deletion. The other five carry **no foreign key**:
metadata may be written before its parent object is synced into its
typed table. `AFTER DELETE` triggers provide a soft cascade so metadata
-never outlives its wallet. Deleting a `wallet_metadata` row brooms every
+never outlives its wallet. Deleting a `wallets` row brooms every
wallet-scoped `meta_*` row by `wallet_id` directly, and the FK cascade
through `identities` brooms the identity-scoped `meta_*` rows by
`identity_id`; both legs key on the id alone, so cleanup is independent
@@ -278,9 +262,9 @@ edges below denote trigger-based cleanup, not an FK relationship.
```mermaid
erDiagram
- WALLET_METADATA ||..o{ META_WALLET : "trigger cleanup (by wallet_id)"
- WALLET_METADATA ||..o{ META_CONTACT : "trigger cleanup (by wallet_id)"
- WALLET_METADATA ||..o{ META_PLATFORM_ADDRESS : "trigger cleanup (by wallet_id)"
+ WALLETS ||..o{ META_WALLET : "trigger cleanup (by wallet_id)"
+ WALLETS ||..o{ META_CONTACT : "trigger cleanup (by wallet_id)"
+ WALLETS ||..o{ META_PLATFORM_ADDRESS : "trigger cleanup (by wallet_id)"
IDENTITIES ||..o{ META_IDENTITY : "trigger cleanup (by identity_id)"
IDENTITIES ||..o{ META_TOKEN : "trigger cleanup (by identity_id)"
@@ -291,7 +275,7 @@ erDiagram
}
META_WALLET {
- BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete"
+ BLOB wallet_id PK "no FK; trigger cleanup on wallets delete"
TEXT key PK
BLOB value
INTEGER updated_at
@@ -313,7 +297,7 @@ erDiagram
}
META_CONTACT {
- BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete"
+ BLOB wallet_id PK "no FK; trigger cleanup on wallets delete"
BLOB owner_id PK
BLOB contact_id PK
TEXT key PK
@@ -322,7 +306,7 @@ erDiagram
}
META_PLATFORM_ADDRESS {
- BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete"
+ BLOB wallet_id PK "no FK; trigger cleanup on wallets delete"
BLOB address PK
TEXT key PK
BLOB value
@@ -342,7 +326,7 @@ erDiagram
## Tables
-### `wallet_metadata`
+### `wallets`
Root anchor for every per-wallet table. Deleting a row cascades to all
direct children; identity-owned children cascade through `identities`.
@@ -359,15 +343,7 @@ the typed `account_type` / `account_index` columns mirror it for SQL
lookups without blob decoding.
- PK: `(wallet_id, account_type, account_index)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
-
-### `account_address_pools`
-
-Address-pool snapshot per `(wallet, account, pool_type)`. `pool_type` is
-one of `external`, `internal`, `absent`, `absent_hardened`.
-
-- PK: `(wallet_id, account_type, account_index, pool_type)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
### `core_transactions`
@@ -376,7 +352,7 @@ One row per transaction the wallet has seen. `height`, `block_hash`, and
is `1` once block context is present.
- PK: `(wallet_id, txid)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
- Index: `idx_core_transactions_height(wallet_id, height)`.
### `core_utxos`
@@ -387,7 +363,7 @@ by a trigger when its referenced `core_transactions` row is deleted
NOT NULL `wallet_id` column).
- PK: `(wallet_id, outpoint)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
- Index: `idx_core_utxos_spent(wallet_id, spent)`.
### `core_instant_locks`
@@ -396,25 +372,21 @@ Instant-lock blobs for transactions that are broadcast but not yet
finalized. Rows are removed when the transaction becomes confirmed.
- PK: `(wallet_id, txid)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
-
-### `core_derived_addresses`
-
-Address-to-account-index map. Written before UTXOs in the same
-transaction so the UTXO writer can resolve `account_index` by address.
-
-- PK: `(wallet_id, account_type, address)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
-- Index: `idx_core_derived_addresses_addr(wallet_id, address)`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
### `core_sync_state`
-One row per wallet, holding monotonically-advancing SPV sync watermarks.
-`last_processed_height` and `synced_height` are NULL until the first
-block is processed.
+One row per wallet, holding monotonically-advancing SPV sync watermarks and
+the last applied ChainLock. `last_processed_height` and `synced_height` are
+NULL until the first block is processed. `last_applied_chain_lock` is NULL
+until a ChainLock has been applied and flushed.
- PK: `wallet_id` (single-row-per-wallet).
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
+- `last_applied_chain_lock BLOB` — bincode-encoded
+ `dashcore::ephemerealdata::chain_lock::ChainLock`; used during rehydration
+ to restore `WalletMetadata::last_applied_chain_lock` so asset-lock proof
+ generation can use the cached ChainLock from before a restart.
### `identities`
@@ -424,7 +396,7 @@ NULL means the identity was written before a parent wallet was registered
marks a logical delete; the row is retained for cascade integrity.
- PK: `identity_id`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE` (nullable).
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE` (nullable).
- Index: `idx_identities_wallet(wallet_id)`.
### `identity_keys`
@@ -432,11 +404,14 @@ marks a logical delete; the row is retained for cascade integrity.
Public identity keys only — no private material. The
`public_key_blob` is a custom wire format (`IdentityKeyWire`) that
pre-encodes the `IdentityPublicKey` via bincode 2 native `Encode/Decode`
-to work around a serde-tag incompatibility.
+to work around a serde-tag incompatibility. `derivation_blob` is a
+reserved column for a future typed projection and is always NULL today
+(derivation indices live inside `public_key_blob`).
-- PK: `(identity_id, key_id)`.
+- PK: `(wallet_id, identity_id, key_id)`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`.
-- Index: `idx_identity_keys_identity(identity_id)`.
+- Index: `idx_identity_keys_wallet_identity(wallet_id, identity_id)`.
### `contacts`
@@ -451,7 +426,7 @@ hold a bincode-encoded `ContactRequest`; `accepted_accounts` holds a
bincode-encoded `Vec`.
- PK: `(wallet_id, owner_id, contact_id)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
- `state` CHECK: sourced from `sqlite::schema::contacts::CONTACT_STATE_LABELS`.
### `platform_addresses`
@@ -461,7 +436,7 @@ HASH160; `balance` and `nonce` are the last-synced values from the
Platform layer.
- PK: `(wallet_id, address)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
### `platform_address_sync`
@@ -469,22 +444,27 @@ Per-wallet watermark for platform address sync. All three height/timestamp
fields advance monotonically (new values are `max(current, incoming)`).
- PK: `wallet_id` (single-row-per-wallet).
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
### `asset_locks`
Lifecycle tracking for asset-lock outpoints. `status` is a queryable
text column; `lifecycle_blob` carries the full `AssetLockEntry`. Consumed
-locks are removed via `AssetLockChangeSet::removed`, not retained with a
-consumed status.
+locks are **retained permanently** with `status = 'consumed'` (an upsert,
+never a `DELETE` — they are not routed through `AssetLockChangeSet::removed`),
+so the full lifecycle history stays on disk and remains visible via the
+unfiltered inspection reader (`schema::asset_locks::list_active`). The
+rehydration feed reads through `schema::asset_locks::load_unconsumed`, which
+filters at the SQL level (`status NOT IN ('consumed')`), so a spent one-shot
+lock is never resurrected as actionable.
- PK: `(wallet_id, outpoint)`.
-- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
+- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
### `token_balances`
Per-identity token balance cache, keyed by `(identity_id, token_id)`.
-Cascade flows `wallet_metadata → identities → token_balances` through the
+Cascade flows `wallets → identities → token_balances` through the
nullable `identities.wallet_id` link; no direct `wallet_id` column exists.
- PK: `(identity_id, token_id)`.
@@ -524,7 +504,7 @@ Unlike every other per-wallet table, the five typed `meta_*` tables carry
host apps can attach metadata independently of sync ordering (and a
global-config persister can write to typed scopes whose parent tables
stay empty). Cleanup is instead a soft cascade. Deleting a
-`wallet_metadata` row fires a wallet-rooted `AFTER DELETE` trigger that
+`wallets` row fires a wallet-rooted `AFTER DELETE` trigger that
brooms the wallet-scoped tables (`meta_wallet`, `meta_contact`,
`meta_platform_address`) by `wallet_id`, and the FK cascade through
`identities` fires a per-identity trigger that brooms `meta_identity` +
@@ -546,7 +526,7 @@ Global metadata with no parent — survives every wallet delete.
Per-wallet metadata. Writable before the wallet exists.
- PK: `(wallet_id, key)`.
-- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`).
+- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`).
#### `meta_identity`
@@ -567,7 +547,7 @@ Per-token-balance metadata. Writable before the token balance exists.
Per-contact metadata for any lifecycle state. Writable before the contact exists.
- PK: `(wallet_id, owner_id, contact_id, key)`.
-- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts`, any state) for a direct contact delete.
+- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`) on a wallet delete, plus `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts`, any state) for a direct contact delete.
#### `meta_platform_address`
@@ -575,30 +555,28 @@ Per-platform-address metadata. `address` is an opaque `BLOB`. Writable
before the address exists.
- PK: `(wallet_id, address, key)`.
-- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`) for a direct address delete.
+- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`) on a wallet delete, plus `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`) for a direct address delete.
## Enum-domain CHECK constraints
-Six TEXT columns carry a `CHECK (col IN (...))` clause whose IN-list is
-built at migration time from `pub(crate) const *_LABELS` arrays declared
-next to each writer function. Five mirror an upstream Rust enum; the
-sixth (`contacts.state`) is a synthetic lifecycle label naming which
-`ContactChangeSet` slot a row came from:
+Four TEXT columns carry a `CHECK (col IN (...))` across four enum
+domains. The IN-list is built at migration time from
+`pub(crate) const *_LABELS` arrays declared next to each writer function.
+Three domains mirror an upstream Rust enum; the fourth (`contacts.state`)
+is a synthetic lifecycle label naming which `ContactChangeSet` slot a row
+came from:
| Table | Column | Source-of-truth const |
|---|---|---|
-| `wallet_metadata` | `network` | `sqlite::schema::wallet_meta::NETWORK_LABELS` |
+| `wallets` | `network` | `sqlite::schema::wallets::NETWORK_LABELS` |
| `account_registrations` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` |
-| `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` |
-| `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` |
-| `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` |
| `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` |
| `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` |
The const arrays are the single source of truth shared by the writer
mapping functions (`network_to_str`, `account_type_db_label`,
-`pool_type_db_label`, `status_str`, `contact_state_db_label`) and the
-migration's CHECK clauses.
+`status_str`, `contact_state_db_label`) and the migration's CHECK
+clauses.
Per-module `*_labels_match_enum` unit tests enforce set-equality
between each const and the writer's codomain — drift (a renamed/added
upstream variant) fails the test rather than landing as silent garbage
@@ -607,10 +585,9 @@ in this document; the source files are canonical.
### Upstream-enum coupling
-Three of the persisted enums live in the external `rust-dashcore`
-crate (`key_wallet::Network`, `key_wallet::account::AccountType`,
-`key_wallet::managed_account::address_pool::AddressPoolType`); the
-fourth (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`)
+Two of the persisted enums live in the external `rust-dashcore`
+crate (`key_wallet::Network`, `key_wallet::account::AccountType`); the
+third (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`)
is in-tree and carries a `# Schema coupling` rustdoc block.
Because the upstream definitions cannot be edited from this repository,
@@ -627,20 +604,24 @@ mechanisms working together:
TODO(rust-dashcore): once the upstream `key_wallet` crate is vendored
or the project gains push access there, mirror the in-tree
-`AssetLockStatus` `# Schema coupling` doc block on the three upstream
+`AssetLockStatus` `# Schema coupling` doc block on the two upstream
enums so a developer editing them upstream sees the constraint without
having to grep this repo.
## Foreign-key conventions
- All direct-child `wallet_id` columns are `BLOB(32)` references to
- `wallet_metadata.wallet_id` with `ON DELETE CASCADE`.
+ `wallets.wallet_id` with `ON DELETE CASCADE`.
- `identities.wallet_id` is the single nullable FK: NULL means orphan
(no parent wallet registered yet). The orphan-to-parented promotion
uses `COALESCE(identities.wallet_id, excluded.wallet_id)` on upsert.
-- Identity-owned tables (`identity_keys`, `token_balances`,
- `dashpay_profiles`, `dashpay_payments_overlay`) have no `wallet_id`
- column. Cascade reaches them via `identities(identity_id)`.
+- Identity-owned tables (`token_balances`, `dashpay_profiles`,
+ `dashpay_payments_overlay`) have no `wallet_id` column. Cascade reaches
+ them via `identities(identity_id)`.
+- `identity_keys` is the exception among identity-owned tables: it carries
+ a `wallet_id BLOB NOT NULL` column and two `ON DELETE CASCADE` FKs
+ (`wallet_id → wallets`, `identity_id → identities`), so a delete on
+ either parent cascades to it.
- `core_utxos.spent_in_txid` is cleared by the `setnull_core_utxos_on_tx_delete`
trigger rather than a native `ON DELETE SET NULL` FK, because SQLite would null
every column of a composite FK on SET NULL — including the NOT NULL `wallet_id`.
@@ -657,7 +638,7 @@ having to grep this repo.
| Trigger | Fires | Action |
|---|---|---|
| `setnull_core_utxos_on_tx_delete` | AFTER DELETE ON `core_transactions` | NULL `core_utxos.spent_in_txid` for the deleted tx |
-| `cascade_meta_on_wallet_delete` | AFTER DELETE ON `wallet_metadata` | delete `meta_wallet`, `meta_contact`, `meta_platform_address` rows by `wallet_id` |
+| `cascade_meta_on_wallet_delete` | AFTER DELETE ON `wallets` | delete `meta_wallet`, `meta_contact`, `meta_platform_address` rows by `wallet_id` |
| `cascade_meta_on_identity_delete` | AFTER DELETE ON `identities` | delete `meta_identity`, `meta_token` rows by `identity_id` |
| `cascade_meta_token_on_token_balance_delete` | AFTER DELETE ON `token_balances` | delete matching `meta_token` rows (direct balance delete) |
| `cascade_meta_contact_on_contact_delete` | AFTER DELETE ON `contacts` | delete matching `meta_contact` rows (any state; direct contact delete) |
@@ -667,4 +648,4 @@ having to grep this repo.
| Version | File | Description |
|---|---|---|
-| V001 | `V001__initial.rs` | Full schema: all 23 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) |
+| V001 | `V001__initial.rs` | Full schema: all 21 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) |
diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md
index 7f983aa071c..9f1361702b1 100644
--- a/packages/rs-platform-wallet-storage/SECRETS.md
+++ b/packages/rs-platform-wallet-storage/SECRETS.md
@@ -30,6 +30,17 @@ The rest of this document is the technical detail behind that boundary: the
`secrets` backends, the `SecretStore` API, the error surface, and the threat
model.
+### Exception: the KV metadata API stores caller-supplied plaintext
+
+The boundary above is about the persister's own domain state. The
+separate `KvStore` API (`kv` feature) is a deliberate, explicit exception:
+it stores **arbitrary caller-supplied `Vec` values as PLAINTEXT** in
+`meta_*` BLOB columns of the same `.db` (and therefore in every backup).
+There is no encryption and no runtime content guard — the safety is
+**caller-policed**. Callers MUST NOT put key or signing material through
+`KvStore`; that is what `SecretStore` is for. The `KvStore` /
+`KvStore::put` rustdoc carries the same `# Security` warning.
+
## The `secrets` submodule
`platform_wallet_storage::secrets` is part of the crate's default
@@ -51,8 +62,22 @@ use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, W
let store = SecretStore::file("/var/lib/wallet/secrets.pwsvault", SecretString::new("pw"))?;
let wallet = WalletId::from(wallet_id);
+
+// Tier-1 only (unprotected by an object password). `set`/`get` are
+// `..,None` wrappers over `set_secret`/`get_secret`.
store.set(&wallet, "mnemonic", &SecretBytes::from_slice(b"abandon ability ..."))?;
let plaintext: Option = store.get(&wallet, "mnemonic")?; // never a bare Vec
+
+// Tier-2: protect a critical object under an extra OBJECT PASSWORD that
+// the backend never sees. Reading it back REQUIRES the password.
+let pw = SecretString::new("a strong object password");
+store.set_secret(&wallet, "seed", &SecretBytes::from_slice(b""), Some(&pw))?;
+let seed = store.get_secret(&wallet, "seed", Some(&pw))?; // Some(secret)
+// Reading a protected object WITHOUT the password fails closed:
+assert!(store.get_secret(&wallet, "seed", None).is_err()); // NeedsPassword
+
+// Add / change / remove an object password in one atomic same-slot flow:
+store.reprotect(&wallet, "seed", Some(&pw), None)?; // remove → now unprotected
store.delete(&wallet, "mnemonic")?; // idempotent
```
@@ -61,6 +86,202 @@ filename); the parent directory is materialized on the first write.
Use `SecretStore::os()` for the platform OS keyring arm instead of
`SecretStore::file(..)`.
+See **Two-tier secret protection** below for the model, the envelope
+format, which tier defeats which adversary, and the strict fail-closed
+read that is the heart of the opt-in scheme.
+
+### Two-tier secret protection
+
+Secret protection comes in two layers. Tier-1 is always on (it is just
+"which backend you opened"); Tier-2 is opt-in, per critical object, and
+backend-independent.
+
+| Tier | Provided by | Defeats | Mechanism |
+|---|---|---|---|
+| **1 — backend baseline** | the *backend* | another local user, a lost laptop, the vault at rest | OS keychain ACLs **or** Argon2id + XChaCha20-Poly1305 vault under a **real** passphrase |
+| **2 — per-object password** | the *library*, above `SecretStore`, over **both** arms | **backend compromise** — the keychain scraped, or the vault stolen *and* its passphrase cracked | the object's bytes are Argon2id + XChaCha20-Poly1305 **enveloped under a per-object password BEFORE they reach the backend** |
+
+**Why Tier-2 is more than key granularity.** Its value is not a sub-key —
+it is (a) an **independent human password the backend never sees** and (b)
+**envelope-before-backend ordering**, so for a protected object the backend
+only ever stores ciphertext. That is the first and only control that keeps
+a chosen critical object confidential across a *full* backend compromise
+(the A2/A3/A6 gap Tier-1 leaves open).
+
+Tier-2 has two guarantees of different strength:
+
+- **Confidentiality** (an attacker cannot *read* a protected secret) is
+ **unconditional** — the object password never enters any backend, so a
+ full backend dump yields only ciphertext + a per-object salt to
+ offline-Argon2id-crack against the password's entropy.
+- **Integrity / anti-downgrade** is delivered by the **strict fail-closed
+ read** below and is **conditional on the caller's trusted model staying
+ intact** (see the documented residual).
+
+#### The envelope (wire format)
+
+Every value written through `set_secret`/`set` is wrapped in a
+self-describing, authenticated envelope before it reaches the backend. The
+backend (file vault or OS keychain) stores only these opaque bytes.
+
+The canonical wire format is **bincode-encoded** under a single
+`WIRE_CONFIG = standard().with_big_endian().with_no_limit()` against
+two `pub(crate)` types whose shapes are the source of truth — see
+[`src/secrets/wire/envelope.rs`](src/secrets/wire/envelope.rs) and
+[`src/secrets/wire/mod.rs`](src/secrets/wire/mod.rs):
+
+```rust
+struct Envelope { version: u32, payload: Payload }
+enum Payload {
+ Unprotected(Vec), // scheme 0
+ Password { // scheme 1
+ kdf: KdfParamsEncoded, // id u8 ‖ m_kib u32 ‖ t u32 ‖ p u32
+ salt: [u8; 32], nonce: [u8; 24],
+ ciphertext: Vec, // includes the 16-byte Poly1305 tag
+ },
+}
+```
+
+`ENVELOPE_VERSION = 1` is bumped only on a breaking layout change,
+independent of the vault `FORMAT_VERSION`. Decoding goes through a
+budget-limited `DECODE_CONFIG = WIRE_CONFIG.with_limit::()` so a
+hostile blob declaring a multi-GiB length prefix is rejected before
+allocation (security-positive deviation from the no-limit encoder
+config). Trailing bytes after a valid decode are also refused —
+`consumed == blob.len()` is a fail-closed invariant.
+
+- **AAD (scheme 1)** is bincode-encoded from `Tier2Aad`
+ ([`src/secrets/wire/aad.rs`](src/secrets/wire/aad.rs)), which binds
+ `domain (PWSEV-TIER2-AAD-v2) ‖ envelope_version ‖ scheme_discriminant
+ ‖ kdf ‖ salt ‖ wallet_id ‖ label`. The vault's own per-entry AAD goes
+ through `EntryAad` (`domain (PWSV-ENTRY-AAD-v2) ‖ format_version ‖
+ wallet_id ‖ label`) and the vault verify-token AAD through `VerifyAad`
+ (`domain (PWSV-VERIFY-AAD-v2) ‖ format_version ‖ salt ‖ kdf`). All
+ three domain tags are pair-wise byte-disjoint by construction. A
+ protected blob relocated to another slot — or any in-place header
+ edit — fails the tag (relocation/header-tamper resistance). On the
+ file arm this AAD is *in addition* to the vault's own per-entry AAD
+ + tag; on the OS arm it is the only authentication layer.
+- **KDF ceiling before derivation (anti-DoS).** The KDF params live in
+ the (attacker-controllable) header, so on a read the Argon2 ceiling
+ is enforced **before** any derivation/allocation — both the wider
+ `enforce_bounds` (algorithm id + floors/ceilings) AND a tighter
+ per-read gate that refuses any `m_kib > default_target().m_kib` OR
+ `t > default_target().t`. A forged header cannot inflate memory by
+ more than the shipped default or CPU by more than the shipped
+ iteration count.
+- **No vault format bump.** The envelope lives *inside* the entry
+ bytes, identical over File and Os, so there is no vault-parser or
+ migration change.
+- **Size cap.** The plaintext is capped at `MAX_PLAINTEXT_LEN`
+ (`MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD`), uniformly for both
+ schemes, so the enveloped bytes always fit the backend's own
+ `MAX_SECRET_LEN` cap and the user-visible limit is stable regardless
+ of scheme. Oversize → `SecretTooLarge { found, max }` with
+ `max = MAX_PLAINTEXT_LEN` (re-exported as `secrets::MAX_PLAINTEXT_LEN`).
+- **Unknown envelope version** → `UnsupportedEnvelopeVersion` — fail
+ closed **regardless of the password**: an envelope tagged for a
+ future layout can be neither safely unwrapped nor treated as
+ unprotected.
+- **Unparseable bytes / unknown scheme tag / trailing garbage** →
+ `Corruption`. There is no magic-byte peek — every blob runs through
+ the bincode decoder, and anything that does not round-trip cleanly
+ with `consumed == blob.len()` fails closed.
+
+#### The strict, fail-closed read
+
+The defining risk of any opt-in "some objects are extra-protected" scheme
+is **strip / downgrade**: an attacker who can WRITE the backend replaces a
+protected blob with a fresh, internally-valid *unprotected* (scheme-0) blob
+carrying a chosen seed/xpriv. There is nothing in that blob alone to prove
+an envelope was *expected*, so inferring protection from the stored bytes
+would silently return the attacker's secret — funds redirection, password
+prompt bypassed.
+
+The fix: **the "expected-protected" bit lives in the CALLER's trusted
+model, surfaced solely by whether a password is supplied to `get_secret` —
+NEVER inferred from the blob.** The library does not guess and does not
+persist the expectation. A supplied password *is* the assertion "this
+object must be protected":
+
+| `password` arg | stored blob | result |
+|---|---|---|
+| `Some(pw)` | valid scheme-1 | the secret, or `WrongPassword` on tag fail |
+| **`Some(pw)`** | **valid scheme-0 envelope** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED** |
+| `Some(pw)` | scheme-1 but truncated/corrupt | `Corruption` |
+| `Some/None` | unknown envelope version | `UnsupportedEnvelopeVersion` |
+| `Some/None` | unparseable / non-envelope bytes / trailing garbage | `Corruption` |
+| `None` | valid scheme-1 | `NeedsPassword` (never ciphertext) |
+| `None` | valid scheme-0 envelope | the secret |
+| any | absent entry | `Ok(None)` (deletion = DoS, never injection) |
+
+The load-bearing row is **`Some(pw)` + scheme-0 envelope ⇒
+`ExpectedProtectedButUnsealed`**: with a password in hand, an
+unprotected envelope can only mean a strip, so it is refused and **no
+bytes are returned**. A consumer bug alone — over- or under-supplying
+a password — fails closed in *every* direction.
+
+**Arm asymmetry.** On the file arm the stored bytes are themselves sealed
+under the vault key, so producing a *readable* stripped blob at a slot
+requires the vault key; a cold/backup-swap actor can only corrupt
+(→ DoS), not inject-to-readable. On the OS-keychain arm the stored item is
+the bare envelope with no second seal, so the strip defence there leans
+entirely on the `Some(pw)` strict rule plus the consumer's metadata
+integrity — this is where the residual bites hardest.
+
+**Documented residual (out of the library's reach).** If an attacker ALSO
+rewrites the consumer's trusted DB so the consumer calls `get_secret(X,
+None)` for a stripped object, the `(scheme-0, None)` quadrant returns the
+attacker's bytes. The library only ever sees the blob and the caller's
+`Some/None`; the "should be protected" fact lives entirely in the
+consumer's metadata store. **Anti-downgrade strength therefore equals the
+tamper-resistance of the consumer's protection-status record** — store it
+as integrity-protected, security-critical state (it is one more field
+alongside the addresses/policy the wallet DB must already protect).
+
+**Value rollback is NOT defended.** Restoring an *older valid* scheme-1
+envelope under the *current* password decrypts cleanly. The strict read
+closes the strip/downgrade injection, not value rollback; if
+backup-swap/restore-old is in scope, anchor a monotonic version in
+integrity-protected consumer metadata. Do not mistake the strict read for
+rollback protection.
+
+#### Add / change / remove an object password
+
+`reprotect(service, label, current, new)` does it in one same-slot
+unwrap→rewrap→overwrite: read under the `current` expectation (so a strip
+is caught before any rewrite), then write under `new` — `None`→`Some` adds,
+`Some`→`Some` changes, `Some`→`None` removes. An absent object returns
+`Err(SecretStoreError::NoEntry)` — `reprotect` is operational, so absence
+means the caller's protection-status record disagrees with the backend and
+must not be silently dropped. The rewrite is a same-slot overwrite — atomic on the file arm,
+and on the OS arm inheriting the backend's single-item-replace contract —
+so a crash between the read and the commit leaves the prior value intact
+and readable under `current`. **After a successful call the consumer MUST
+update its own protection-status record** (the protection expectation lives
+there). There is **no password recovery** — losing an object password
+bricks that object (an availability trade-off the UX must state plainly).
+
+#### Entropy policy is the consumer's
+
+The library enforces only **non-blank** at enrol (and a coarse
+`MIN_PASSPHRASE_LEN` floor, `1` today = merely non-blank) for both the
+vault passphrase and the Tier-2 object password. It ships **no**
+password-strength estimator: real entropy policy (zxcvbn-style strength,
+dictionary checks, UX feedback) is locale- and threat-specific and is the
+**consumer's responsibility**. For a protected object the password's
+entropy is the *whole* guarantee against an offline Argon2id attacker who
+already holds the backend — choose it accordingly.
+
+#### Greenfield only — no legacy tolerance
+
+The envelope is the only on-disk Tier-2 format this build understands.
+A decrypted entry that does not bincode-decode to a valid `Envelope`
+under `WIRE_CONFIG` (including trailing-byte extension probes) surfaces
+as `Corruption` on every read — there is no magic-byte peek and no
+magic-less raw legacy path. The shipped wire layer is the source of
+truth; older non-enveloped stored values are out of scope.
+
### Internal SPI
Below `SecretStore`, `EncryptedFileStore` and `default_credential_store`
@@ -118,6 +339,29 @@ unwrapped copy is allocated.
One file, one passphrase, one lock — a multi-wallet
store cannot lock its other wallets out by construction. Errors
surface as the typed `SecretStoreError` through `SecretStore`.
+ On Unix the vault's parent directory must not be group/other writable
+ (`mode & 0o022`): directory write access governs rename/replace of the
+ vault, so a writable parent is refused at `open` with
+ `SecretStoreError::InsecureParentDir` (the A1 guarantee depends on it).
+ A read-only group-accessible parent (`0o750`) is accepted — it only
+ leaks filenames, never the 0600-protected vault contents.
+ Each secret is capped at `MAX_SECRET_LEN` (64 KiB) at the write
+ boundary — generously above any mnemonic/seed/xpriv — so a single
+ oversized entry cannot inflate the shared document past the read-side
+ 128 MiB ceiling and brick every wallet on the next open. (Through
+ `SecretStore::set_secret`/`set` the user-facing plaintext cap is the
+ slightly lower `MAX_PLAINTEXT_LEN`, leaving room for the envelope
+ overhead; see **Two-tier secret protection**.)
+ **Blank passphrase is rejected.** `open` (and `rekey`) refuse a blank
+ (empty / all-whitespace) passphrase with `SecretStoreError::BlankPassphrase`
+ — a blank passphrase derives a key from a public salt only, i.e.
+ obfuscation, not confidentiality. This is an **intended behavioural
+ break** for any caller that relied on `SecretString::empty()`. A
+ deliberate keyless vault uses the explicit
+ `EncryptedFileStore::open_unprotected(path)` /
+ `SecretStore::file_unprotected(path)` door instead (use it only where the
+ stored secrets carry their own Tier-2 object password, or as a staging
+ step before `rekey` to a real passphrase — the empty→real migration).
- **OS keyring (`SecretStore::os` / `default_credential_store`)** —
returns an `Arc` over the
platform's default credential store. The backend on Linux/FreeBSD is
@@ -135,6 +379,18 @@ unwrapped copy is allocated.
with `NoDefaultStore`. Callers that need durable storage on a
headless host should pin `SecretStore::file(...)` (encrypted-file
vault) instead of relying on the OS keyring.
+
+ **Enumerable metadata (OS arm).** Each entry is keyed by
+ `service = SERVICE_PREFIX + hex(wallet_id)` and `user = label`, stored
+ as **plaintext, enumerable** keyring metadata: same-user list-only
+ tooling can see which wallet ids exist and which slot kinds (labels)
+ each has, without unlocking any secret. This is dominated by the
+ already-accepted same-user (A2/A3) residual. The `keyring-core` 1.0.0
+ `build` modifiers are vendor-specific creation hints, not a replacement
+ for the `(service, user)` identity, so there is no portable knob to
+ redact the pair; operators who need metadata hiding should use the file
+ vault, whose `(wallet_id, label)` map lives only inside the sealed
+ vault. Prefer non-descriptive labels on the OS arm regardless.
- **Tests** — integration tests construct a tempdir-backed
`EncryptedFileStore` directly via
`EncryptedFileStore::open(tempfile::tempdir()?.path().join("vault.pwsvault"), SecretString::new("..."))`,
@@ -150,25 +406,52 @@ automatic fallback between backends.
`SecretStore` returns the typed `SecretStoreError`. For the file arm this
is **lossless**: `WrongPassphrase`, `Corruption`, `AlreadyLocked`,
`KdfFailure`, `VersionUnsupported`, `MalformedVault`, `InsecurePermissions`,
-`VaultTooLarge`, and `InvalidLabel` are distinct typed variants
-(`VaultTooLarge` surfaces when the on-disk vault exceeds the 128 MiB
-ceiling). For the OS arm,
+`InsecureParentDir`, `SecretTooLarge`, `VaultTooLarge`, `Encrypt`, and
+`InvalidLabel` are distinct typed variants. The Tier-2 layer adds five more:
+`ExpectedProtectedButUnsealed` (the fail-closed strip refusal),
+`NeedsPassword` (a protected object read with no password), `WrongPassword`
+(object-password tag fail — distinct from the Tier-1 `WrongPassphrase`),
+`BlankPassphrase` (a blank vault passphrase or object password), and
+`UnsupportedEnvelopeVersion { found }` (a future envelope format, fail
+closed regardless of the password). The four Tier-2 credential/protection
+*state* variants project to a recoverable `NoStorageAccess` (boxed,
+downcast-recoverable, like `WrongPassphrase`); `UnsupportedEnvelopeVersion`
+joins the secret-free `BadStoreFormat` group. `VaultTooLarge` surfaces when
+the on-disk vault exceeds the read-side ceiling; `SecretTooLarge` rejects an
+oversized secret at the write boundary before it can inflate the shared
+vault; `InsecureParentDir` refuses a vault whose parent directory is
+group/other-writable (a writable parent governs rename/replace despite the
+file's own `0600`); `Encrypt` is the (effectively unreachable) AEAD
+encrypt-side failure, kept typed so a write failure is never mislabeled a
+key-derivation error. For the OS arm,
`keyring_core::Error` projects best-effort into
`SecretStoreError::OsKeyring { kind: OsKeyringErrorKind }`, a payload-free
discriminant — keyring variants carrying raw bytes (`BadEncoding`,
`BadDataFormat`) are collapsed so their bytes never enter the error
(CWE-209/CWE-532).
+**`WrongPassword` on the OS arm is ambiguous.** A Tier-2 envelope AEAD tag
+failure surfaces as `WrongPassword`, but on the OS-keyring arm the stored
+item is the bare envelope with no second authentication layer, so a tag
+failure can mean EITHER a wrong object password OR a corrupted keychain
+item — one AEAD tag cannot disambiguate the two. Treat `WrongPassword` on
+the OS arm as "wrong password or corrupted item." On the file arm it is
+unambiguous: the vault's own per-entry tag has already authenticated the
+stored bytes before the envelope is parsed.
+
The internal SPI projection `From for
keyring_core::Error` keeps the `WrongPassphrase` / `AlreadyLocked` variants
recoverable: they ride in `NoStorageAccess` with the typed
`SecretStoreError` boxed as the source, so an SPI-only consumer can recover
them via `err.source().and_then(|s| s.downcast_ref::())`.
The `BadStoreFormat` group (`Corruption`, `KdfFailure`,
-`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`,
-`VaultTooLarge`, `Decrypt`, `OsKeyring`) has no box slot and carries only a
-secret-free string; those remain fully typed on the `SecretStore` path
-(so `VaultTooLarge` is not losslessly recoverable through the SPI downcast).
+`VersionUnsupported`, `UnsupportedEnvelopeVersion`, `MalformedVault`,
+`InsecurePermissions`, `InsecureParentDir`, `SecretTooLarge`,
+`VaultTooLarge`, `Decrypt`, `Encrypt`, `OsKeyring`) has no box slot and
+carries only a secret-free
+string; those remain fully typed on the `SecretStore` path (so e.g.
+`VaultTooLarge` / `SecretTooLarge` are not losslessly recoverable through
+the SPI downcast).
`keyring_core::Error` is safe to `Display` (`{ }`-format), but
`{:?}`-format embeds `BadEncoding(Vec)` / `BadDataFormat(Vec, _)`
diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs
index 59a6e45eaea..45d56406182 100644
--- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs
+++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs
@@ -7,15 +7,17 @@
//!
//! Per-wallet tables carry `wallet_id BLOB` in (or as all of) their
//! primary key plus a native `FOREIGN KEY (wallet_id) REFERENCES
-//! wallet_metadata(wallet_id) ON DELETE CASCADE`. Identity-owned
-//! tables (`identity_keys`, `dashpay_profiles`,
-//! `dashpay_payments_overlay`, `token_balances`) are keyed by
-//! `identity_id` only; their FK targets `identities(identity_id)` so
-//! cascade flows `wallet_metadata → identities → child` through the
-//! nullable `identities.wallet_id` link. `identities.wallet_id` is
-//! NULL-allowed so identity-only flows (no parent wallet, e.g. the
-//! identity-sync manager populating rows before any wallet is
-//! registered) work without a placeholder.
+//! wallets(wallet_id) ON DELETE CASCADE`. Identity-owned
+//! tables (`dashpay_profiles`, `dashpay_payments_overlay`,
+//! `token_balances`) are keyed by `identity_id` only; their FK targets
+//! `identities(identity_id)` so cascade flows `wallets →
+//! identities → child` through the nullable `identities.wallet_id`
+//! link. `identity_keys` additionally carries its own `wallet_id`
+//! column (so per-wallet reads stay a direct `WHERE wallet_id = ?`)
+//! and keeps the `identity_id` FK for the identity-delete cascade.
+//! `identities.wallet_id` is NULL-allowed so identity-only flows (no
+//! parent wallet, e.g. the identity-sync manager populating rows
+//! before any wallet is registered) work without a placeholder.
//!
//! The one relationship that stays a trigger is
//! `core_utxos.spent_in_txid` clearing to NULL on transaction delete —
@@ -27,10 +29,10 @@
//! read back) at every connection open via `open_conn`
//! (`src/sqlite/conn.rs`).
//!
-//! Enum-shaped TEXT columns (`network`, `account_type`, `pool_type`,
-//! `status`, `state`) carry a `CHECK (col IN (...))` clause whose
+//! Enum-shaped TEXT columns (`network`, `account_type`, `status`,
+//! `state`) carry a `CHECK (col IN (...))` clause whose
//! IN-list is built from the `*_LABELS` const arrays in
-//! `crate::sqlite::schema::{wallet_meta, accounts, asset_locks,
+//! `crate::sqlite::schema::{wallets, accounts, asset_locks,
//! contacts}`. The consts are the single source of truth shared with
//! the writer mapping functions; the per-module `*_labels_match_enum`
//! unit tests enforce set-equality between each const and its writer's
@@ -46,18 +48,25 @@ fn build_check_in(labels: &[&str]) -> String {
}
pub fn migration() -> String {
- let network_check = build_check_in(crate::sqlite::schema::wallet_meta::NETWORK_LABELS);
+ let network_check = build_check_in(crate::sqlite::schema::wallets::NETWORK_LABELS);
let account_type_check =
build_check_in(crate::sqlite::schema::accounts::ACCOUNT_TYPE_LABELS);
- let pool_type_check = build_check_in(crate::sqlite::schema::accounts::POOL_TYPE_LABELS);
let asset_lock_status_check =
build_check_in(crate::sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS);
let contact_state_check =
build_check_in(crate::sqlite::schema::contacts::CONTACT_STATE_LABELS);
+ // Stamp the header `application_id` so a foreign refinery-versioned
+ // SQLite DB can be told apart from a wallet-storage DB (asserted in
+ // `open()` pre-migration and in `restore_from`'s staged validation).
+ // Splice the constant in decimal — `PRAGMA` takes no bound params.
+ let application_id = crate::sqlite::conn::APPLICATION_ID;
+
format!(
"\
-CREATE TABLE wallet_metadata (
+PRAGMA application_id = {application_id};
+
+CREATE TABLE wallets (
wallet_id BLOB NOT NULL PRIMARY KEY,
network TEXT NOT NULL CHECK (network IN {network_check}),
birth_height INTEGER NOT NULL
@@ -69,17 +78,7 @@ CREATE TABLE account_registrations (
account_index INTEGER NOT NULL,
account_xpub_bytes BLOB NOT NULL,
PRIMARY KEY (wallet_id, account_type, account_index),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
-);
-
-CREATE TABLE account_address_pools (
- wallet_id BLOB NOT NULL,
- account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}),
- account_index INTEGER NOT NULL,
- pool_type TEXT NOT NULL CHECK (pool_type IN {pool_type_check}),
- snapshot_blob BLOB NOT NULL,
- PRIMARY KEY (wallet_id, account_type, account_index, pool_type),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE TABLE core_transactions (
@@ -91,7 +90,7 @@ CREATE TABLE core_transactions (
finalized INTEGER NOT NULL,
record_blob BLOB NOT NULL,
PRIMARY KEY (wallet_id, txid),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE INDEX idx_core_transactions_height ON core_transactions(wallet_id, height);
@@ -106,7 +105,7 @@ CREATE TABLE core_utxos (
spent INTEGER NOT NULL,
spent_in_txid BLOB,
PRIMARY KEY (wallet_id, outpoint),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE INDEX idx_core_utxos_spent ON core_utxos(wallet_id, spent);
@@ -130,50 +129,46 @@ CREATE TABLE core_instant_locks (
txid BLOB NOT NULL,
islock_blob BLOB NOT NULL,
PRIMARY KEY (wallet_id, txid),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
-CREATE TABLE core_derived_addresses (
- wallet_id BLOB NOT NULL,
- account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}),
- account_index INTEGER NOT NULL,
- address TEXT NOT NULL,
- derivation_path TEXT NOT NULL,
- used INTEGER NOT NULL,
- PRIMARY KEY (wallet_id, account_type, address),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
-);
-
-CREATE INDEX idx_core_derived_addresses_addr ON core_derived_addresses(wallet_id, address);
-
CREATE TABLE core_sync_state (
wallet_id BLOB NOT NULL PRIMARY KEY,
last_processed_height INTEGER,
synced_height INTEGER,
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ -- Bincode-encoded `dashcore::ephemerealdata::chain_lock::ChainLock`.
+ -- NULL until the first ChainLock has been applied and flushed.
+ last_applied_chain_lock BLOB,
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE TABLE identities (
identity_id BLOB NOT NULL PRIMARY KEY,
wallet_id BLOB,
- wallet_index INTEGER,
+ identity_index INTEGER,
entry_blob BLOB NOT NULL,
tombstoned INTEGER NOT NULL,
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE INDEX idx_identities_wallet ON identities(wallet_id);
CREATE TABLE identity_keys (
+ wallet_id BLOB NOT NULL,
identity_id BLOB NOT NULL,
key_id INTEGER NOT NULL,
public_key_blob BLOB NOT NULL,
public_key_hash BLOB NOT NULL,
- PRIMARY KEY (identity_id, key_id),
+ -- Reserved for a future typed projection; always NULL today.
+ -- derivation_indices lives inside public_key_blob (the
+ -- IdentityKeyWire blob is the single source of truth).
+ derivation_blob BLOB,
+ PRIMARY KEY (wallet_id, identity_id, key_id),
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE,
FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE
);
-CREATE INDEX idx_identity_keys_identity ON identity_keys(identity_id);
+CREATE INDEX idx_identity_keys_wallet_identity ON identity_keys(wallet_id, identity_id);
CREATE TABLE contacts (
wallet_id BLOB NOT NULL,
@@ -188,7 +183,7 @@ CREATE TABLE contacts (
accepted_accounts BLOB,
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (wallet_id, owner_id, contact_id),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE TABLE platform_addresses (
@@ -199,7 +194,7 @@ CREATE TABLE platform_addresses (
balance INTEGER NOT NULL,
nonce INTEGER NOT NULL,
PRIMARY KEY (wallet_id, address),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE TABLE platform_address_sync (
@@ -207,7 +202,7 @@ CREATE TABLE platform_address_sync (
sync_height INTEGER NOT NULL,
sync_timestamp INTEGER NOT NULL,
last_known_recent_block INTEGER NOT NULL,
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE TABLE asset_locks (
@@ -219,7 +214,7 @@ CREATE TABLE asset_locks (
amount_duffs INTEGER NOT NULL,
lifecycle_blob BLOB NOT NULL,
PRIMARY KEY (wallet_id, outpoint),
- FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
+ FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
CREATE TABLE token_balances (
@@ -316,7 +311,7 @@ CREATE TABLE meta_platform_address (
-- Soft-cascade cleanup: drop a scope's metadata when its parent object
-- is deleted. SQLite fires these for parents removed by an FK cascade
--- too (e.g. wallet_metadata delete → identities cascade → identity
+-- too (e.g. wallets delete → identities cascade → identity
-- trigger), so deleting a wallet cleans its metadata transitively.
--
-- Two root brooms key on the deleted parent's id alone so they reach
@@ -330,7 +325,7 @@ CREATE TABLE meta_platform_address (
-- row, parentless included. Keys on wallet_id only, so contact state and
-- whether the typed parent ever existed are both irrelevant.
CREATE TRIGGER cascade_meta_on_wallet_delete
-AFTER DELETE ON wallet_metadata
+AFTER DELETE ON wallets
FOR EACH ROW
BEGIN
DELETE FROM meta_wallet WHERE wallet_id = OLD.wallet_id;
diff --git a/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs
index 17ff3d0ba52..698447fe1c9 100644
--- a/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs
+++ b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs
@@ -149,11 +149,8 @@ impl CliError {
fn run(cli: Cli) -> Result {
let auto_backup_dir: Option = cli.auto_backup_dir;
- // `prune` is a pure filesystem op against the backups directory —
- // `--db` is meaningless for it and must not be required. Handle the
- // subcommand BEFORE extracting `cli.db` so the operator can run
- // `prune --backups-dir ... --keep-last N` without
- // also passing a database path.
+ // `prune` is a pure filesystem op; `--db` is meaningless, so handle it
+ // before requiring `cli.db`.
if let Cmd::Prune(args) = &cli.cmd {
return run_prune(args);
}
@@ -167,11 +164,8 @@ fn run(cli: Cli) -> Result {
return run_restore(&db, args, auto_backup_dir.as_deref());
}
- // For `migrate --no-auto-backup`, we must keep `auto_backup_dir =
- // None` so the open-time pre-migration backup is skipped. For
- // every other subcommand we leave the user-configured dir (or the
- // default) in place — the library's safe-by-default semantics
- // still apply.
+ // `migrate --no-auto-backup` clears `auto_backup_dir` so the open-time
+ // pre-migration backup is skipped; other subcommands keep the default.
let mut config = SqlitePersisterConfig::new(&db);
if let Some(dir) = auto_backup_dir.clone() {
config = config.with_auto_backup_dir(Some(dir));
@@ -183,10 +177,8 @@ fn run(cli: Cli) -> Result {
}
}
- // Migrate (idempotent): open performs it. We capture the prior
- // schema version so we can print "applied: N". A transient read
- // failure must surface — silently reading 0 would print a wrong
- // `applied:` count.
+ // Migrate is done by `open`; capture pre/post versions to print
+ // "applied: N". A read failure must surface, not be read as 0.
if let Cmd::Migrate(_) = &cli.cmd {
let pre_version = peek_schema_version(&db).map_err(|e| CliError::runtime(e.to_string()))?;
let _persister = SqlitePersister::open(config.clone()).map_err(map_open_err_for_cli)?;
@@ -228,21 +220,16 @@ fn map_open_err_for_cli(err: WalletStorageError) -> CliError {
/// transient failure for "version 0".
fn peek_schema_version(db: &Path) -> Result, rusqlite::Error> {
use rusqlite::{OpenFlags, OptionalExtension};
- // Open READ-ONLY (no SQLITE_OPEN_CREATE) so a typo'd --db path errors
- // out at this gate rather than silently materialising a zero-byte
- // SQLite file that bypasses the crate's 0o600 invariant. A genuinely
- // fresh `migrate` invocation against a non-existent DB file is normal
- // — surface that as `Ok(None)` so the migrate path proceeds and
- // `SqlitePersister::open` creates the file under the 0o600 invariant.
+ // A missing path is a normal fresh `migrate`: `Ok(None)` lets
+ // `SqlitePersister::open` create the file under the 0o600 invariant,
+ // instead of materialising a stub here that bypasses it.
if !db.exists() {
return Ok(None);
}
- let conn = rusqlite::Connection::open_with_flags(
- db,
- OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
- )?;
- // Pre-migration the history table may not exist yet — that is a
- // legitimate "no version" answer, not a failure.
+ // READ-ONLY, URI parsing off (matches the open-conn choke-point) so a
+ // `--db` path can't smuggle `file:` query params defeating read-only.
+ let conn = rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
+ // Pre-migration the history table may legitimately not exist.
let has_history = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'",
@@ -266,8 +253,7 @@ fn peek_schema_version(db: &Path) -> Result , rusqlite::Error> {
}
fn run_backup(persister: &SqlitePersister, args: BackupArgs) -> Result {
- // `backup_to` is the single authority on refuse-to-overwrite — it
- // returns `BackupDestinationExists` for a pre-existing file path.
+ // `backup_to` owns refuse-to-overwrite (`BackupDestinationExists`).
let path = persister.backup_to(&args.out).map_err(|e| match e {
WalletStorageError::BackupDestinationExists { path } => CliError::runtime(format!(
"backup destination exists and refuses to overwrite: {}",
@@ -294,9 +280,8 @@ fn run_restore(
eprintln!("warning: auto-backup skipped (--no-auto-backup)");
SqlitePersister::restore_from_skip_backup(db, &args.from)
} else {
- // CLI default mirrors the persister config default
- // (`/backups/auto/`). The CLI doesn't open a
- // persister here, so we compute the default inline.
+ // No persister is opened here, so compute the config default
+ // (`/backups/auto/`) inline.
let resolved_dir: PathBuf = match auto_backup_dir {
None => default_auto_backup_dir(db),
Some(p) => p.to_path_buf(),
@@ -350,10 +335,8 @@ fn run_prune(args: &PruneArgs) -> Result {
mod tests {
use super::*;
- /// `peek_schema_version` on a non-existent path must NOT materialise
- /// a zero-byte SQLite file at that path — opening READ-ONLY (no
- /// SQLITE_OPEN_CREATE) keeps a typo from being rewarded with a stub
- /// file lacking the crate's 0o600 mode invariant.
+ /// `peek_schema_version` on a missing path must not materialise a stub
+ /// file (opening READ-ONLY) that would lack the 0o600 invariant.
#[test]
fn peek_schema_version_on_missing_db_does_not_create_stub() {
let tmp = tempfile::tempdir().expect("tempdir");
diff --git a/packages/rs-platform-wallet-storage/src/kv.rs b/packages/rs-platform-wallet-storage/src/kv.rs
index 8dd1e3d22a1..ddfadbfcb2d 100644
--- a/packages/rs-platform-wallet-storage/src/kv.rs
+++ b/packages/rs-platform-wallet-storage/src/kv.rs
@@ -10,19 +10,15 @@
//! serialization (bincode, JSON, protobuf, raw bytes). Keys are
//! bounded `TEXT` (1..=128 chars).
//!
-//! Scoping: each [`ObjectId`] variant addresses a dedicated table. The
-//! [`ObjectId::Global`] slot has no parent and survives wallet deletion.
-//! Every other variant names a wallet object, but a write does NOT
-//! require that object to exist yet — metadata may be attached ahead of
-//! sync. When the object is later deleted, an `AFTER DELETE` trigger on
-//! its parent table removes the matching metadata. However, if the
-//! parent object is never created, or is removed via a path the trigger
-//! does not cover, the metadata row may persist as an orphan. This is an
-//! accepted limitation across all scopes; a future garbage-collection pass
-//! is expected to reap such orphans (no live parent, e.g. older than ~1
-//! week) — callers should not rely on orphan metadata persisting forever.
-//! The same key string under different scopes is independent — the scopes
-//! live in separate tables.
+//! Scoping: each [`ObjectId`] variant addresses a dedicated table, so the
+//! same key string under different scopes is independent.
+//! [`ObjectId::Global`] has no parent and survives wallet deletion. Other
+//! variants name a wallet object but a write does NOT require it to exist
+//! yet (metadata may be attached ahead of sync); an `AFTER DELETE` trigger
+//! reaps the metadata when the object is deleted. Rows whose parent is
+//! never created, or removed via a path the trigger misses, may persist as
+//! orphans — an accepted limitation; a future GC pass is expected to reap
+//! them, so callers must not rely on orphans living forever.
//!
//! This API is **independent of [`platform_wallet::changeset::PlatformWalletPersistence`]**:
//! KV is for app metadata, not wallet domain state. Reads and writes go
@@ -33,16 +29,10 @@ use platform_wallet::wallet::platform_wallet::WalletId;
/// Scope of a metadata entry — one variant per dedicated `meta_*` table.
///
-/// [`ObjectId::Global`] has no parent and survives wallet deletion. The
-/// other variants name a wallet object but carry no insert-time
-/// existence requirement: metadata may be written before its parent
-/// object is synced into its typed table. An `AFTER DELETE` trigger on
-/// each parent removes the matching metadata when the object is deleted.
-///
-/// **Orphan metadata:** if the parent object is never created, or is
-/// removed via a path the trigger does not cover, the metadata row may
-/// persist as an orphan. A future GC pass is expected to reap such
-/// rows; do not rely on them living forever.
+/// [`ObjectId::Global`] has no parent and survives wallet deletion. Other
+/// variants name a wallet object but may be written before it is synced;
+/// an `AFTER DELETE` trigger reaps the metadata when the object is deleted.
+/// See the module docs for the orphan-metadata limitation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObjectId {
/// Global app metadata; no parent (`meta_global`).
@@ -69,25 +59,17 @@ pub enum ObjectId {
},
}
-/// Maximum allowed key length. Enforced in Rust as a **byte**-length
-/// bound (`validate_key` rejects with `KeyTooLong`/`KeyEmpty` on
-/// `key.len()`) and in SQL as a **code-point** bound
-/// (`CHECK (length(key) BETWEEN 1 AND 128)`, where SQLite's `length()`
-/// counts UTF-8 code points). For ASCII keys the two coincide; for
-/// non-ASCII keys the Rust byte bound is the stricter of the two, so no
-/// over-length key reaches SQL.
+/// Maximum allowed key length, in **code points**. `validate_key` counts
+/// `chars().count()`; the SQL `CHECK (length(key) BETWEEN 1 AND 128)` uses
+/// the same unit (SQLite `length()` counts code points), so the two bounds
+/// accept exactly the same key set.
pub const MAX_KEY_LEN: usize = 128;
/// Hard cap on the size of a single KV value, in bytes, so a tampered or
/// corrupted backup row cannot force a multi-gigabyte allocation on the
-/// next `get`.
-///
-/// Kept in sync MANUALLY with the `BLOB_SIZE_LIMIT_BYTES` ceiling on
-/// bincode-serde blobs in `sqlite::schema::blob`: the `sqlite` and `kv`
-/// features compile independently, so a `const`-level cross-reference
-/// between the two modules can't be relied on. Change both together if
-/// the ceiling moves.
-pub const MAX_VALUE_LEN: usize = 16 * 1024 * 1024;
+/// next `get`. Shares the crate-root [`SIZE_LIMIT_BYTES`](crate::SIZE_LIMIT_BYTES)
+/// ceiling with the bincode-serde BLOB decode cap.
+pub const MAX_VALUE_LEN: usize = crate::SIZE_LIMIT_BYTES;
/// Errors returned by [`KvStore`] operations.
///
@@ -99,14 +81,14 @@ pub enum KvError {
#[error("kv key is empty")]
KeyEmpty,
- /// Key exceeded [`MAX_KEY_LEN`].
- #[error("kv key too long: {len} bytes (max {})", MAX_KEY_LEN)]
+ /// Key exceeded [`MAX_KEY_LEN`]. `len` is the key's code-point count
+ /// (the same unit the SQL `length()` CHECK uses).
+ #[error("kv key too long: {len} code points (max {})", MAX_KEY_LEN)]
KeyTooLong { len: usize },
/// A value exceeded [`MAX_VALUE_LEN`]. Raised by `put` before the
- /// INSERT and by `get` before the bytes are materialised, so an
- /// oversize value never lands and a tampered row never OOMs the
- /// process.
+ /// INSERT and by `get` before materialising, so a tampered row can't
+ /// OOM the process.
#[error("kv value too large: {found} bytes (max {max})")]
ValueTooLarge { found: usize, max: usize },
@@ -124,6 +106,15 @@ pub enum KvError {
///
/// See the module-level docs for scoping and value semantics. Each
/// [`ObjectId`] variant addresses a dedicated table.
+///
+/// # Security
+///
+/// Values are stored **PLAINTEXT** in the persister `.db` and in every
+/// backup copied from it. This API is the explicit, caller-policed
+/// plaintext exception to the crate's no-secrets-in-the-db boundary
+/// (see `SECRETS.md`). **NEVER store key or signing material here** —
+/// mnemonics, seeds, private keys, or anything that could move funds.
+/// Use [`SecretStore`](crate::secrets::SecretStore) for secret material.
pub trait KvStore {
/// Read the value bound to `(scope, key)`. Returns `Ok(None)` when
/// the key is absent. Backends MUST reject values larger than
@@ -140,6 +131,12 @@ pub trait KvStore {
/// Backends MUST reject a `value` larger than [`MAX_VALUE_LEN`] with
/// [`KvError::ValueTooLarge`] before writing, so a `put` can never
/// plant a row a later `get` would refuse to materialise.
+ ///
+ /// # Security
+ ///
+ /// `value` is stored **PLAINTEXT** in the `.db` and all backups.
+ /// NEVER store key/signing material here — use
+ /// [`SecretStore`](crate::secrets::SecretStore).
fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError>;
/// Remove the row bound to `(scope, key)`. Idempotent — a missing
@@ -156,14 +153,15 @@ pub trait KvStore {
fn list_keys(&self, scope: &ObjectId, prefix: Option<&str>) -> Result, KvError>;
}
-/// Validate a key against the length bounds. Used by [`KvStore`]
-/// implementations as a typed-error pre-check before reaching SQL.
+/// Typed-error pre-check used by [`KvStore`] impls before reaching SQL.
+/// Counts code points to match the SQL CHECK unit (see [`MAX_KEY_LEN`]).
pub(crate) fn validate_key(key: &str) -> Result<(), KvError> {
if key.is_empty() {
return Err(KvError::KeyEmpty);
}
- if key.len() > MAX_KEY_LEN {
- return Err(KvError::KeyTooLong { len: key.len() });
+ let code_points = key.chars().count();
+ if code_points > MAX_KEY_LEN {
+ return Err(KvError::KeyTooLong { len: code_points });
}
Ok(())
}
diff --git a/packages/rs-platform-wallet-storage/src/lib.rs b/packages/rs-platform-wallet-storage/src/lib.rs
index b75ddee4658..dbfb05b9c9a 100644
--- a/packages/rs-platform-wallet-storage/src/lib.rs
+++ b/packages/rs-platform-wallet-storage/src/lib.rs
@@ -27,6 +27,13 @@
#![deny(rust_2018_idioms)]
#![deny(unsafe_code)]
+/// Shared 16 MiB ceiling for the two independent size caps in this crate:
+/// the KV value cap ([`kv::MAX_VALUE_LEN`]) and the bincode-serde BLOB
+/// decode cap (`sqlite::schema::blob::BLOB_SIZE_LIMIT_BYTES`). At the crate
+/// root so the independently-compiled `kv` and `sqlite` features share one
+/// source of truth.
+pub const SIZE_LIMIT_BYTES: usize = 16 * 1024 * 1024;
+
#[cfg(feature = "kv")]
pub mod kv;
#[cfg(feature = "sqlite")]
@@ -35,10 +42,8 @@ pub mod sqlite;
#[cfg(feature = "secrets")]
pub mod secrets;
-// Convenience re-exports kept under the crate root so embedders don't
-// have to spell out the `::sqlite::` middle segment for the common
-// names. Adding to or trimming from this list does NOT count as a
-// breaking change of the submodule API.
+// Convenience re-exports so embedders can skip the `::sqlite::` segment
+// for common names.
#[cfg(feature = "kv")]
pub use kv::{KvError, KvStore, ObjectId};
#[cfg(feature = "sqlite")]
@@ -48,9 +53,8 @@ pub use sqlite::{
WalletStorageError,
};
-// Compile-time assertions — `Send + Sync`, `PlatformWalletPersistence`
-// object-safety, and the no-boxed-trait-object error policy.
-// Lint-gated to the SQLite feature because they reference its types.
+// Compile-time assertions: `Send + Sync` and `PlatformWalletPersistence`
+// object-safety. Gated to `sqlite` because they reference its types.
#[cfg(feature = "sqlite")]
#[allow(dead_code)]
const fn _send_sync_check() {}
@@ -67,9 +71,8 @@ fn _object_safety_check(persister: SqlitePersister) {
std::sync::Arc::new(persister);
}
-// The keyring SPI must be object-safe and its error `Send + Sync`, so
-// a backend can be held behind `Arc` and its errors crossed between threads / FFI.
+// The keyring SPI must be object-safe with `Send + Sync` errors so a
+// backend can live behind `Arc`.
#[cfg(feature = "secrets")]
#[allow(dead_code)]
const fn _secrets_send_sync_check() {}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs
index 506814cd49f..7057946714a 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/error.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs
@@ -1,24 +1,10 @@
//! Secret-store error taxonomy and its `keyring_core::Error` projection.
//!
-//! One concrete `thiserror` enum shared by both
-//! [`SecretStore`](crate::secrets::SecretStore) backends (the encrypted
-//! file vault and the OS keyring), no `#[non_exhaustive]`, **no** secret
-//! byte, passphrase, plaintext, or stringified source that could carry
-//! one in any variant. `#[error]` strings are static + structural; only
-//! non-secret diagnostics (POSIX mode bits, header version int, vault
-//! path) are carried as typed fields (CWE-209/CWE-532).
-//!
-//! The `EncryptedFileStore` surfaces this enum at its construction /
-//! `rekey` API; its `keyring_core::api::CredentialApi` /
-//! `CredentialStoreApi` impls project it into `keyring_core::Error` via
-//! [`From`] so SPI callers see a uniform error. The `WrongPassphrase` /
-//! `AlreadyLocked` variants box the typed `SecretStoreError` as the
-//! `NoStorageAccess` source, so an SPI consumer can recover them
-//! losslessly via `source().downcast_ref::()`; the
-//! `BadStoreFormat` group has no box slot and carries only a secret-free
-//! string. Either way, the fully typed path is the public
-//! [`SecretStore`](crate::secrets::SecretStore) API, which returns
-//! `SecretStoreError` directly.
+//! Variants carry only non-secret diagnostics (POSIX mode bits, header
+//! version, vault path) — never a secret byte, passphrase, plaintext, or
+//! stringified source (CWE-209/CWE-532). The public, fully-typed path is
+//! the [`SecretStore`](crate::secrets::SecretStore) API; the SPI
+//! projection into `keyring_core::Error` is lossy (see the [`From`] impl).
use std::path::Path;
@@ -34,16 +20,52 @@ pub enum SecretStoreError {
#[error("wrong passphrase")]
WrongPassphrase,
- /// AEAD tag failure on a stored entry (or a rekey re-encrypt) *after*
- /// the header verify-token already passed: the entry ciphertext is
- /// corrupt or tampered, **not** a wrong passphrase. Carries no
- /// plaintext (CWE-347).
+ /// Tier-2 strip/downgrade guard: the caller asserted — by supplying
+ /// an object password — that this object MUST be password-protected,
+ /// but the stored value is a well-formed UNPROTECTED envelope
+ /// (scheme-0), i.e. a strip/downgrade. **Fails closed:** the stored
+ /// bytes are NEVER returned (CWE-757/CWE-345).
+ #[error("expected a password-protected secret but the stored value is unprotected")]
+ ExpectedProtectedButUnsealed,
+
+ /// Tier-2: a valid password-protected (scheme-1) envelope was read
+ /// with NO object password supplied. Never returns ciphertext.
+ #[error("secret is password-protected; a password is required")]
+ NeedsPassword,
+
+ /// Tier-2: the object password failed the envelope's AEAD tag. Carries
+ /// **no** plaintext and no source (CWE-347). Distinct from
+ /// [`WrongPassphrase`] (the Tier-1 vault passphrase). On the
+ /// [`SecretStore::Os`] arm a tag failure may also indicate keychain
+ /// corruption rather than a wrong password — documented in
+ /// `SECRETS.md`; one AEAD tag cannot disambiguate the two.
+ ///
+ /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase
+ /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os
+ #[error("wrong object password")]
+ WrongPassword,
+
+ /// A vault passphrase (Tier-1 `open`/`rekey`) or an object password
+ /// (Tier-2 enrol/unwrap) was blank — empty or all-whitespace — rejected
+ /// via [`SecretString::is_blank`]. CWE-521.
+ ///
+ /// Neutral wording: the variant covers both Tier-1 vault passphrases and
+ /// Tier-2 per-object passwords; the caller's context determines which.
+ /// Tier-1 callers wanting a deliberately keyless vault should use
+ /// [`EncryptedFileStore::open_unprotected`](crate::secrets::EncryptedFileStore::open_unprotected).
+ ///
+ /// [`SecretString::is_blank`]: crate::secrets::SecretString::is_blank
+ #[error("passphrase or password must not be blank")]
+ BlankPassphrase,
+
+ /// AEAD tag failure on a stored entry (or rekey re-encrypt) *after*
+ /// the header verify-token passed: the entry ciphertext is corrupt or
+ /// tampered, **not** a wrong passphrase. No plaintext (CWE-347).
#[error("vault entry failed integrity check (corruption or tampering)")]
Corruption,
- /// Argon2 key derivation failed. The upstream error carries no
- /// useful non-secret diagnostic, so it is intentionally not
- /// embedded.
+ /// Argon2 key derivation failed. The upstream error carries no useful
+ /// non-secret diagnostic, so it is not embedded.
#[error("key derivation failed")]
KdfFailure,
@@ -55,6 +77,20 @@ pub enum SecretStoreError {
found: u32,
},
+ /// A Tier-2 secret envelope decoded with a `version` this build does
+ /// not understand. Fails closed REGARDLESS of the password argument
+ /// — an unparseable future format can be neither safely unwrapped
+ /// nor safely treated as unprotected, so it is refused both ways.
+ /// Mirrors [`VersionUnsupported`] for the vault format.
+ ///
+ /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported
+ #[error("unsupported secret envelope version {found}")]
+ UnsupportedEnvelopeVersion {
+ /// The envelope `version` byte read from the (unauthenticated)
+ /// header.
+ found: u8,
+ },
+
/// The vault file was malformed (bad magic, truncated header, bad
/// record framing) — no plaintext was produced.
#[error("malformed vault file")]
@@ -65,6 +101,17 @@ pub enum SecretStoreError {
#[error("invalid label")]
InvalidLabel,
+ /// No credential exists under `(service, label)` on either arm. Returned
+ /// by mutators that need an entry to operate on (e.g. [`reprotect`]) so
+ /// absence is a signal, not a silent no-op — caller's protection-status
+ /// record disagreeing with the backend must not be swallowed. Surfaced
+ /// by the file arm when `delete_bytes` reports `Ok(false)` and by the
+ /// OS arm when [`keyring_core::Error::NoEntry`] bubbles out.
+ ///
+ /// [`reprotect`]: crate::secrets::SecretStore::reprotect
+ #[error("no entry under (service, label)")]
+ NoEntry,
+
/// A pre-existing vault file had permissions looser than `0600`.
/// Refuse rather than tighten-and-trust.
#[error("vault file has insecure permissions")]
@@ -73,13 +120,35 @@ pub enum SecretStoreError {
mode: u32,
},
- /// The vault sidecar (`.lock`) is already held by
- /// another `EncryptedFileStore` handle — in this process or in
- /// another process. The resident-vault model requires exclusive
- /// ownership of the vault file for the store's lifetime, so the
- /// second `open()` fails fast (no retry, no wait budget). Drop the
- /// other handle, or wait for the other process to exit, and retry.
- /// A recoverable runtime state, not a logic bug.
+ /// The vault file's parent directory was group/other WRITABLE
+ /// (`mode & 0o022 != 0`). Directory write governs rename/unlink, so a
+ /// writable parent lets another local user swap the vault despite its
+ /// own `0600`. Read-only group access (`0o750`) is fine — it leaks
+ /// filenames, not the 0600-protected contents.
+ #[error("vault parent directory has insecure permissions")]
+ InsecureParentDir {
+ /// The offending POSIX mode bits on the parent directory (not
+ /// secret).
+ mode: u32,
+ },
+
+ /// A secret offered for storage exceeded the per-secret write cap
+ /// ([`MAX_SECRET_LEN`](crate::secrets::MAX_SECRET_LEN)). Rejected at
+ /// the write boundary so an oversized entry never inflates the shared
+ /// vault past the read-side ceiling and bricks every wallet on reopen.
+ #[error("secret exceeds maximum size of {max} bytes (got {found})")]
+ SecretTooLarge {
+ /// The offered secret length (bytes).
+ found: usize,
+ /// The compiled-in per-secret ceiling (bytes).
+ max: usize,
+ },
+
+ /// The vault sidecar (`.lock`) is already held by another
+ /// `EncryptedFileStore` handle in this or another process. The
+ /// resident-vault model needs exclusive ownership for the store's
+ /// lifetime, so a second `open()` fails fast (no retry). Recoverable:
+ /// drop the other handle and retry.
#[error("vault is already locked by another store handle")]
AlreadyLocked,
@@ -95,28 +164,35 @@ pub enum SecretStoreError {
max: u64,
},
- /// Internal AEAD tag failure with no vault context yet attached. The
- /// crypto seam (`crypto::open`) cannot tell *why* a tag failed, so it
- /// returns this; callers translate it to [`WrongPassphrase`] (in the
- /// verify-token context) or [`Corruption`] (in an entry context).
- /// Never escapes to the SPI / public surface.
+ /// Internal AEAD tag failure with no vault context attached:
+ /// `crypto::open` cannot tell *why* a tag failed, so callers translate
+ /// this to [`WrongPassphrase`] (verify-token context) or
+ /// [`Corruption`] (entry context). Never escapes to the SPI surface.
///
/// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase
/// [`Corruption`]: SecretStoreError::Corruption
#[error("decryption/integrity check failed")]
Decrypt,
+ /// AEAD encrypt-side failure (cipher construction or `encrypt`).
+ /// Effectively unreachable — the key is always 32 bytes and plaintext
+ /// never approaches XChaCha20's ~256 GiB limit — but kept typed so a
+ /// write failure is never mislabeled a [`KdfFailure`].
+ ///
+ /// [`KdfFailure`]: SecretStoreError::KdfFailure
+ #[error("encryption failed")]
+ Encrypt,
+
/// Filesystem error (open / write / rename / fsync). The inner
- /// [`IoError`] carries an OS code and, when the failing operation
- /// knew it, the *non-secret* path it was operating on — a
- /// caller-supplied filesystem path, never a secret byte.
+ /// [`IoError`] carries an OS code and, when known, the *non-secret*
+ /// caller-supplied path — never a secret byte.
#[error("{0}")]
Io(#[from] IoError),
- /// An OS-keyring backend (the [`SecretStore::Os`] arm) failure,
- /// projected to a non-secret discriminant. Keyring variants that
- /// carry raw bytes (`BadEncoding`, `BadDataFormat`) are collapsed to
- /// [`OsKeyringErrorKind::BadStoreFormat`] — their bytes never enter
+ /// An OS-keyring backend ([`SecretStore::Os`] arm) failure, projected
+ /// to a non-secret discriminant. Byte-bearing keyring variants
+ /// (`BadEncoding`, `BadDataFormat`) collapse to
+ /// [`OsKeyringErrorKind::BadStoreFormat`]; their bytes never enter
/// this type (CWE-209/CWE-532).
///
/// [`SecretStore::Os`]: crate::secrets::SecretStore::Os
@@ -128,11 +204,9 @@ pub enum SecretStoreError {
}
impl SecretStoreError {
- /// Build an [`Io`](SecretStoreError::Io) error that names the
- /// non-secret filesystem `path` the failing operation touched.
- /// Use at the vault read / write / lock seams where the path is
- /// known; the bare `?`/`From` conversion (path
- /// unknown) stays available for the deep helpers.
+ /// Build an [`Io`](SecretStoreError::Io) error naming the non-secret
+ /// `path` the failing operation touched. Use at the read/write/lock
+ /// seams; deep helpers can still use the bare `?` (path unknown).
pub(crate) fn io_at(path: &Path, source: std::io::Error) -> Self {
Self::Io(IoError {
path: Some(path.to_path_buf()),
@@ -142,14 +216,12 @@ impl SecretStoreError {
}
/// Filesystem-error payload for [`SecretStoreError::Io`]. Wraps the OS
-/// [`std::io::Error`] and, when the failing operation knew it, the
-/// non-secret path it was operating on. `From` is
-/// derived so a bare `?` still works (path defaults to `None`); the
-/// path-aware seams attach it via [`SecretStoreError::io_at`].
+/// [`std::io::Error`] plus the non-secret path, when known. A bare `?`
+/// works (path `None`); path-aware seams use [`SecretStoreError::io_at`].
#[derive(Debug, thiserror::Error)]
pub struct IoError {
- /// The non-secret filesystem path, when the failing operation knew
- /// it. A caller-supplied path, never a secret.
+ /// The non-secret caller-supplied path, when the failing operation
+ /// knew it.
pub path: Option,
/// The underlying OS error.
pub source: std::io::Error,
@@ -171,14 +243,12 @@ impl From for IoError {
}
/// Non-secret discriminant for an OS-keyring backend failure, projected
-/// from `keyring_core::Error` for the [`SecretStore::Os`] arm. Carries no
-/// payload, so no secret byte, path, or attribute value can ride along.
+/// from `keyring_core::Error` for the [`SecretStore::Os`] arm. Payload-
+/// less, so no secret byte / path / attribute value can ride along.
///
/// [`SecretStore::Os`]: crate::secrets::SecretStore::Os
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsKeyringErrorKind {
- /// `keyring_core::Error::NoEntry`.
- NoEntry,
/// `keyring_core::Error::NoStorageAccess` (store locked / inaccessible).
NoStorageAccess,
/// `keyring_core::Error::NoDefaultStore` (no reachable backend).
@@ -194,7 +264,6 @@ pub enum OsKeyringErrorKind {
impl std::fmt::Display for OsKeyringErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
- Self::NoEntry => "no entry",
Self::NoStorageAccess => "storage inaccessible",
Self::NoDefaultStore => "no default store",
Self::BadStoreFormat => "bad store format",
@@ -210,60 +279,71 @@ impl From for SecretStoreError {
}
}
-/// Bare `?` on a [`std::io::Error`] inside a function returning
-/// [`SecretStoreError`] threads through [`IoError`] (path `None`); the
-/// path-aware seams call [`SecretStoreError::io_at`] instead.
+/// Bare `?` on an [`std::io::Error`] threads through [`IoError`] with
+/// path `None`; path-aware seams call [`SecretStoreError::io_at`].
impl From for SecretStoreError {
fn from(source: std::io::Error) -> Self {
Self::Io(IoError::from(source))
}
}
-/// Project a [`SecretStoreError`] into `keyring_core::Error` for the
-/// `CredentialApi` / `CredentialStoreApi` SPI seam.
+/// Project a [`SecretStoreError`] into `keyring_core::Error` for the SPI
+/// seam. Lossy by design — the lossless typed path is the
+/// [`SecretStore`](crate::secrets::SecretStore) API.
///
-/// - [`WrongPassphrase`] and [`AlreadyLocked`] ride in
-/// [`KeyringError::NoStorageAccess`] (operator UX: "ask the operator to
-/// unlock / retry") with the typed `SecretStoreError` boxed as the
-/// source, so an SPI consumer can losslessly recover the variant via
+/// - [`WrongPassphrase`] / [`AlreadyLocked`] and the Tier-2 credential /
+/// protection states ([`NeedsPassword`], [`WrongPassword`],
+/// [`ExpectedProtectedButUnsealed`], [`BlankPassphrase`]) ride in
+/// [`KeyringError::NoStorageAccess`] with the typed error boxed as the
+/// source, recoverable via
/// `err.source().and_then(|s| s.downcast_ref::())`.
-/// - [`Corruption`], [`KdfFailure`], [`VersionUnsupported`],
-/// [`MalformedVault`], [`InsecurePermissions`], the internal
-/// [`Decrypt`], and [`OsKeyring`] collapse into
-/// [`KeyringError::BadStoreFormat`], whose `String` payload has no box
-/// slot, so they carry only a static secret-free string (never secret
-/// data in a format error). They remain losslessly typed on the
-/// [`SecretStore`](crate::secrets::SecretStore) path.
-/// - [`InvalidLabel`] becomes `KeyringError::Invalid("user", _)`.
-/// - [`Io`] becomes [`KeyringError::PlatformFailure`].
+/// These are all "the caller must act on a credential/expectation to
+/// proceed" states, so lossless recovery lets an SPI consumer react
+/// precisely.
+/// - The format/crypto group — including [`UnsupportedEnvelopeVersion`]
+/// (a fail-closed forward-format incompatibility, mirroring
+/// [`VersionUnsupported`]) — collapses into
+/// [`KeyringError::BadStoreFormat`] (a static secret-free string — that
+/// variant has no box slot).
+/// - [`InvalidLabel`] → `KeyringError::Invalid("user", _)`;
+/// [`Io`] → [`KeyringError::PlatformFailure`].
///
/// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase
/// [`AlreadyLocked`]: SecretStoreError::AlreadyLocked
-/// [`Corruption`]: SecretStoreError::Corruption
-/// [`KdfFailure`]: SecretStoreError::KdfFailure
+/// [`NeedsPassword`]: SecretStoreError::NeedsPassword
+/// [`WrongPassword`]: SecretStoreError::WrongPassword
+/// [`ExpectedProtectedButUnsealed`]: SecretStoreError::ExpectedProtectedButUnsealed
+/// [`BlankPassphrase`]: SecretStoreError::BlankPassphrase
+/// [`UnsupportedEnvelopeVersion`]: SecretStoreError::UnsupportedEnvelopeVersion
/// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported
-/// [`MalformedVault`]: SecretStoreError::MalformedVault
-/// [`InsecurePermissions`]: SecretStoreError::InsecurePermissions
-/// [`Decrypt`]: SecretStoreError::Decrypt
-/// [`OsKeyring`]: SecretStoreError::OsKeyring
/// [`InvalidLabel`]: SecretStoreError::InvalidLabel
/// [`Io`]: SecretStoreError::Io
impl From for KeyringError {
fn from(e: SecretStoreError) -> Self {
use SecretStoreError as E;
match e {
- E::WrongPassphrase | E::AlreadyLocked => KeyringError::NoStorageAccess(Box::new(e)),
+ E::WrongPassphrase
+ | E::AlreadyLocked
+ | E::NeedsPassword
+ | E::WrongPassword
+ | E::ExpectedProtectedButUnsealed
+ | E::BlankPassphrase => KeyringError::NoStorageAccess(Box::new(e)),
E::Corruption
| E::KdfFailure
| E::VersionUnsupported { .. }
+ | E::UnsupportedEnvelopeVersion { .. }
| E::MalformedVault
| E::InsecurePermissions { .. }
+ | E::InsecureParentDir { .. }
+ | E::SecretTooLarge { .. }
| E::VaultTooLarge { .. }
| E::Decrypt
+ | E::Encrypt
| E::OsKeyring { .. } => KeyringError::BadStoreFormat(e.to_string()),
E::InvalidLabel => {
KeyringError::Invalid("user".to_string(), "label allowlist violation".to_string())
}
+ E::NoEntry => KeyringError::NoEntry,
E::Io(io) => KeyringError::PlatformFailure(Box::new(io.source)),
}
}
@@ -289,10 +369,20 @@ mod tests {
for e in [
SecretStoreError::Corruption,
SecretStoreError::Decrypt,
+ SecretStoreError::Encrypt,
SecretStoreError::KdfFailure,
SecretStoreError::VersionUnsupported { found: 999 },
SecretStoreError::MalformedVault,
SecretStoreError::InsecurePermissions { mode: 0o644 },
+ SecretStoreError::InsecureParentDir { mode: 0o777 },
+ SecretStoreError::SecretTooLarge {
+ found: 100,
+ max: 10,
+ },
+ SecretStoreError::VaultTooLarge {
+ found: 100,
+ max: 10,
+ },
] {
let k: KeyringError = e.into();
assert!(matches!(k, KeyringError::BadStoreFormat(_)));
@@ -316,9 +406,6 @@ mod tests {
#[test]
fn io_at_names_path_in_display_without_leaking_secret() {
- // The path-aware Io error renders the offending path so operators
- // can see which file failed; the source message rides along, but
- // no secret byte does (the path is caller-supplied).
let err = SecretStoreError::io_at(
std::path::Path::new("/var/lib/wallet/vault.pwsvault"),
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
@@ -351,9 +438,6 @@ mod tests {
#[test]
fn wrong_passphrase_is_recoverable_from_no_storage_access_source() {
- // WrongPassphrase / AlreadyLocked box the typed SecretStoreError
- // as the NoStorageAccess source, so an SPI consumer recovers the
- // variant losslessly via `source().downcast_ref::()`.
use std::error::Error as _;
for original in [
SecretStoreError::WrongPassphrase,
@@ -382,6 +466,102 @@ mod tests {
assert!(!format!("{k}").contains("plaintext"));
}
+ /// The five new variants exist, are constructable, render
+ /// distinct non-empty messages, and the Tier-2 `WrongPassword` is NOT
+ /// the Tier-1 `WrongPassphrase` (nor is the unseal error `Corruption`).
+ #[test]
+ fn new_variants_exist_and_are_distinct() {
+ use SecretStoreError as E;
+ assert_ne!(E::WrongPassword.to_string(), E::WrongPassphrase.to_string());
+ assert_ne!(
+ E::ExpectedProtectedButUnsealed.to_string(),
+ E::Corruption.to_string()
+ );
+ let msgs: std::collections::HashSet = [
+ E::NeedsPassword.to_string(),
+ E::WrongPassword.to_string(),
+ E::BlankPassphrase.to_string(),
+ E::ExpectedProtectedButUnsealed.to_string(),
+ E::UnsupportedEnvelopeVersion { found: 2 }.to_string(),
+ ]
+ .into_iter()
+ .collect();
+ assert_eq!(msgs.len(), 5, "all five messages must be distinct");
+ }
+
+ /// Display + Debug render static, secret-free text. The
+ /// version variant surfaces the (non-secret) version byte and nothing
+ /// more.
+ #[test]
+ fn new_variants_carry_no_secret_in_display() {
+ use SecretStoreError as E;
+ assert_eq!(
+ E::NeedsPassword.to_string(),
+ "secret is password-protected; a password is required"
+ );
+ assert_eq!(E::WrongPassword.to_string(), "wrong object password");
+ assert_eq!(
+ E::BlankPassphrase.to_string(),
+ "passphrase or password must not be blank"
+ );
+ assert_eq!(
+ E::ExpectedProtectedButUnsealed.to_string(),
+ "expected a password-protected secret but the stored value is unprotected"
+ );
+ assert_eq!(
+ E::UnsupportedEnvelopeVersion { found: 7 }.to_string(),
+ "unsupported secret envelope version 7"
+ );
+ // Debug is non-empty and free of plaintext-ish tokens for all.
+ for e in [
+ E::NeedsPassword,
+ E::WrongPassword,
+ E::BlankPassphrase,
+ E::ExpectedProtectedButUnsealed,
+ E::UnsupportedEnvelopeVersion { found: 7 },
+ ] {
+ let rendered = format!("{e} {e:?}");
+ assert!(!rendered.contains("plaintext"));
+ }
+ }
+
+ /// The four Tier-2 credential /
+ /// protection states project to a recoverable `NoStorageAccess` with
+ /// the typed error losslessly downcast-able, leaking no secret.
+ #[test]
+ fn tier2_state_errors_project_to_recoverable_no_storage_access() {
+ for original in [
+ SecretStoreError::NeedsPassword,
+ SecretStoreError::WrongPassword,
+ SecretStoreError::ExpectedProtectedButUnsealed,
+ SecretStoreError::BlankPassphrase,
+ ] {
+ let want = original.to_string();
+ let k: KeyringError = original.into();
+ assert!(!format!("{k}").contains("plaintext"));
+ match &k {
+ KeyringError::NoStorageAccess(src) => {
+ let recovered = src.downcast_ref::();
+ assert!(
+ matches!(recovered, Some(e) if e.to_string() == want),
+ "expected recoverable {want}, got {recovered:?}"
+ );
+ }
+ other => panic!("expected NoStorageAccess for {want}, got {other:?}"),
+ }
+ }
+ }
+
+ /// `UnsupportedEnvelopeVersion` projects to the
+ /// secret-free `BadStoreFormat` group (forward-format incompat,
+ /// mirroring `VersionUnsupported`).
+ #[test]
+ fn unsupported_envelope_version_projects_to_bad_store_format() {
+ let k: KeyringError = SecretStoreError::UnsupportedEnvelopeVersion { found: 9 }.into();
+ assert!(matches!(k, KeyringError::BadStoreFormat(_)));
+ assert!(!format!("{k}").contains("plaintext"));
+ }
+
#[test]
fn os_keyring_projects_to_bad_store_format() {
let k: KeyringError = SecretStoreError::OsKeyring {
diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs
index 3205db672f5..bbfb6b26420 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs
@@ -19,11 +19,10 @@ pub(crate) const ARGON2_MIN_T: u32 = 2;
pub(crate) const ARGON2_P: u32 = 1;
/// Argon2 parameter ceilings. Vault `kdf` params are attacker-
-/// controllable JSON, so an oversized `m_kib`/`t` would let a crafted
-/// vault force a multi-GiB allocation or an unbounded-time derivation (a
-/// DoS) before any tag check. 1 GiB memory and 16 passes bound the cost
-/// well above the shipped default (64 MiB, t=3) yet far below an
-/// exhaustion threshold.
+/// controllable JSON, so without a cap an oversized `m_kib`/`t` could
+/// force a multi-GiB allocation or unbounded derivation (DoS) before any
+/// tag check. 1 GiB / 16 passes is well above the default, far below
+/// exhaustion.
pub(crate) const ARGON2_MAX_M_KIB: u32 = 1_048_576;
pub(crate) const ARGON2_MAX_T: u32 = 16;
@@ -43,11 +42,10 @@ pub(crate) fn random_bytes(buf: &mut [u8]) -> Result<(), SecretStoreError> {
getrandom(buf).map_err(|_| SecretStoreError::KdfFailure)
}
-/// Argon2id parameters as stored in / read from the vault. Serializes
-/// directly to the on-disk `kdf` object — `id` discriminates the KDF
-/// algorithm (only [`KDF_ID_ARGON2ID`] is accepted today), validated
-/// alongside the parameter ranges in [`KdfParams::enforce_bounds`].
-/// `deny_unknown_fields` fails closed on a stray sibling (C3).
+/// Argon2id parameters stored in the on-disk `kdf` object. `id`
+/// discriminates the algorithm (only [`KDF_ID_ARGON2ID`] today),
+/// validated with the parameter ranges in [`KdfParams::enforce_bounds`].
+/// `deny_unknown_fields` fails closed on a stray sibling.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct KdfParams {
@@ -68,13 +66,11 @@ impl KdfParams {
}
}
- /// Reject params outside the accepted bounds before any derivation
- /// or allocation runs. The lower bound refuses a downgraded vault;
- /// the upper bound refuses an inflated vault from an
- /// attacker-controllable JSON file that would otherwise force a
- /// huge allocation / unbounded derivation ahead of any tag check.
- /// An unknown algorithm `id` is also a bounds failure — Argon2id is
- /// the only KDF family this version supports.
+ /// Reject out-of-bounds params before any derivation/allocation: the
+ /// lower bound refuses a downgraded vault, the upper bound an inflated
+ /// one (huge allocation / unbounded derivation ahead of any tag
+ /// check). An unknown algorithm `id` also fails — Argon2id is the only
+ /// supported family.
pub(crate) fn enforce_bounds(&self) -> Result<(), SecretStoreError> {
if self.id != KDF_ID_ARGON2ID
|| self.m_kib < ARGON2_MIN_M_KIB
@@ -89,20 +85,20 @@ impl KdfParams {
}
}
-/// Derive a 32-byte AEAD key from `passphrase` + `salt` with Argon2id.
-/// Output lands directly in a [`SecretBytes`].
+/// Derive a 32-byte AEAD key from `passphrase` + `salt` with Argon2id,
+/// landing directly in a [`SecretBytes`]. Takes `&SecretString` so the
+/// bare-byte passphrase view lives only inside this function.
///
-/// Takes `&SecretString` directly so the bare-byte view of the
-/// passphrase lives only inside this function — callers can no
-/// longer accidentally hand a `&[u8]` (e.g. by holding a stray
-/// `expose_secret().as_bytes()` longer than intended) into KDF input.
+/// Zeroization residual: argon2 0.5.3's `zeroize` feature wipes
+/// `initial_hash` / `blockhash` but NOT the bulk `Block` matrix (up to
+/// `m_kib` of derived state). Accepted residual against A5 (swap /
+/// core-dump while unlocked); closing it needs an upstream fix.
pub(crate) fn derive_key(
passphrase: &SecretString,
- salt: &[u8],
+ salt: &[u8; SALT_LEN],
params: KdfParams,
) -> Result {
- // Bounds MUST gate before Params::new / hash_password_into so an
- // inflated m_kib never reaches the allocator.
+ // Bounds MUST gate first so an inflated m_kib never reaches the allocator.
params.enforce_bounds()?;
let argon_params = Params::new(params.m_kib, params.t, params.p, Some(KEY_LEN))
.map_err(|_| SecretStoreError::KdfFailure)?;
@@ -126,7 +122,7 @@ pub(crate) fn seal(
plaintext: &[u8],
) -> Result<([u8; NONCE_LEN], Vec), SecretStoreError> {
let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret())
- .map_err(|_| SecretStoreError::KdfFailure)?;
+ .map_err(|_| SecretStoreError::Encrypt)?;
let mut nonce_bytes = [0u8; NONCE_LEN];
random_bytes(&mut nonce_bytes)?;
let nonce = XNonce::from_slice(&nonce_bytes);
@@ -138,11 +134,36 @@ pub(crate) fn seal(
aad,
},
)
- // Encrypt-path failure (XChaCha20-Poly1305 only fails here when
- // the plaintext exceeds the construction's length limit), so it is
- // not a decryption concern; keep it on the same write-oriented
- // variant the cipher-construction failure above uses.
- .map_err(|_| SecretStoreError::KdfFailure)?;
+ // AEAD write-side failure (only when plaintext exceeds the length
+ // limit), not a key-derivation one.
+ .map_err(|_| SecretStoreError::Encrypt)?;
+ Ok((nonce_bytes, ct))
+}
+
+/// Like [`seal`] but takes a caller-supplied `nonce` instead of pulling
+/// from the CSPRNG. **Test-only** — golden-vector / size-budget tests
+/// need byte-deterministic ciphertext output. Production code MUST use
+/// [`seal`] so nonces stay unique (XChaCha20-Poly1305 nonce reuse leaks
+/// the keystream).
+#[cfg(test)]
+pub(crate) fn seal_with_nonce(
+ key: &SecretBytes,
+ nonce_bytes: [u8; NONCE_LEN],
+ aad: &[u8],
+ plaintext: &[u8],
+) -> Result<([u8; NONCE_LEN], Vec), SecretStoreError> {
+ let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret())
+ .map_err(|_| SecretStoreError::Encrypt)?;
+ let nonce = XNonce::from_slice(&nonce_bytes);
+ let ct = cipher
+ .encrypt(
+ nonce,
+ chacha20poly1305::aead::Payload {
+ msg: plaintext,
+ aad,
+ },
+ )
+ .map_err(|_| SecretStoreError::Encrypt)?;
Ok((nonce_bytes, ct))
}
@@ -157,7 +178,7 @@ pub(crate) fn open(
ciphertext: &[u8],
) -> Result {
let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret())
- .map_err(|_| SecretStoreError::KdfFailure)?;
+ .map_err(|_| SecretStoreError::Encrypt)?;
let nonce = XNonce::from_slice(nonce);
let pt = cipher
.decrypt(
@@ -175,6 +196,10 @@ pub(crate) fn open(
mod tests {
use super::*;
+ // Compile-time guard: argon2's `impl Zeroize for Block` is feature-
+ // gated, so this fails to build if `argon2/zeroize` is ever dropped.
+ static_assertions::assert_impl_all!(argon2::Block: zeroize::Zeroize);
+
/// Argon2id floor params — fast enough for unit tests; production
/// runs at the default target (64 MiB).
fn floor_params() -> KdfParams {
@@ -253,10 +278,8 @@ mod tests {
#[test]
fn derive_key_rejects_inflated_m_kib_before_allocating() {
- // u32::MAX m_kib must error fast (enforce_bounds) and never reach
- // the multi-GiB allocator. A real allocation of ~4 TiB would OOM
- // the test, so reaching here at all proves the ceiling fired
- // first.
+ // u32::MAX m_kib must error via enforce_bounds before the ~4 TiB
+ // allocation — which would OOM the test if it ever ran.
let err = derive_key(
&SecretString::new("pw"),
&[0u8; SALT_LEN],
diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs
index d188658dd3b..c137afb2439 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs
@@ -1,10 +1,7 @@
//! Versioned, self-describing vault format + canonical AAD.
//!
-//! The vault is one `serde_json` document covering every wallet in the
-//! store: a single passphrase / salt / KDF block at the top, and a
-//! nested map keyed first by `wallet_id` (lowercase hex) and then by
-//! `label`. One file, one passphrase, one lock — a multi-wallet store
-//! cannot lock its other wallets out by construction.
+//! The vault is one `serde_json` document: a single salt / KDF block at
+//! the top, then a map keyed by `wallet_id` (lowercase hex) and `label`.
//!
//! ```json
//! {
@@ -21,22 +18,18 @@
//! }
//! ```
//!
-//! Entries are nested `BTreeMap`s so lookup is O(log n) and the on-disk
-//! shape excludes duplicate `(wallet_id, label)` pairs by construction
-//! (a JSON object cannot carry two values under the same key).
+//! Nested `BTreeMap`s give O(log n) lookup and a JSON-object shape that
+//! excludes duplicate `(wallet_id, label)` pairs by construction.
//!
//! Parsing is two-step: a lax [`VersionProbe`] reads `version` first
-//! (tolerating future-version sibling fields), then — only for the
-//! compiled-in [`FORMAT_VERSION`] — the strict [`Vault`] payload is
-//! parsed. All byte fields are lowercase hex; Argon2 params are JSON
-//! numbers.
+//! (tolerating future-version siblings), then the strict [`Vault`]
+//! payload is parsed only for the compiled-in [`FORMAT_VERSION`].
//!
-//! KDF params/salt are store-wide. `verify_ct` is an AEAD seal of a
-//! fixed constant under the header-derived key — a wrong passphrase
-//! fails its tag, so a mismatched key is rejected before any entry is
-//! written or read (no mixed-key corruption). The verify-token AAD is
-//! NOT bound to any wallet id (the store is now multi-wallet) so the
-//! token validates the store-wide passphrase exactly once per op.
+//! `verify_ct` is an AEAD seal of a fixed constant under the
+//! header-derived key, so a wrong passphrase fails its tag and a
+//! mismatched key is rejected before any entry is touched (no mixed-key
+//! corruption). The verify-token AAD is not bound to any wallet id, so it
+//! validates the store-wide passphrase once per op.
use std::collections::BTreeMap;
@@ -44,6 +37,9 @@ use serde::{Deserialize, Serialize};
use super::crypto::{KdfParams, NONCE_LEN, SALT_LEN};
use crate::secrets::error::SecretStoreError;
+use crate::secrets::wire::aad::{EntryAad, VerifyAad};
+use crate::secrets::wire::config::{ENTRY_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG};
+use crate::secrets::wire::kdf::KdfParamsEncoded;
pub(crate) const FORMAT_VERSION: u32 = 1;
pub(crate) const KDF_ID_ARGON2ID: u8 = 1;
@@ -53,35 +49,16 @@ pub(crate) const KDF_ID_ARGON2ID: u8 = 1;
/// value itself is not secret.
pub(crate) const VERIFY_CONSTANT: &[u8] = b"PWSVAULT-VERIFY-v1";
-/// AAD slot label for the verification token. The leading NUL keeps it
-/// disjoint from every allowlisted entry label, so the token can never
-/// alias a real entry's AAD.
-pub(crate) const VERIFY_LABEL: &str = "\0verify";
-
-/// Sentinel wallet id used as the verify-token AAD's wallet slot. The
-/// store-wide token is not bound to any real wallet; this 32-byte zero
-/// id keeps the AAD shape identical to entry AAD (same length-prefixed
-/// construction) without aliasing a real wallet's namespace — a real
-/// wallet id `[0u8; 32]` would still produce a different AAD because
-/// the label slot differs ([`VERIFY_LABEL`] vs any allowlisted label).
-const VERIFY_WALLET_ID: [u8; 32] = [0u8; 32];
-
/// Minimum AEAD ciphertext length: the Poly1305 tag is always present
/// even for an empty plaintext, so any `verify_ct`/`ciphertext` shorter
/// than this is structurally impossible and rejected.
const AEAD_TAG_LEN: usize = 16;
-/// The full parsed vault: format `version`, KDF parameters, salt, the
-/// passphrase-verification token, and every wallet's entries.
-/// Serializes directly to the on-disk wire form — `hex_array` validates
-/// `salt`/`verify_nonce` widths at the serde seam, so no parallel
-/// `Vec`-typed wire mirror is needed. Field order matches the
-/// documented schema and `serde_json` preserves it, so the byte layout
-/// is stable.
-///
-/// `deny_unknown_fields` fails closed on a stray sibling for this
-/// compiled-in [`FORMAT_VERSION`] (C3). Forward-compat dispatch on
-/// `version` runs through [`VersionProbe`] before this strict parse.
+/// The full parsed vault, serializing directly to the on-disk wire form.
+/// `hex_array` validates fixed-width fields at the serde seam, and
+/// `serde_json` preserves field order, so the byte layout is stable.
+/// `deny_unknown_fields` fails closed on a stray sibling; forward-compat
+/// dispatch runs through [`VersionProbe`] before this strict parse.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Vault {
@@ -99,12 +76,9 @@ pub(crate) struct Vault {
pub wallets: BTreeMap>,
}
-/// One decrypted-on-demand vault entry body. The owning
-/// `Vault.wallets[wallet]` `BTreeMap` keys this by `label`, so the
-/// label is the map key — not a field — and the on-disk shape can't
-/// carry two entries under the same label. `hex_array` validates
-/// `nonce`'s fixed width at parse; `deny_unknown_fields` fails closed
-/// on a stray sibling (C3).
+/// One vault entry body, keyed by `label` in the owning `BTreeMap` (so
+/// the label is the map key, not a field). `hex_array` validates `nonce`'s
+/// width at parse; `deny_unknown_fields` fails closed on a stray sibling.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct EntryBody {
@@ -114,42 +88,49 @@ pub(crate) struct EntryBody {
pub ciphertext: Vec,
}
-/// Canonical length-prefixed AAD binding ciphertext to its slot:
-/// `format_version ‖ wallet_id ‖ label`. A blob moved to another slot,
-/// or a rolled-back `format_version`, fails the tag.
+/// Canonical AAD binding a vault entry's ciphertext to its slot:
+/// `domain ‖ format_version ‖ wallet_id ‖ label`, bincode-encoded
+/// against [`WIRE_CONFIG`]. A blob moved to another slot, or one
+/// version-rolled-back, fails the tag.
///
-/// AAD-DETERMINISM INVARIANT (C1): AAD is built solely from the typed
-/// `(format_version, wallet_id, label)` triple via this length-prefixed
-/// layout — never from any serialized JSON bytes or JSON key order. The
-/// `format_version` argument is always the compiled-in [`FORMAT_VERSION`]
-/// constant at every call site; the JSON `version` field is used ONLY as
-/// the two-step dispatch gate and is NEVER routed into AAD.
+/// Determinism invariant: AAD is built solely from this typed triple,
+/// never from serialized JSON bytes or key order. `format_version` is
+/// always the compiled-in [`FORMAT_VERSION`]; the JSON `version` field
+/// is a dispatch gate only and is never routed into AAD.
pub(crate) fn aad(format_version: u32, wallet_id: &[u8; 32], label: &str) -> Vec {
- let lb = label.as_bytes();
- let mut v = Vec::with_capacity(4 + 4 + 32 + 4 + lb.len());
- v.extend_from_slice(&format_version.to_le_bytes());
- v.extend_from_slice(&(wallet_id.len() as u32).to_le_bytes());
- v.extend_from_slice(wallet_id);
- v.extend_from_slice(&(lb.len() as u32).to_le_bytes());
- v.extend_from_slice(lb);
- v
+ bincode::encode_to_vec(
+ EntryAad {
+ domain: ENTRY_DOMAIN_V2,
+ format_version,
+ wallet_id: *wallet_id,
+ label,
+ },
+ WIRE_CONFIG,
+ )
+ .expect("EntryAad encode is infallible")
}
-/// AAD for the passphrase-verification token. Uses the same canonical
-/// construction as entry AAD but with a sentinel zero wallet id and
-/// [`VERIFY_LABEL`] (NUL-prefixed, disjoint from every allowlisted
-/// label) so the token is cryptographically tied to this
-/// `format_version` only and cannot be replayed into any real entry
-/// slot.
-pub(crate) fn verify_aad(format_version: u32) -> Vec {
- aad(format_version, &VERIFY_WALLET_ID, VERIFY_LABEL)
+/// AAD for the verify-token: bincode-encoded `VerifyAad` binding the
+/// vault-wide salt + KDF header against the verify domain tag. A
+/// tampered header yields a different AAD AND a different derived key,
+/// so the token surfaces `WrongPassphrase`.
+pub(crate) fn verify_aad(format_version: u32, salt: &[u8; SALT_LEN], kdf: &KdfParams) -> Vec {
+ bincode::encode_to_vec(
+ VerifyAad {
+ domain: VERIFY_DOMAIN_V2,
+ format_version,
+ salt: *salt,
+ kdf: KdfParamsEncoded::from(*kdf),
+ },
+ WIRE_CONFIG,
+ )
+ .expect("VerifyAad encode is infallible")
}
-/// Serde helpers encoding `Vec` as lowercase hex strings. Hex is
-/// already a crate dependency (`WalletId::to_hex`), is deterministic and
-/// self-validating, and avoids adding `base64`. The encoding sits wholly
-/// outside the AEAD envelope and the AAD (C1), so it has no bearing on
-/// any cryptographic binding.
+/// Serde helpers encoding `Vec` as lowercase hex. Hex is already a
+/// crate dependency, deterministic, and avoids adding `base64`. The
+/// encoding sits outside the AEAD envelope and the AAD, so it has no
+/// cryptographic bearing.
mod hex_bytes {
use serde::{Deserialize, Deserializer, Serializer};
@@ -163,12 +144,10 @@ mod hex_bytes {
}
}
-/// Const-generic companion to [`hex_bytes`] for fixed-width byte fields.
-/// Wire form is identical (lowercase hex), but the `[u8; N]` deserialize
-/// target moves length validation into the serde seam — a wrong-length
-/// hex blob is rejected at parse with a `serde::de::Error` naming both
-/// the offending size and the expected `N`, so the field is identifiable
-/// in the error message (no anonymous "invalid length").
+/// Const-generic companion to [`hex_bytes`] for fixed-width fields. The
+/// `[u8; N]` target moves length validation into the serde seam: a
+/// wrong-length blob is rejected at parse with an error naming the
+/// offending size and the expected `N`.
pub(super) mod hex_array {
use serde::{de::Error as DeError, Deserialize, Deserializer, Serializer};
@@ -201,33 +180,32 @@ pub(super) mod hex_array {
}
}
-/// Step-1 probe: read ONLY `version`, tolerating unknown sibling fields
-/// so a future v-N file can be dispatched on before its payload shape is
-/// committed to. MUST NOT use `deny_unknown_fields` (C3).
+/// Step-1 probe: read ONLY `version`, tolerating unknown siblings so a
+/// future vN file can be dispatched on. MUST NOT use `deny_unknown_fields`.
#[derive(Deserialize)]
struct VersionProbe {
version: u32,
}
-/// Serialize a full vault to JSON bytes. Contains only salt/params
-/// (non-secret) + ciphertext — never plaintext.
+/// Serialize a vault to JSON bytes — salt/params + ciphertext only, never
+/// plaintext.
pub(crate) fn serialize(vault: &Vault) -> Vec {
- // Vault carries only fixed-width arrays and owned Vecs that serialize
- // infallibly; a serializer error would be a logic bug.
+ // Vault holds only fixed arrays and owned Vecs; serialization is
+ // infallible, so an error would be a logic bug.
serde_json::to_vec(vault).expect("vault serialization is infallible")
}
-/// Parse a vault. Two-step: probe `version` (lax), then parse the strict
-/// payload for the known version. Refuses unknown versions and any
-/// malformed/short byte field — fail closed. Unknown KDF
-/// algorithm ids and out-of-range Argon2 params are caught later at
-/// `KdfParams::enforce_bounds` (called on every `derive_key`), so they
-/// can't silently slip past. All `serde_json` errors are mapped to a
-/// static [`SecretStoreError`] with the source DISCARDED so input bytes
-/// can never leak into an error string or log. Salt and nonce widths
-/// are validated by `hex_array` at the serde seam; the AEAD-tag-length
-/// floor remains a post-parse check.
+/// Parse a vault: probe `version` (lax), then parse the strict payload
+/// for the known version. Fails closed on unknown versions and malformed
+/// fields. `serde_json` errors are mapped to a static
+/// [`SecretStoreError`] with the source DISCARDED so input bytes never
+/// leak. Unknown KDF ids / out-of-range Argon2 params are caught later at
+/// `KdfParams::enforce_bounds`.
pub(crate) fn deserialize(buf: &[u8]) -> Result {
+ // INTENTIONAL: the 2x parse (probe + strict) over the 128MiB-capped,
+ // lock-gated local file is accepted for forward-version dispatch.
+ // INTENTIONAL: relies on serde_json's default recursion limit (128)
+ // for deep-nesting DoS safety — MUST NOT disable it or use from_reader.
let probe: VersionProbe =
serde_json::from_slice(buf).map_err(|_| SecretStoreError::MalformedVault)?;
if probe.version != FORMAT_VERSION {
@@ -242,12 +220,9 @@ pub(crate) fn deserialize(buf: &[u8]) -> Result {
return Err(SecretStoreError::MalformedVault);
}
- // Validate outer wallet-id keys and inner label keys at parse time.
- // The serde shape allows any string for either key, so
- // a malformed file (or a tampered one) could otherwise smuggle a
- // bogus wallet id past parse and surface only at the first `put` /
- // `get` / `delete`. Reject the whole vault on the first offender so
- // a single bad key fails the file open, not a downstream op.
+ // Validate wallet-id and label keys at parse: the serde shape allows
+ // any string, so a bogus key would otherwise surface only at the
+ // first put/get/delete. Reject the whole vault on the first offender.
for (wallet_hex, entries) in &vault.wallets {
super::decode_wallet_id_hex(wallet_hex)?;
for (label, body) in entries {
@@ -266,33 +241,6 @@ pub(crate) fn deserialize(buf: &[u8]) -> Result {
mod tests {
use super::*;
- #[test]
- fn aad_binds_slot() {
- let w = [1u8; 32];
- assert_ne!(aad(1, &w, "a"), aad(1, &w, "b"));
- assert_ne!(aad(1, &w, "a"), aad(2, &w, "a"));
- assert_ne!(aad(1, &w, "a"), aad(1, &[2u8; 32], "a"));
- // Length-prefix defeats `"a"+"bc"` vs `"ab"+"c"` ambiguity.
- assert_ne!(aad(1, &w, "ab"), {
- let mut v = aad(1, &w, "a");
- v.extend_from_slice(b"b");
- v
- });
- }
-
- #[test]
- fn verify_aad_disjoint_from_every_entry_aad() {
- // The verify-token's slot is `(VERIFY_WALLET_ID, VERIFY_LABEL)`.
- // VERIFY_LABEL starts with NUL, which the allowlist forbids, so
- // no real entry's AAD can collide with the token's AAD — even
- // if a caller happens to register the all-zero wallet id.
- let v = verify_aad(FORMAT_VERSION);
- // A real entry on the same sentinel wallet id can never match
- // because its label cannot contain NUL.
- assert_ne!(v, aad(FORMAT_VERSION, &VERIFY_WALLET_ID, "seed"));
- assert_ne!(v, aad(FORMAT_VERSION, &[1u8; 32], "seed"));
- }
-
fn test_vault(wallets: BTreeMap>) -> Vault {
Vault {
version: FORMAT_VERSION,
@@ -366,9 +314,8 @@ mod tests {
#[test]
fn deserialize_accepts_unknown_kdf_id_and_bounds_check_rejects_later() {
- // Unknown algo ids ride through parse so the algorithm gate
- // lives in one place — `KdfParams::enforce_bounds`, called on
- // every `derive_key`. The format layer no longer guards it.
+ // Unknown algo ids ride through parse; the gate lives solely at
+ // `KdfParams::enforce_bounds` (called on every `derive_key`).
let mut vault = test_vault(BTreeMap::new());
vault.kdf.id = 7;
let bytes = serialize(&vault);
@@ -618,4 +565,146 @@ mod tests {
"error leaked input bytes: {rendered}"
);
}
+
+ /// A parse of mutated bytes must be a clean `Ok` or a typed error
+ /// variant — never a panic / abort.
+ fn assert_deserialize_outcome_is_typed(bytes: &[u8]) {
+ let res = std::panic::catch_unwind(|| deserialize(bytes));
+ let parsed = res.expect("deserialize must never panic on hostile input");
+ match parsed {
+ Ok(_)
+ | Err(SecretStoreError::MalformedVault)
+ | Err(SecretStoreError::VersionUnsupported { .. })
+ | Err(SecretStoreError::InvalidLabel) => {}
+ Err(other) => panic!("unexpected error variant from parser: {other:?}"),
+ }
+ }
+
+ /// Deterministic byte-level fuzz: flip bytes and truncate at every
+ /// offset of a valid vault, asserting the parser stays fail-closed and
+ /// never panics. Fixed seed, no proptest dependency.
+ #[test]
+ fn parser_is_fuzz_resistant_to_byte_mutation() {
+ let mut entries = BTreeMap::new();
+ entries.insert(
+ "bip39_mnemonic".to_string(),
+ EntryBody {
+ nonce: [0x33; NONCE_LEN],
+ ciphertext: vec![0x44; AEAD_TAG_LEN + 16],
+ },
+ );
+ let mut wallets = BTreeMap::new();
+ wallets.insert(hex::encode([0xABu8; 32]), entries);
+ let valid = serialize(&test_vault(wallets));
+
+ // The pristine vault parses.
+ assert!(deserialize(&valid).is_ok());
+
+ // xorshift32 — deterministic, std-only.
+ let mut state: u32 = 0x1234_5678;
+ let mut next = || {
+ state ^= state << 13;
+ state ^= state >> 17;
+ state ^= state << 5;
+ state
+ };
+
+ for _ in 0..2_000 {
+ let mut buf = valid.clone();
+ // Flip 1..=4 random bytes.
+ let flips = 1 + (next() % 4) as usize;
+ for _ in 0..flips {
+ let idx = (next() as usize) % buf.len();
+ buf[idx] ^= (next() & 0xFF) as u8;
+ }
+ assert_deserialize_outcome_is_typed(&buf);
+ }
+
+ // Truncation at every offset — a short read must never panic.
+ for cut in 0..valid.len() {
+ assert_deserialize_outcome_is_typed(&valid[..cut]);
+ }
+ }
+
+ /// Structural fuzz: hostile shapes a byte-flip rarely hits (oversized
+ /// KDF params, deep nesting, bad labels, wrong-width hex). Each must be
+ /// a typed error or a valid Ok, never a panic. Inflated KDF params
+ /// parse Ok by design (the bounds gate lives at `derive_key`).
+ #[test]
+ fn parser_is_fuzz_resistant_to_structural_mutation() {
+ let base: serde_json::Value =
+ serde_json::from_slice(&serialize(&test_vault(BTreeMap::new()))).unwrap();
+ let wid_owned = hex::encode([1u8; 32]);
+ let wid = wid_owned.as_str();
+ let good_nonce = "0".repeat(NONCE_LEN * 2);
+ let good_ct = "0".repeat((AEAD_TAG_LEN + 1) * 2);
+
+ let mut cases: Vec = Vec::new();
+
+ // Oversized / absurd KDF params.
+ for (k, v) in [
+ ("m_kib", serde_json::json!(u32::MAX)),
+ ("t", serde_json::json!(u32::MAX)),
+ ("p", serde_json::json!(u32::MAX)),
+ ("id", serde_json::json!(255)),
+ ] {
+ let mut c = base.clone();
+ c["kdf"][k] = v;
+ cases.push(c);
+ }
+
+ // Deep nesting in the wallets map (well past the type's depth).
+ {
+ let mut nested = serde_json::json!(0);
+ for _ in 0..512 {
+ nested = serde_json::json!([nested]);
+ }
+ let mut c = base.clone();
+ c["wallets"] = nested;
+ cases.push(c);
+ }
+
+ // Hostile labels and key shapes.
+ for label in ["\0null", "../escape", &"a".repeat(65), "has space"] {
+ let mut c = base.clone();
+ c["wallets"] = serde_json::json!({ wid: { label: { "nonce": good_nonce.as_str(), "ciphertext": good_ct.as_str() } } });
+ cases.push(c);
+ }
+
+ // Wrong-width hex and oversized declared sizes.
+ for (nonce, ct) in [
+ ("00", good_ct.as_str()), // short nonce
+ (good_nonce.as_str(), "00"), // short ciphertext
+ (&"0".repeat(NONCE_LEN * 4), good_ct.as_str()), // over-wide nonce
+ ("zz", good_ct.as_str()), // non-hex nonce
+ ] {
+ let mut c = base.clone();
+ c["wallets"] =
+ serde_json::json!({ wid: { "seed": { "nonce": nonce, "ciphertext": ct } } });
+ cases.push(c);
+ }
+
+ // Non-hex / wrong-length outer wallet-id key.
+ for bad_wid in ["not-hex", &"aa".repeat(8), &"AB".repeat(32)] {
+ let mut c = base.clone();
+ c["wallets"] = serde_json::json!({ bad_wid: { "seed": { "nonce": good_nonce.as_str(), "ciphertext": good_ct.as_str() } } });
+ cases.push(c);
+ }
+
+ // Header fields (salt / verify_nonce / verify_ct): empty / short /
+ // over-wide / non-hex must each be a typed error, never a panic.
+ let over_wide = "0".repeat(SALT_LEN * 4);
+ for field in ["salt", "verify_nonce", "verify_ct"] {
+ for bad in ["", "00", over_wide.as_str(), "zz"] {
+ let mut c = base.clone();
+ c[field] = serde_json::json!(bad);
+ cases.push(c);
+ }
+ }
+
+ for c in cases {
+ let bytes = serde_json::to_vec(&c).unwrap();
+ assert_deserialize_outcome_is_typed(&bytes);
+ }
+ }
}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
index c2ae67e7352..68ad88d76c5 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
@@ -1,28 +1,24 @@
//! [`EncryptedFileStore`] — passphrase-encrypted on-disk vault, resident
//! in memory while the store handle lives.
//!
-//! # Lifecycle
+//! [`open`] takes the advisory lock on a sibling `.lock` sidecar (single
+//! attempt), then creates or decrypts the vault and keeps the plaintext
+//! entry map resident. [`get`] serves from memory (no per-op KDF/disk);
+//! every mutation ([`put`], [`delete`], [`rekey`]) edits memory then
+//! re-encrypts and atomically rewrites the file. [`Drop`] best-effort
+//! re-syncs, re-asserts `0600` on Unix, and releases the lock. A second
+//! `open()` of a held path fails fast with
+//! [`SecretStoreError::AlreadyLocked`].
//!
-//! - [`open`] grabs the cross-platform advisory lock on a sibling
-//! `.lock` sidecar (single attempt, no retry), creates a fresh vault
-//! if none exists yet, otherwise decrypts the existing one, and keeps
-//! the plaintext entry map resident.
-//! - Every mutation ([`put`], [`delete`], [`rekey`]) edits the in-memory
-//! vault and immediately re-encrypts and atomically writes it back to
-//! disk (eager sync).
-//! - [`get`] reads from the in-memory map — no KDF, no disk hit per op.
-//! - [`Drop`] best-effort-syncs the resident state once more, re-asserts
-//! `0600` on Unix, and releases the lock when the file descriptor
-//! closes.
+//! **Cross-process exclusion is LOCAL-filesystem only.** `AlreadyLocked`
+//! rests on `fd-lock` (`flock` / `LockFileEx`), which does NOT interlock
+//! over NFS/CIFS/SMB — two hosts could each "lock" and last-writer-wins
+//! would lose secrets. A vault file MUST NOT be shared across hosts; use
+//! the OS keyring ([`SecretStore::Os`]) for multi-host access. The lock
+//! sidecar is distinct from the vault file so the atomic `persist` rename
+//! never touches the inode the open lock fd points at.
//!
-//! Concurrency is intentionally not supported: a second `open()` against
-//! a path some other store handle (in this or another process) is
-//! already holding fails fast with [`SecretStoreError::AlreadyLocked`].
-//!
-//! One file, one passphrase, one lock — a multi-wallet store cannot
-//! lock its other wallets out by construction. The lock sidecar
-//! (`.lock`) is distinct from the vault file itself so the atomic
-//! `persist` rename never touches the inode an open lock fd points at.
+//! [`SecretStore::Os`]: crate::secrets::SecretStore::Os
//!
//! [`open`]: EncryptedFileStore::open
//! [`put`]: EncryptedFileStore::put_bytes
@@ -30,21 +26,21 @@
//! [`rekey`]: EncryptedFileStore::rekey
//! [`get`]: EncryptedFileStore::get_bytes
//!
-//! ## Threat coverage
-//!
-//! Covers **A1** (other local user), **A4** (lost laptop / cold
-//! backup), **A6** (synced backup of the vault file): the at-rest file
-//! is Argon2id + AEAD, useless without the passphrase. Does **not**
-//! cover **A3** (passphrase / derived key resident while unlocked), a
-//! weak operator passphrase (KDF raises cost, does not eliminate the
-//! risk — an accepted residual), or **A5** if the derived key / plaintext is
-//! swapped or core-dumped while unlocked (best-effort mitigated by
-//! zeroize + mlock, not eliminated). The derived AEAD key is held
-//! resident inside a [`SecretBytes`] for the store's lifetime so reads
-//! and writes do not pay the Argon2 cost per op; it is zeroized on Drop.
-
-mod crypto;
-mod format;
+//! Threat coverage: the at-rest file is Argon2id + AEAD, so it protects
+//! **A1** (other local user), **A4** (lost laptop / cold backup), and
+//! **A6** (synced backup). It does NOT cover **A3** (key/passphrase
+//! resident while unlocked), a weak operator passphrase, or **A5**
+//! (swap / core-dump while unlocked) — the last is best-effort mitigated
+//! by zeroize + mlock. The derived AEAD key stays resident in a
+//! [`SecretBytes`] (to avoid per-op Argon2) and is zeroized on Drop.
+
+// `pub(super)` (= visible within `crate::secrets`) so the Tier-2
+// `envelope` module — a sibling of `file` under `secrets` — can reuse the
+// shared Argon2id/XChaCha primitives and `KDF_ID_ARGON2ID` without
+// duplicating crypto. Items inside stay `pub(crate)`/`pub(in …file)`, so
+// nothing escapes the secrets tree (see the crypto.rs module doc).
+pub(super) mod crypto;
+pub(super) mod format;
use std::any::Any;
use std::collections::HashMap;
@@ -61,12 +57,11 @@ use format::{EntryBody, Vault};
use super::error::SecretStoreError;
-use super::secret::{SecretBytes, SecretString};
+use super::secret::{SecretBytes, SecretString, MIN_PASSPHRASE_LEN};
use super::validate::{validated_label, WalletId};
-/// Upstream service-prefix for vault entries. The full `service`
-/// string is `SERVICE_PREFIX + hex(wallet_id)`, mapping each wallet
-/// to its own keyring "service" namespace.
+/// Service-prefix for vault entries: the full `service` string is
+/// `SERVICE_PREFIX + hex(wallet_id)`, one namespace per wallet.
pub const SERVICE_PREFIX: &str = "dash.platform-wallet-storage/";
/// Vendor / id tags published through `CredentialStoreApi`.
@@ -74,113 +69,118 @@ const VENDOR: &str = "dash.platform-wallet-storage";
const STORE_ID: &str = "encrypted-file-store-v1";
/// Structural ceiling on the on-disk vault file. The vault is
-/// attacker-controllable JSON; a multi-GiB file would force a huge
-/// `fs::read` allocation ahead of any tag check, so refuse to even
-/// allocate beyond this cap and surface
-/// [`SecretStoreError::VaultTooLarge`].
+/// attacker-controllable JSON, so refuse to allocate / parse beyond this
+/// cap (surfacing [`SecretStoreError::VaultTooLarge`]) ahead of any tag
+/// check rather than let a multi-GiB file force a huge `fs::read`.
pub const MAX_VAULT_SIZE_BYTES: u64 = 128 * 1024 * 1024;
+/// Per-secret write-side ceiling. The vault is ONE shared document, so an
+/// uncapped oversized entry would inflate it past [`MAX_VAULT_SIZE_BYTES`]
+/// and brick every wallet on reopen. 64 KiB is far above any legitimate
+/// mnemonic / seed / xpriv. Enforced with
+/// [`SecretStoreError::SecretTooLarge`] at the write boundary before the
+/// secret is sealed or inserted.
+pub const MAX_SECRET_LEN: usize = 64 * 1024;
+
/// A passphrase-encrypted file-backed credential store.
///
-/// One file, one passphrase, one lock — the whole store rotates
-/// together via [`rekey`](Self::rekey). Every [`SecretString`] and the
-/// resident derived AEAD key are zeroized when the store drops.
-/// The plaintext entry map is held in
-/// [`EntryBody`]-shaped form: the bytes inside `ciphertext` are
-/// ciphertext, but the structure is fully populated so reads do not
-/// re-touch disk.
-///
-/// The handle is cheap-`Clone` — both clones share the same
-/// `Arc>`, so every operation through any clone sees the same
-/// resident state and serializes against every other operation.
+/// One file, one passphrase, one lock; the whole store rotates together
+/// via [`rekey`](Self::rekey). The cheap-`Clone` handle shares one
+/// `Arc>`, so every clone sees the same resident state and
+/// serializes against every other operation. All [`SecretString`]s and
+/// the resident AEAD key are zeroized on drop.
#[derive(Clone)]
pub struct EncryptedFileStore {
inner: Arc>,
}
-/// All resident state for the store — path, advisory file lock,
-/// in-memory vault, cached AEAD key, and the store-wide passphrase —
-/// coalesced behind a single [`Mutex`] at the [`Arc`] wrapper level so
-/// every mutation observes a consistent triple (vault matches the key
-/// it was sealed under, key matches the passphrase that derived it).
-///
-/// A single lock keeps put/get/delete/rekey serialized against each
-/// other so a concurrent put cannot seal under an old key while rekey
-/// is swapping in a new one. The disk write happens while
-/// the lock is held; the file-lock sidecar already serializes
-/// cross-process so this does not introduce a new I/O contention
-/// point.
+/// Resident store state behind a single [`Mutex`] so every mutation sees
+/// a consistent triple (vault matches the key it was sealed under, key
+/// matches the passphrase that derived it). The single lock serializes
+/// put/get/delete/rekey so a put cannot seal under an old key mid-rekey;
+/// the disk write happens under it, and the file-lock sidecar already
+/// serializes cross-process, so this adds no new I/O contention point.
struct EncryptedFileStoreInner {
/// Vault file path supplied by the caller at [`open`].
///
/// [`open`]: EncryptedFileStore::open
path: PathBuf,
- /// In-memory vault. Mutations edit this directly and then call
- /// `sync_to_disk` to re-encrypt and atomically replace the
- /// on-disk file. Reads return clones from here without hitting
- /// disk.
+ /// In-memory vault. Mutations edit it then `sync_to_disk` to
+ /// re-encrypt and atomically replace the file; reads clone from here.
vault: Vault,
- /// Cached AEAD key derived once at [`open`] from the salt + KDF
- /// params + passphrase. Re-derived only on [`rekey`]. Keeping the
- /// key resident is what makes mutations cheap (one AEAD seal per
- /// entry, no Argon2 per op) and matches the resident-vault model.
- /// A3 (key resident while unlocked) is an accepted threat in the
- /// module docs; the buffer zeroizes when the state drops.
+ /// AEAD key derived once at [`open`] (re-derived only on [`rekey`]).
+ /// Held resident to avoid per-op Argon2 — A3 (key resident while
+ /// unlocked) is an accepted threat; zeroized when the state drops.
///
/// [`open`]: EncryptedFileStore::open
/// [`rekey`]: EncryptedFileStore::rekey
derived_key: SecretBytes,
- /// The store-wide passphrase. Swapped atomically with `vault` and
- /// `derived_key` under the same lock during [`rekey`].
+ /// Store-wide passphrase, swapped with `vault` + `derived_key` under
+ /// the same lock during [`rekey`].
///
/// [`rekey`]: EncryptedFileStore::rekey
passphrase: SecretString,
- /// Holds the cross-platform advisory write-lock on `.lock`
- /// for the entire lifetime of the store. Dropped (releasing the
- /// flock / LockFileEx) when the store drops.
+ /// Holds the advisory write-lock on `.lock` for the store's
+ /// lifetime; dropped (releasing the lock) when the store drops.
_lock: VaultLock,
}
impl EncryptedFileStore {
- /// Open a vault store at `path`, unlocked by `passphrase`. `path`
- /// is the vault FILE, not a directory — the operator picks the
- /// filename.
- ///
- /// The call acquires an exclusive advisory lock on a sibling
- /// `.lock` sidecar before touching the vault. If the lock is
- /// already held (by another handle in this process or by another
- /// process) the call returns [`SecretStoreError::AlreadyLocked`]
- /// immediately — there is no retry loop.
+ /// Open a vault store at `path` (the vault FILE, not a directory),
+ /// unlocked by `passphrase`.
///
- /// If `path` does not exist yet a fresh vault (random salt, default
- /// Argon2 params, sealed verify token, no entries) is created at
- /// `0600` on Unix. If it exists the vault is read, the passphrase
- /// is verified against the header verify-token, and the plaintext
- /// entry map is loaded into memory. Either way the returned store
- /// is immediately usable.
+ /// Acquires an exclusive advisory lock on a sibling `.lock`
+ /// first; if already held, returns [`SecretStoreError::AlreadyLocked`]
+ /// immediately (no retry). A missing `path` is created fresh (random
+ /// salt, default Argon2 params, sealed verify token) at `0600` on
+ /// Unix; an existing one is read and its passphrase verified against
+ /// the header verify-token. Either way the returned store is usable.
pub fn open(
path: impl AsRef,
passphrase: SecretString,
) -> Result {
- let path = path.as_ref().to_path_buf();
-
- // Make sure the parent directory exists so both the lock sidecar
- // open and the vault create do not fail on a not-yet-materialized
- // dir (canonical for first-setup operators). `Path::parent()`
- // returns `Some("")` for a bare relative filename, which neither
- // `create_dir_all` nor the cross-platform persist path can
- // consume — normalize the empty-string parent to ".".
+ // Tier-1 baseline: reject a blank passphrase (empty / all-whitespace)
+ // BEFORE touching the filesystem. A blank passphrase derives a key
+ // from a public salt only — obfuscation, not confidentiality
+ // (obfuscation, not confidentiality). This is an INTENDED behavioural break for any caller
+ // that relied on `SecretString::empty()`; a deliberate keyless vault
+ // must use [`open_unprotected`](Self::open_unprotected). No vault
+ // file is created or altered for a blank passphrase.
+ reject_weak_passphrase(&passphrase)?;
+ Self::open_inner(path.as_ref(), passphrase)
+ }
+
+ /// Open (or create) a **deliberately keyless** vault — the only door
+ /// that accepts no passphrase. The vault key is derived from an empty
+ /// passphrase under the public salt, so this is **obfuscation, not
+ /// confidentiality**: use it only where the stored secrets carry their
+ /// own Tier-2 object password, or as a staging step before
+ /// [`rekey`](Self::rekey) to a real passphrase. This is the explicit
+ /// keyless door, distinct from [`open`](Self::open) (which now rejects a
+ /// blank passphrase).
+ pub fn open_unprotected(path: impl AsRef) -> Result {
+ Self::open_inner(path.as_ref(), SecretString::empty())
+ }
+
+ /// Shared open/create core for [`open`](Self::open) and
+ /// [`open_unprotected`](Self::open_unprotected). Does NOT apply the
+ /// blank-passphrase guard — the public doors decide that.
+ fn open_inner(path: &Path, passphrase: SecretString) -> Result {
+ let path = path.to_path_buf();
+
+ // Materialize the parent so the lock-sidecar open and vault
+ // create do not fail on a not-yet-existing dir.
let parent = normalized_parent(&path);
create_parent_dir(parent)?;
+ // Refuse a group/other-WRITABLE parent: directory write governs
+ // rename/unlink, so a writable parent lets another local user
+ // replace the vault despite its own 0600 (the A1 guarantee).
+ check_parent_perms(parent)?;
- // Acquire the lock first — every subsequent step assumes
- // exclusive ownership of the vault file.
+ // Lock first — every subsequent step assumes exclusive ownership.
let lock = VaultLock::acquire(&lock_path_for(&path))?;
- // Decide between load-existing and create-fresh based on a
- // single open attempt: NotFound → fresh; anything else → load
- // (the perm check inside `read_existing_vault` covers loose
- // perms on a real file).
+ // NotFound → create fresh; anything else → load.
let (vault, derived_key) = match Self::load_existing_vault(&path, &passphrase)? {
Some(loaded) => loaded,
None => Self::create_new_vault(&path, &passphrase)?,
@@ -222,32 +222,28 @@ impl EncryptedFileStore {
Ok((vault, key))
}
- /// Re-encrypt the whole store under `new_passphrase`: fresh salt +
- /// fresh per-entry nonces for every wallet's entries, then
- /// atomically replace the vault file. No `.bak` retains old key
- /// material. The swap is whole-store: every
- /// wallet's entries are re-keyed in one shot, so the store cannot
- /// end up half-rotated. The in-memory vault, derived key, and
- /// passphrase advance together under the resident-state mutex.
+ /// Re-encrypt the whole store under `new_passphrase` — fresh salt and
+ /// per-entry nonces for every wallet, atomically in one shot (no
+ /// half-rotated state, no `.bak` retaining old key material). Vault,
+ /// derived key, and passphrase advance together under the mutex.
///
- /// The fresh KDF / Argon2 derivation runs OUTSIDE the lock — it
- /// only touches the new passphrase + a fresh salt and never reads
- /// resident state, so paying ~hundreds of ms inside the critical
- /// section would just stall unrelated put/get operations.
+ /// The Argon2 derivation runs OUTSIDE the lock — it touches only the
+ /// new passphrase + fresh salt, so paying ~hundreds of ms inside the
+ /// critical section would needlessly stall unrelated put/get ops.
pub fn rekey(&self, new_passphrase: SecretString) -> Result<(), SecretStoreError> {
+ // Reject a blank target passphrase: `rekey` always advances to a
+ // REAL passphrase (the empty→real migration uses this). The resident
+ // vault, key, and on-disk file are untouched on rejection. To make a
+ // vault keyless, use `open_unprotected` on a fresh path instead.
+ reject_weak_passphrase(&new_passphrase)?;
let (new_vault, new_key) = build_fresh_vault(&new_passphrase)?;
lock_inner(&self.inner).rekey(new_vault, new_key, new_passphrase)
}
/// Store `secret` under `(wallet_id, label)`, returning the typed
- /// [`SecretStoreError`] (lossless — no `keyring_core::Error` seam).
- /// The public [`SecretStore`](crate::secrets::SecretStore) file
- /// arm delegates here so the structural error distinction
- /// survives. Symmetric with [`get_bytes`]: the secret stays
- /// wrapped in [`SecretBytes`] across this seam; the lone bare-buffer
- /// exposure lives one layer down at the AEAD seal call.
- ///
- /// [`get_bytes`]: Self::get_bytes
+ /// [`SecretStoreError`] losslessly (no SPI seam). The secret stays
+ /// wrapped across this boundary; the bare-buffer exposure is one layer
+ /// down at the AEAD seal.
pub(crate) fn put_bytes(
&self,
wallet_id: &WalletId,
@@ -258,12 +254,9 @@ impl EncryptedFileStore {
}
/// Retrieve the plaintext under `(wallet_id, label)`, or `None` if
- /// absent, returning the typed [`SecretStoreError`]. The plaintext
- /// stays inside a zeroizing [`SecretBytes`] all the way to this
- /// boundary; the single `.expose_secret().to_vec()` conversion lives
- /// at the upstream `CredentialApi::get_secret`
- /// SPI seam, the only point where the SPI contract demands a bare
- /// `Vec`.
+ /// absent. The plaintext stays inside a zeroizing [`SecretBytes`] to
+ /// this boundary; the bare-`Vec` conversion lives only at the
+ /// `CredentialApi::get_secret` SPI seam, where the contract demands it.
pub(crate) fn get_bytes(
&self,
wallet_id: &WalletId,
@@ -292,11 +285,9 @@ impl EncryptedFileStore {
write_vault_at(&lock_inner(&self.inner).path, vault)
}
- /// Drop the in-memory copy of the vault and reload it from disk
- /// under the current passphrase. Useful for tests that mutate the
- /// on-disk file out from under the store and want subsequent reads
- /// to observe the new bytes (the resident-vault model otherwise
- /// caches the loaded state).
+ /// Reload the vault from disk under the current passphrase, so a test
+ /// that patched the on-disk file sees the new bytes (the resident
+ /// model otherwise serves the cached state).
#[cfg(test)]
pub(crate) fn test_reload_from_disk(&self) -> Result<(), SecretStoreError> {
let mut state = lock_inner(&self.inner);
@@ -310,11 +301,9 @@ impl EncryptedFileStore {
}
}
-/// Acquire the single coarse-grained state lock on `inner`.
-/// Poisoned-mutex recovery is "log and continue": a previously-panicked
-/// holder cannot have left the [`EncryptedFileStoreInner`] half-written
-/// (every mutation either succeeds wholesale and writes to disk or
-/// reverts), so the inner value is safe to keep using.
+/// Acquire the state lock on `inner`. A poisoned mutex is recovered (not
+/// propagated): every mutation either commits wholesale or reverts, so a
+/// panicked holder cannot have left the inner value half-written.
fn lock_inner(
inner: &Arc>,
) -> std::sync::MutexGuard<'_, EncryptedFileStoreInner> {
@@ -337,32 +326,38 @@ impl EncryptedFileStoreInner {
secret: &SecretBytes,
) -> Result<(), SecretStoreError> {
let label = validated_label(label)?.to_string();
+ // Reject before sealing: the shared document would otherwise
+ // inflate past the read-side ceiling and brick every wallet.
+ if secret.len() > MAX_SECRET_LEN {
+ return Err(SecretStoreError::SecretTooLarge {
+ found: secret.len(),
+ max: MAX_SECRET_LEN,
+ });
+ }
let aad = format::aad(format::FORMAT_VERSION, wallet_id.as_bytes(), &label);
let (nonce, ciphertext) = crypto::seal(&self.derived_key, &aad, secret.expose_secret())?;
- // Mutate in memory; remember the prior body so we can roll
- // back on a disk-write failure (the resident state must
- // always match what is on disk after a returned-Ok mutation).
+ // Remember the prior body so a disk-write failure can revert the
+ // resident state to match disk (Ok must imply memory == disk).
let prior = {
let entries = self.vault.wallets.entry(wallet_id.to_hex()).or_default();
entries.insert(label.clone(), EntryBody { nonce, ciphertext })
};
if let Err(e) = self.sync_to_disk() {
- let entries = self
- .vault
- .wallets
- .get_mut(&wallet_id.to_hex())
- .expect("entry just inserted");
- match prior {
- Some(prev) => {
- entries.insert(label, prev);
- }
- None => {
- entries.remove(&label);
- if entries.is_empty() {
- self.vault.wallets.remove(&wallet_id.to_hex());
+ // A missing bucket means the insert never landed (nothing to
+ // undo) — return the error rather than panic.
+ if let Some(entries) = self.vault.wallets.get_mut(&wallet_id.to_hex()) {
+ match prior {
+ Some(prev) => {
+ entries.insert(label, prev);
+ }
+ None => {
+ entries.remove(&label);
+ if entries.is_empty() {
+ self.vault.wallets.remove(&wallet_id.to_hex());
+ }
}
}
}
@@ -457,9 +452,8 @@ impl EncryptedFileStoreInner {
new_vault.wallets.insert(wallet_hex.clone(), new_entries);
}
- // Stage the new triple in memory, write to disk, and on
- // failure restore the old triple so the live handle keeps
- // serving under the still-on-disk key.
+ // Stage the new triple; on disk-write failure restore the old one
+ // so the live handle keeps serving under the still-on-disk key.
let old_vault = std::mem::replace(&mut self.vault, new_vault);
let old_key = std::mem::replace(&mut self.derived_key, new_key);
let old_pp = std::mem::replace(&mut self.passphrase, new_passphrase);
@@ -476,53 +470,60 @@ impl EncryptedFileStoreInner {
impl Drop for EncryptedFileStoreInner {
fn drop(&mut self) {
- // Belt-and-suspenders sync of resident state. Eager-sync on
- // every mutation makes this redundant in the success path, but
- // a final write lets a future feature (e.g. opportunistic
- // background buffering) hang off the same Drop without changing
- // the contract. `&mut self` here implies unique ownership —
- // the outer `Mutex` is being dropped too, so no other holder
- // can be waiting.
+ // Best-effort final sync. Redundant in the success path (every
+ // mutation eager-syncs) but kept as a contract anchor.
if let Err(e) = self.sync_to_disk() {
tracing::warn!(error = %e, "drop-time vault sync failed");
}
- // Re-assert restrictive perms on Unix. Between writes the file
- // is already 0600, but this defends against a peer that
- // loosened them through some other path while we held the
- // lock. Best-effort: any failure is non-fatal at Drop.
+ // Re-assert 0600 on Unix in case a peer loosened it while we held
+ // the lock. Best-effort: failures are non-fatal at Drop.
#[cfg(unix)]
- if let Ok(file) = open_no_follow(&self.path) {
- if let Err(e) = set_restrictive_perms(&file) {
- tracing::warn!(error = %e, "drop-time perm re-assert failed");
+ match open_no_follow(&self.path) {
+ Ok(file) => {
+ if let Err(e) = set_restrictive_perms(&file) {
+ tracing::warn!(error = %e, "drop-time perm re-assert failed");
+ }
+ }
+ Err(e) => {
+ tracing::warn!(
+ error = %e,
+ "drop-time perm re-assert skipped: vault re-open refused"
+ );
}
}
- // The `VaultLock` field drops naturally after this method
- // returns, releasing the OS advisory lock.
+ // `VaultLock` drops after this returns, releasing the OS lock.
}
}
-/// Sidecar advisory-lock path for the store's vault file. Kept
-/// distinct from the vault file itself so the cross-platform
-/// `persist` swap never touches the inode an open lock fd points
-/// at — the lock fd remains valid across the atomic replace.
+/// Sidecar lock path (`.lock`). Distinct from the vault file so the
+/// atomic `persist` swap never touches the inode the lock fd points at.
fn lock_path_for(path: &Path) -> PathBuf {
let mut s = path.to_path_buf().into_os_string();
s.push(".lock");
PathBuf::from(s)
}
-/// Build a fresh vault skeleton: random salt, default Argon2
-/// params, and a passphrase-verification token sealed under the
-/// freshly derived key (the token is the mixed-key-corruption guard).
-/// Returns the (entry-less) vault and the
-/// derived key so the caller can seal entries against it without
-/// re-deriving.
+/// Reject a blank (empty / all-whitespace) or sub-floor passphrase →
+/// [`SecretStoreError::BlankPassphrase`]. The floor is the coarse
+/// [`MIN_PASSPHRASE_LEN`] (1 today = merely non-blank); the real entropy
+/// policy is the consumer's (see `SECRETS.md`). A blank check alone closes
+/// the length term keeps the floor wired for a future bump.
+fn reject_weak_passphrase(passphrase: &SecretString) -> Result<(), SecretStoreError> {
+ if passphrase.is_blank() || passphrase.trimmed().len() < MIN_PASSPHRASE_LEN {
+ return Err(SecretStoreError::BlankPassphrase);
+ }
+ Ok(())
+}
+
+/// Build a fresh entry-less vault (random salt, default Argon2 params,
+/// verify-token sealed under the derived key) plus that derived key, so
+/// the caller can seal entries without re-deriving.
fn build_fresh_vault(passphrase: &SecretString) -> Result<(Vault, SecretBytes), SecretStoreError> {
let mut salt = [0u8; SALT_LEN];
crypto::random_bytes(&mut salt)?;
let kdf = KdfParams::default_target();
let key = crypto::derive_key(passphrase, &salt, kdf)?;
- let v_aad = format::verify_aad(format::FORMAT_VERSION);
+ let v_aad = format::verify_aad(format::FORMAT_VERSION, &salt, &kdf);
let (verify_nonce, verify_ct) = crypto::seal(&key, &v_aad, format::VERIFY_CONSTANT)?;
Ok((
Vault {
@@ -546,7 +547,7 @@ fn derive_and_verify(
passphrase: &SecretString,
) -> Result {
let key = crypto::derive_key(passphrase, &vault.salt, vault.kdf)?;
- let v_aad = format::verify_aad(format::FORMAT_VERSION);
+ let v_aad = format::verify_aad(format::FORMAT_VERSION, &vault.salt, &vault.kdf);
match crypto::open(&key, &vault.verify_nonce, &v_aad, &vault.verify_ct) {
Ok(_) => Ok(key),
Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassphrase),
@@ -554,13 +555,10 @@ fn derive_and_verify(
}
}
-/// Read + parse the vault at `path`, or `None` if it does not exist.
-/// Refuses a pre-existing file with looser-than-0600 perms and a file
-/// exceeding [`MAX_VAULT_SIZE_BYTES`].
-///
-/// Eliminates the metadata→read TOCTOU: opens the file once with
-/// `O_NOFOLLOW` on Unix, then derives perms / size from
-/// the open handle's `metadata()` and reads from the same fd.
+/// Read + parse the vault at `path`, or `None` if absent. Refuses
+/// looser-than-0600 perms and a file over [`MAX_VAULT_SIZE_BYTES`].
+/// Opens once with `O_NOFOLLOW` and derives perms/size from the same fd
+/// to avoid a metadata→read TOCTOU.
fn read_vault_at(path: &Path) -> Result, SecretStoreError> {
let file = match open_no_follow(path) {
Ok(file) => file,
@@ -592,16 +590,12 @@ fn read_vault_at(path: &Path) -> Result , SecretStoreError> {
Ok(Some(format::deserialize(&bytes)?))
}
-/// Atomically replace the vault at `path`, cross-platform.
-///
-/// Stages into a `NamedTempFile` in the SAME directory (so `persist`
-/// cannot fail cross-volume), tightens perms to 0600 on Unix before
-/// any byte is written, then: `write_all` → `sync_all` →
-/// `persist(path)` → Unix parent-dir fsync. The destination is never
-/// pre-removed, so a crash leaves either the old or the new vault,
-/// never an absent one. On `persist` failure the temp drops and
-/// self-cleans — no manual remove racing it. The temp holds only
-/// ciphertext+header, never plaintext.
+/// Atomically replace the vault at `path`. Stages into a same-directory
+/// `NamedTempFile` (so `persist` cannot fail cross-volume), tightens to
+/// 0600 before writing, then `write_all` → `sync_all` → `persist` → Unix
+/// parent-dir fsync. The destination is never pre-removed, so a crash
+/// leaves the old or new vault, never none. The temp holds only
+/// ciphertext + header, never plaintext.
fn write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> {
do_write_vault_at(path, vault).inspect_err(|e| {
tracing::warn!(error = %e, "failed to write vault file");
@@ -610,9 +604,14 @@ fn write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> {
fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> {
let serialized = format::serialize(vault);
- // Normalize an empty / bare-filename parent to "." so neither
- // `NamedTempFile::new_in` nor the Unix parent-dir fsync sees an
- // empty path.
+ // Defence in depth: never write a vault the read path would refuse,
+ // so the on-disk file is never left unopenable.
+ if serialized.len() as u64 > MAX_VAULT_SIZE_BYTES {
+ return Err(SecretStoreError::VaultTooLarge {
+ found: serialized.len() as u64,
+ max: MAX_VAULT_SIZE_BYTES,
+ });
+ }
let parent = normalized_parent(path);
create_parent_dir(parent)?;
let mut tmp =
@@ -626,30 +625,48 @@ fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError>
.map_err(|e| SecretStoreError::io_at(path, e))?;
tmp.persist(path)
.map_err(|e| SecretStoreError::io_at(path, e.error))?;
+ // The vault is now committed on disk via the atomic persist() rename above.
+ // A subsequent parent-directory fsync failure cannot undo the already-written
+ // file. Propagating the error would cause callers (put/delete/rekey) to roll
+ // back in-memory state that already matches the on-disk vault — leaving the
+ // live handle diverged from disk. Log a warning instead so the caller's
+ // rollback guard is not triggered.
#[cfg(unix)]
{
- let d = fs::File::open(parent).map_err(|e| SecretStoreError::io_at(parent, e))?;
- d.sync_all()
- .map_err(|e| SecretStoreError::io_at(parent, e))?;
+ match fs::File::open(parent) {
+ Ok(d) => {
+ if let Err(e) = d.sync_all() {
+ tracing::warn!(
+ error = %e,
+ parent = %parent.display(),
+ "parent-dir fsync failed after vault persist; vault is committed on disk"
+ );
+ }
+ }
+ Err(e) => {
+ tracing::warn!(
+ error = %e,
+ parent = %parent.display(),
+ "parent-dir open for fsync failed after vault persist; vault is committed on disk"
+ );
+ }
+ }
}
Ok(())
}
-/// Normalize `path.parent()` for callers that need a directory path
-/// they can pass to `fs::create_dir_all`, `NamedTempFile::new_in`, and
-/// the Unix parent-dir fsync. `Path::parent()` returns `Some("")` for a
-/// bare relative filename like `"vault.pwsvault"`, and the empty path
-/// errors out at every one of those calls — normalize to "." so a
-/// caller that supplies a bare filename in their cwd just works.
+/// Normalize `path.parent()` to a usable directory: `Path::parent()`
+/// returns `Some("")` for a bare filename, which errors at
+/// `create_dir_all` / `NamedTempFile::new_in` / parent-dir fsync — map
+/// the empty parent to "." so a bare filename in the cwd just works.
fn normalized_parent(path: &Path) -> &Path {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
-/// Create the parent directory for a vault file, applying a `0700` mode
-/// on Unix so the directory created at first-setup is not
-/// world-readable. Idempotent: a pre-existing directory is left alone.
+/// Create the vault's parent directory at `0700` on Unix (not
+/// world-readable). Idempotent — a pre-existing directory is left alone.
fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> {
#[cfg(unix)]
{
@@ -659,10 +676,8 @@ fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> {
.recursive(true)
.create(parent)?;
}
- // INTENTIONAL: Windows ACL hardening on the parent dir is deferred
- // to https://github.com/dashpay/platform/issues/3754. The recursive
- // create still runs so the path materializes; operators on Windows
- // MUST tighten ACLs manually until the follow-up lands.
+ // INTENTIONAL: Windows parent-dir ACL hardening deferred to
+ // https://github.com/dashpay/platform/issues/3754 — tighten manually.
#[cfg(not(unix))]
{
fs::create_dir_all(parent)?;
@@ -670,28 +685,25 @@ fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> {
Ok(())
}
-/// Cross-platform advisory write-lock holder. Owns a `Box>`
-/// (so the address is stable) and an owned `RwLockWriteGuard` borrowing
-/// from it. Dropping the holder drops the guard first (which releases
-/// the OS lock via `fd-lock`'s Drop impl, calling `flock(LOCK_UN)` on
-/// Unix and `UnlockFileEx` on Windows) and then frees the heap-pinned
-/// `RwLock`.
-///
-/// The self-reference is unavoidable: `fd-lock`'s guard borrows the
-/// `RwLock`, and the resident-vault model requires the lock to stay
-/// held continuously between `open` and `Drop`. Wrapped in a small
-/// allow-unsafe island so the rest of the crate keeps
-/// `deny(unsafe_code)`. Safety arguments:
+/// Advisory write-lock holder owning a heap-pinned `Box>`
+/// and a self-referential `'static` guard borrowing from it. The
+/// self-reference is unavoidable: `fd-lock`'s guard borrows the `RwLock`,
+/// and the resident-vault model needs the lock held continuously between
+/// `open` and `Drop`. Safety:
///
-/// 1. The `RwLock` lives on the heap via `Box::into_raw`, so its
-/// address is stable for the holder's lifetime.
-/// 2. The `'static` lifetime on the guard is a lie tolerated only
-/// because the guard never outlives the holder, and the holder's
-/// `Drop` impl takes the guard out (running its Drop) *before*
-/// reclaiming the box.
+/// 1. `Box::into_raw` gives the `RwLock` a stable address for the
+/// holder's lifetime.
+/// 2. The `'static` guard lifetime is a lie sound only because `Drop`
+/// takes the guard out (running its Drop, releasing the OS lock)
+/// BEFORE reclaiming the box.
/// 3. The raw pointer never escapes this module.
+///
+/// Calibrated to `fd-lock = "=4.0.4"` (exact-pinned): any bump must
+/// re-verify the guard releases the OS lock before the box is reclaimed.
mod vault_lock {
- #![allow(unsafe_code)]
+ // INTENTIONAL: the crate's only unsafe island; soundness rests on the
+ // drop-order argument above, not a Miri test. `#![deny(unsafe_code)]`
+ // still applies everywhere outside the narrowed per-item allows.
use std::fs;
use std::path::Path;
@@ -709,26 +721,30 @@ mod vault_lock {
// member is a `File`/`RawFd`, both `Send + Sync`). The raw pointer
// points at the heap-pinned `RwLock` this struct owns; sending the
// struct moves ownership of the box address with it.
+ #[allow(unsafe_code)]
unsafe impl Send for VaultLock {}
+ #[allow(unsafe_code)]
unsafe impl Sync for VaultLock {}
impl VaultLock {
pub(super) fn acquire(lock_path: &Path) -> Result {
- // INTENTIONAL: on non-unix platforms the symlink-following
- // hardening is deferred to
- // https://github.com/dashpay/platform/issues/3754 — Windows
- // requires `FILE_FLAG_OPEN_REPARSE_POINT` via the raw API
- // and is out of scope for the secrets-feature landing.
+ // INTENTIONAL: non-unix symlink hardening (Windows needs
+ // FILE_FLAG_OPEN_REPARSE_POINT) deferred to
+ // https://github.com/dashpay/platform/issues/3754.
let mut opts = fs::OpenOptions::new();
opts.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
+ // Restrictive from the first byte — no loose-perm window.
+ opts.mode(0o600);
}
let lock_file = opts
.open(lock_path)
.map_err(|e| SecretStoreError::io_at(lock_path, e))?;
+ // `mode()` only applies to a file this call creates; re-assert
+ // 0600 on a pre-existing sidecar.
#[cfg(unix)]
set_restrictive_perms(&lock_file)?;
@@ -740,6 +756,7 @@ mod vault_lock {
// at a valid `RwLock`. No other reference exists
// yet, so promoting it to `&'static mut` is sound for the
// borrow we hand to `try_write`.
+ #[allow(unsafe_code)]
let static_ref: &'static mut fd_lock::RwLock = unsafe { &mut *raw };
let guard = match static_ref.try_write() {
@@ -749,7 +766,10 @@ mod vault_lock {
// no live borrow points at the box; reclaiming
// here is sound and avoids leaking on the error
// path.
- unsafe { drop(Box::from_raw(raw)) };
+ #[allow(unsafe_code)]
+ unsafe {
+ drop(Box::from_raw(raw))
+ };
return Err(match e.kind() {
std::io::ErrorKind::WouldBlock => SecretStoreError::AlreadyLocked,
_ => SecretStoreError::from(e),
@@ -773,19 +793,19 @@ mod vault_lock {
// the guard has just been dropped (no live borrow), and we
// are the only owner. Reclaiming the Box runs the
// `RwLock`'s Drop, which closes the file fd.
- unsafe { drop(Box::from_raw(self.rwlock)) };
+ #[allow(unsafe_code)]
+ unsafe {
+ drop(Box::from_raw(self.rwlock))
+ };
}
}
}
use vault_lock::VaultLock;
-/// Why a wallet-id hex string failed the canonical-form check.
-///
-/// `WalletId::to_hex` only ever emits 64 lowercase hex chars, so every
-/// seam that parses a wallet id back enforces exactly that shape in one
-/// place via [`wallet_id_hex_to_bytes`]; this enum lets each caller map
-/// the reason onto its own error type with the right message.
+/// Why a wallet-id hex string failed the canonical-form check, so each
+/// caller of [`wallet_id_hex_to_bytes`] can map the reason onto its own
+/// error type and message.
enum WalletIdHexError {
/// Not exactly 64 characters.
WrongLength,
@@ -795,10 +815,9 @@ enum WalletIdHexError {
NotHex,
}
-/// Decode a 64-lowercase-hex-char wallet id into its 32 bytes, enforcing
-/// the canonical form `WalletId::to_hex` writes (64 chars, lowercase).
-/// The single seam both the on-disk outer-key check and the SPI
-/// service-string parse go through so the contract lives in one place.
+/// Decode a wallet id into 32 bytes, enforcing the canonical form
+/// `WalletId::to_hex` writes (64 lowercase hex chars). The single seam
+/// for both the on-disk outer-key check and the SPI service-string parse.
fn wallet_id_hex_to_bytes(s: &str) -> Result<[u8; 32], WalletIdHexError> {
if s.len() != 64 {
return Err(WalletIdHexError::WrongLength);
@@ -811,13 +830,10 @@ fn wallet_id_hex_to_bytes(s: &str) -> Result<[u8; 32], WalletIdHexError> {
Ok(out)
}
-/// Decode a wallet-id hex string (the on-disk outer key) into the
-/// 32-byte form the AAD construction expects. A malformed key here is
-/// an on-disk integrity failure — the format-layer parse already
-/// constrains entries to JSON object semantics, but the outer key is
-/// a free-form string at the type level, so the bytes-back check is a
-/// defence-in-depth structural guard. Off-canonical (uppercase / wrong
-/// length / non-hex) keys are all rejected as corruption.
+/// Decode the on-disk outer-key wallet hex into the 32 bytes the AAD
+/// expects. The outer key is a free-form string at the type level, so
+/// this bytes-back check is a defence-in-depth structural guard; any
+/// off-canonical key is rejected as corruption.
pub(super) fn decode_wallet_id_hex(s: &str) -> Result<[u8; 32], SecretStoreError> {
wallet_id_hex_to_bytes(s).map_err(|_| SecretStoreError::MalformedVault)
}
@@ -843,14 +859,10 @@ fn parse_service(service: &str) -> Result {
Ok(WalletId::from(bytes))
}
-/// A `(wallet_id, label)` row in an [`EncryptedFileStore`].
-///
-/// Holds a [`Clone`]d handle to the parent store so each credential
-/// goes through the same public store API (and the same single-lock
-/// critical section per operation). All four operations re-validate
-/// `user` (label); the store key is resident on the inner so a
-/// wrong-passphrase race cannot happen at the credential layer — the
-/// open already failed if the passphrase was wrong.
+/// A `(wallet_id, label)` row in an [`EncryptedFileStore`]. Holds a
+/// cloned handle to the parent so each op goes through the same store API
+/// and single-lock critical section. All ops re-validate the label; the
+/// passphrase was already verified at open, so no wrong-pass race here.
pub struct EncryptedFileCredential {
store: EncryptedFileStore,
wallet_id: WalletId,
@@ -869,6 +881,13 @@ impl std::fmt::Debug for EncryptedFileCredential {
impl CredentialApi for EncryptedFileCredential {
fn set_secret(&self, secret: &[u8]) -> KeyringResult<()> {
let _ = validated_label(&self.label).map_err(SecretStoreError::from)?;
+ // Cap before wrapping so an oversized secret is never materialized.
+ if secret.len() > MAX_SECRET_LEN {
+ return Err(KeyringError::from(SecretStoreError::SecretTooLarge {
+ found: secret.len(),
+ max: MAX_SECRET_LEN,
+ }));
+ }
self.store
.put_bytes(
&self.wallet_id,
@@ -881,6 +900,8 @@ impl CredentialApi for EncryptedFileCredential {
fn get_secret(&self) -> KeyringResult> {
let _ = validated_label(&self.label).map_err(SecretStoreError::from)?;
match self.store.get_bytes(&self.wallet_id, &self.label) {
+ // SPI contract forces a bare Vec; caller owns disposal —
+ // prefer SecretStore::get for a zeroizing SecretBytes.
Ok(Some(v)) => Ok(v.expose_secret().to_vec()),
Ok(None) => Err(KeyringError::NoEntry),
Err(e) => Err(e.into()),
@@ -921,6 +942,11 @@ impl CredentialStoreApi for EncryptedFileStore {
STORE_ID.to_string()
}
+ /// Build a credential for `(service, user)`. SPI-direct consumers:
+ /// format the returned [`KeyringError`] with `Display`, never `Debug`
+ /// — byte-bearing variants embed raw bytes in `Debug` (CWE-209/
+ /// CWE-532). Prefer the typed
+ /// [`SecretStore`](crate::secrets::SecretStore) path.
fn build(
&self,
service: &str,
@@ -950,10 +976,9 @@ impl CredentialStoreApi for EncryptedFileStore {
impl std::fmt::Debug for EncryptedFileStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- // `try_lock` rather than `lock_inner` so a Debug invoked from
- // a panic path while the same thread already holds the state
- // lock cannot deadlock or double-panic. Poison is folded into
- // the same fallback display as contention.
+ // `try_lock` (not `lock_inner`) so a Debug from a panic path that
+ // already holds the lock cannot deadlock; poison folds into the
+ // same fallback as contention.
let path: PathBuf = match self.inner.try_lock() {
Ok(guard) => guard.path.clone(),
Err(_) => PathBuf::from(""),
@@ -964,14 +989,11 @@ impl std::fmt::Debug for EncryptedFileStore {
}
}
-/// Project an entry-level `crypto::open` result into the typed
-/// distinction the secret backend exposes. The verify-token has already
-/// passed at every caller (open / get / rekey), so a
-/// `SecretStoreError::Decrypt` here is corruption or tampering of the
-/// individual entry — **not** a wrong passphrase. Logs the non-secret
-/// `(wallet_id, label)` pair at error level (never the secret) and
-/// maps to `SecretStoreError::Corruption`. Every other variant rides
-/// through unchanged.
+/// Map an entry-level `crypto::open` failure to the typed distinction.
+/// The verify-token has already passed at every caller, so a `Decrypt`
+/// here is entry corruption/tampering, NOT a wrong passphrase — logs the
+/// non-secret `(wallet_id, label)` and maps to `Corruption`; other
+/// variants pass through.
fn entry_decrypt_or_corruption(
wallet_hex: &str,
label: &str,
@@ -1000,17 +1022,37 @@ fn check_perms(meta: &fs::Metadata) -> Result<(), SecretStoreError> {
Ok(())
}
-// INTENTIONAL: Windows ACL read-check deferred to a follow-up PR —
-// tracked at https://github.com/dashpay/platform/issues/3754. Vault
-// file mode hardening on Windows requires GetSecurityInfo via
-// `windows-acl` or `winapi`; out of scope for the secrets-feature
-// landing. Operators on Windows MUST set ACLs manually until the
-// follow-up lands.
+// INTENTIONAL: Windows ACL read-check (needs GetSecurityInfo) deferred to
+// https://github.com/dashpay/platform/issues/3754 — set ACLs manually.
#[cfg(not(unix))]
fn check_perms(_meta: &fs::Metadata) -> Result<(), SecretStoreError> {
Ok(())
}
+/// Refuse a group/other-WRITABLE vault parent (`mode & 0o022`). The
+/// threat is rename/unlink/replace, which POSIX gates on directory WRITE,
+/// so this targets write bits only — a 0o755 read-only parent leaks
+/// filenames but not the 0600 contents and is the common layout.
+/// `DirBuilder::mode` only hardens dirs this process creates, so a
+/// pre-existing loose dir must still be checked here.
+#[cfg(unix)]
+fn check_parent_perms(parent: &Path) -> Result<(), SecretStoreError> {
+ use std::os::unix::fs::MetadataExt;
+ let meta = fs::metadata(parent).map_err(|e| SecretStoreError::io_at(parent, e))?;
+ let mode = meta.mode() & 0o777;
+ if mode & 0o022 != 0 {
+ return Err(SecretStoreError::InsecureParentDir { mode });
+ }
+ Ok(())
+}
+
+// INTENTIONAL: Windows parent-dir ACL check deferred to the same
+// follow-up as `check_perms` — https://github.com/dashpay/platform/issues/3754.
+#[cfg(not(unix))]
+fn check_parent_perms(_parent: &Path) -> Result<(), SecretStoreError> {
+ Ok(())
+}
+
#[cfg(unix)]
fn set_restrictive_perms(f: &fs::File) -> Result<(), SecretStoreError> {
use std::os::unix::fs::PermissionsExt;
@@ -1018,12 +1060,8 @@ fn set_restrictive_perms(f: &fs::File) -> Result<(), SecretStoreError> {
Ok(())
}
-// INTENTIONAL: Windows ACL tightening deferred to the same follow-up
-// as `check_perms` above — tracked at
-// https://github.com/dashpay/platform/issues/3754. Vault file mode
-// hardening on Windows requires SetSecurityInfo via `windows-acl` or
-// `winapi`; out of scope for the secrets-feature landing. Operators on
-// Windows MUST set ACLs manually until the follow-up lands.
+// INTENTIONAL: Windows ACL tightening (needs SetSecurityInfo) deferred to
+// https://github.com/dashpay/platform/issues/3754 — set ACLs manually.
#[cfg(not(unix))]
fn set_restrictive_perms(_f: &fs::File) -> Result<(), SecretStoreError> {
Ok(())
@@ -1055,6 +1093,13 @@ mod tests {
}
fn vault_path(dir: &Path) -> PathBuf {
+ // Tighten the umask-0002 tempdir (0o775) to 0o700 so it passes the
+ // parent-dir perm check (dedicated perm tests use a subdir).
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
+ }
dir.join("vault.pwsvault")
}
@@ -1086,9 +1131,7 @@ mod tests {
#[test]
fn open_creates_vault_file_on_first_open() {
- // Resident-vault model: open() creates a usable vault file even
- // without any subsequent put, so a second open() of the same
- // path observes a real on-disk file (modulo the lock).
+ // open() creates a usable vault file even without a put.
let dir = tempfile::tempdir().unwrap();
let path = vault_path(dir.path());
{
@@ -1135,10 +1178,8 @@ mod tests {
#[test]
fn open_acquires_exclusive_lock_until_drop() {
- // Resident-vault model: a second open() of the same path while
- // the first store is alive returns AlreadyLocked immediately
- // (no retry, no wait). Once the first store drops the lock is
- // released and a fresh open() succeeds.
+ // A second open() while the first store is alive returns
+ // AlreadyLocked; once it drops, a fresh open() succeeds.
let dir = tempfile::tempdir().unwrap();
let path = vault_path(dir.path());
let s1 = store_at(&path);
@@ -1376,6 +1417,62 @@ mod tests {
);
}
+ /// A parent-dir fsync failure that occurs AFTER `persist()` has already
+ /// committed the vault to disk must NOT cause `put` to return an error or
+ /// roll back the in-memory entry. The vault is already on disk; propagating
+ /// the post-persist error would diverge in-memory state from the on-disk file.
+ ///
+ /// Trigger: set the parent dir to `0o300` (write + execute, no read) so
+ /// `persist()` (a rename — needs write) succeeds but the subsequent
+ /// `fs::File::open(parent)` for fsync fails (needs read).
+ #[cfg(unix)]
+ #[test]
+ fn put_succeeds_when_parent_dir_fsync_fails_post_persist() {
+ use std::os::unix::fs::PermissionsExt;
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ let s = store_at(&path);
+ entry(&s, wid(1), "seed")
+ .set_secret(b"first-value")
+ .unwrap();
+
+ // Tighten the parent dir to write+execute only (no read):
+ // - NamedTempFile::new_in : needs write (0o200) + execute (0o100) → OK
+ // - persist() (rename) : needs write on directory → OK
+ // - fs::File::open(parent) : needs read (0o400) — MISSING → FAILS
+ // This forces the post-persist parent-dir fsync to fail, which is the
+ // scenario under test.
+ fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o300)).unwrap();
+ let result = entry(&s, wid(1), "seed").set_secret(b"second-value");
+ fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
+
+ // Post-fix: put must succeed even though the parent-dir fsync failed —
+ // the vault was already committed via persist().
+ assert!(
+ result.is_ok(),
+ "put must succeed even when parent-dir fsync fails post-persist; got {result:?}"
+ );
+
+ // In-memory state was NOT rolled back.
+ assert_eq!(
+ entry(&s, wid(1), "seed").get_secret().unwrap(),
+ b"second-value",
+ "in-memory state must reflect the committed put"
+ );
+
+ // Drop `s` (releases the vault lock) before opening a second store on
+ // the same file — `EncryptedFileStore::open` is exclusive by design.
+ drop(s);
+
+ // The vault on disk reflects the committed value.
+ let reopened = EncryptedFileStore::open(&path, SecretString::new("pw-correct")).unwrap();
+ assert_eq!(
+ entry(&reopened, wid(1), "seed").get_secret().unwrap(),
+ b"second-value",
+ "vault on disk must have the committed value after post-persist fsync failure"
+ );
+ }
+
#[test]
fn get_corruption_after_verify_token_is_not_wrong_passphrase() {
let dir = tempfile::tempdir().unwrap();
@@ -1455,6 +1552,174 @@ mod tests {
);
}
+ /// The no-plaintext-at-rest guarantee also holds through the public
+ /// `SecretStore::set` path (which writes an unprotected envelope sealed
+ /// under the vault key), not just the raw SPI entry path.
+ #[test]
+ fn no_plaintext_in_vault_file_via_secret_store_set() {
+ use crate::secrets::SecretStore;
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ let store = SecretStore::file(&path, SecretString::new("pw-correct")).unwrap();
+ store
+ .set(
+ &wid(1),
+ "seed",
+ &SecretBytes::from_slice(b"PLAINTEXTNEEDLE"),
+ )
+ .unwrap();
+ let raw = fs::read(&path).unwrap();
+ assert!(
+ raw.windows(b"PLAINTEXTNEEDLE".len())
+ .all(|w| w != b"PLAINTEXTNEEDLE"),
+ "plaintext leaked into vault file via SecretStore::set"
+ );
+ }
+
+ /// A blank passphrase is rejected at `open` →
+ /// `BlankPassphrase`; no vault file (or lock sidecar) is created.
+ #[test]
+ fn open_rejects_blank_passphrase() {
+ for blank in [
+ SecretString::empty(),
+ SecretString::new(""),
+ SecretString::new(" "),
+ SecretString::new("\t\n"),
+ ] {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ let err = EncryptedFileStore::open(&path, blank).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::BlankPassphrase),
+ "blank passphrase must be rejected, got {err:?}"
+ );
+ assert!(!path.exists(), "no vault file for a blank passphrase");
+ assert!(
+ !lock_path_for(&path).exists(),
+ "no lock sidecar for a blank passphrase"
+ );
+ }
+ }
+
+ /// A blank passphrase is rejected at `rekey`; the resident
+ /// vault, key, and on-disk file are UNCHANGED — the original passphrase
+ /// still reads every entry, live and after reopen.
+ #[test]
+ fn rekey_rejects_blank_passphrase_vault_unchanged() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ let s = store_at(&path); // real "pw-correct"
+ entry(&s, wid(1), "seed").set_secret(b"v1").unwrap();
+ for blank in [SecretString::empty(), SecretString::new(" ")] {
+ let err = s.rekey(blank).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::BlankPassphrase),
+ "blank rekey must be rejected, got {err:?}"
+ );
+ }
+ // Old passphrase still reads the entry, live…
+ assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"v1");
+ // …and after a clean reopen under the original passphrase.
+ drop(s);
+ let s2 = store_at(&path);
+ assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"v1");
+ }
+
+ /// `open_unprotected` permits a deliberate keyless vault that
+ /// round-trips; a real-passphrase `open` of that keyless vault then
+ /// fails with `WrongPassphrase` (it is keyless, not real-pass).
+ #[test]
+ fn open_unprotected_permits_keyless_vault() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ {
+ let s = EncryptedFileStore::open_unprotected(&path).unwrap();
+ entry(&s, wid(1), "seed")
+ .set_secret(b"keyless-seed")
+ .unwrap();
+ }
+ {
+ let s = EncryptedFileStore::open_unprotected(&path).unwrap();
+ assert_eq!(
+ entry(&s, wid(1), "seed").get_secret().unwrap(),
+ b"keyless-seed"
+ );
+ }
+ let err = EncryptedFileStore::open(&path, SecretString::new("real")).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassphrase),
+ "real-pass open of a keyless vault must fail, got {err:?}"
+ );
+ }
+
+ /// Empty→real passphrase migration via `rekey`. After rekey,
+ /// `open(real)` reads every entry; the keyless door no longer opens it;
+ /// no `.bak`/`.tmp` residue beside the vault.
+ #[test]
+ fn empty_to_real_rekey_migration() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ {
+ let s = EncryptedFileStore::open_unprotected(&path).unwrap();
+ entry(&s, wid(1), "seed").set_secret(b"migrate-me").unwrap();
+ s.rekey(SecretString::new("real-pass")).unwrap();
+ // The live handle keeps working post-rekey.
+ assert_eq!(
+ entry(&s, wid(1), "seed").get_secret().unwrap(),
+ b"migrate-me"
+ );
+ }
+ // Reopen under the real passphrase reads the entry.
+ {
+ let s = EncryptedFileStore::open(&path, SecretString::new("real-pass")).unwrap();
+ assert_eq!(
+ entry(&s, wid(1), "seed").get_secret().unwrap(),
+ b"migrate-me"
+ );
+ }
+ // The keyless door no longer opens it.
+ let err = EncryptedFileStore::open_unprotected(&path).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassphrase),
+ "keyless open after migration must fail, got {err:?}"
+ );
+ // No .bak / .tmp residue (mirrors rekey_reencrypts_and_old_passphrase_fails).
+ for sibling in fs::read_dir(dir.path()).unwrap().flatten() {
+ let name = sibling.file_name();
+ let name = name.to_string_lossy();
+ assert!(
+ !name.ends_with(".bak") && !name.ends_with(".tmp"),
+ "unexpected residue: {name}"
+ );
+ }
+ }
+
+ /// Crash-safety: a disk-write failure mid-rekey leaves the
+ /// pre-rekey keyless vault intact and readable via `open_unprotected`
+ /// (mirrors rekey_does_not_corrupt_on_disk_temp_failure).
+ #[cfg(unix)]
+ #[test]
+ fn empty_to_real_rekey_crash_safe_stays_keyless() {
+ use std::os::unix::fs::PermissionsExt;
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ let s = EncryptedFileStore::open_unprotected(&path).unwrap();
+ entry(&s, wid(1), "seed").set_secret(b"keyless").unwrap();
+
+ // Read-only parent → the rekey atomic temp-write fails.
+ fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
+ let err = s.rekey(SecretString::new("real-pass")).unwrap_err();
+ assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}");
+ fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
+
+ // The live handle still serves the pre-rekey keyless vault…
+ assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"keyless");
+ // …and on disk it is still the keyless vault.
+ drop(s);
+ let s2 = EncryptedFileStore::open_unprotected(&path).unwrap();
+ assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"keyless");
+ }
+
#[test]
fn build_rejects_malformed_service() {
let dir = tempfile::tempdir().unwrap();
@@ -1552,13 +1817,9 @@ mod tests {
#[test]
fn inflated_kdf_params_fail_open_with_kdf_failure() {
- // A vault whose JSON declares m_kib = u32::MAX must be refused
- // at open() with KdfFailure — before the verify-token is
- // derived and without the ~4 TiB allocation the inflated param
- // would demand. Under the resident-vault model this surfaces at
- // open() rather than on first get(). Drop the store BEFORE
- // patching the on-disk file so the drop-time sync cannot
- // overwrite our injected corruption.
+ // A JSON m_kib = u32::MAX must be refused at open() with
+ // KdfFailure, before the ~4 TiB allocation it would demand. Drop
+ // the store before patching disk so the drop-sync can't undo it.
let dir = tempfile::tempdir().unwrap();
let path = vault_path(dir.path());
{
@@ -1613,14 +1874,10 @@ mod tests {
);
}
- /// Two threads share a cloned [`EncryptedFileStore`] handle (same
- /// shape [`EncryptedFileCredential`] uses internally): one hammers
- /// `put_bytes` + `get_bytes`, the other calls `rekey`. The single
- /// state lock serializes every put/get/rekey, so a put can never
- /// capture an old key and insert under a newly-swapped vault — every
- /// `get` returns either the right plaintext, `Ok(None)`, or a clean
- /// typed error, NEVER garbled bytes from a mis-keyed seal (which
- /// would surface as `Corruption`).
+ /// Two threads share a cloned handle: one hammers put/get, the other
+ /// rekeys. The single state lock serializes them, so a `get` only ever
+ /// returns the right plaintext, `Ok(None)`, or a clean typed error —
+ /// never garbled bytes from a put that sealed under a swapped key.
#[test]
fn rekey_does_not_race_put_into_corruption() {
let dir = tempfile::tempdir().unwrap();
@@ -1629,19 +1886,15 @@ mod tests {
let writer_store = store.clone();
let rekeyer_store = store.clone();
- // Iteration counts are tuned for cost: every rekey runs Argon2
- // at the default-target params, so the rekey loop dominates
- // wall-clock. 16 rekeys overlap a 200-iter put loop reliably
- // enough to hit the pre-fix race window on the test runner
- // without dragging the suite out.
+ // Counts tuned for cost: each rekey runs Argon2, so 16 rekeys
+ // overlapping a 200-iter put loop hits the race window affordably.
const PUT_ITERS: usize = 200;
const REKEY_ITERS: usize = 16;
let wallet = wid(7);
let label = "racy";
- // A fixed-prefix payload byte vector — never built with
- // `format!` so the in-source secrets-guard scanner does not
- // flag this test as a sink/expose_secret pairing.
+ // Fixed prefix, never built with `format!` so the secrets-guard
+ // scanner does not flag this as a sink/expose_secret pairing.
const PREFIX: &[u8] = b"payload-";
let writer = std::thread::spawn(move || {
let mut buf = Vec::with_capacity(PREFIX.len() + 4);
@@ -1654,9 +1907,8 @@ mod tests {
.expect("put");
match writer_store.get_bytes(&wallet, label) {
Ok(Some(bytes)) => {
- // Must be one of OUR payloads — never random
- // bytes from a mis-keyed seal. Compare only
- // length + prefix; never log the bytes.
+ // Must be one of OUR payloads, never mis-keyed
+ // garbage. Check length + prefix; never log bytes.
let got = bytes.expose_secret();
assert!(got.starts_with(PREFIX), "garbled get-after-put");
assert_eq!(got.len(), PREFIX.len() + 4);
@@ -1668,10 +1920,8 @@ mod tests {
});
let rekeyer = std::thread::spawn(move || {
- // Alternate two passphrases so consecutive rekeys actually
- // change the resident key (the salt rerolls regardless, but
- // alternating distinct passphrases is the operator-facing
- // model and keeps the race window real).
+ // Alternate passphrases so consecutive rekeys change the
+ // resident key, keeping the race window real.
let passphrases = ["pw-A", "pw-B"];
for i in 0..REKEY_ITERS {
rekeyer_store
@@ -1684,20 +1934,23 @@ mod tests {
rekeyer.join().expect("rekeyer thread");
}
- /// A bare relative filename makes `Path::parent()` return `Some("")`,
- /// which `NamedTempFile::new_in("")` and the Unix parent-dir fsync
- /// both reject; the `normalized_parent` helper rewrites the empty
- /// parent to ".". Switch cwd to a temp dir for the test scope so we
- /// exercise the bare-filename path without scribbling in the
- /// workspace.
+ /// A bare filename makes `Path::parent()` return `Some("")`, which
+ /// `normalized_parent` rewrites to "."; exercise that path in a temp
+ /// cwd so nothing lands in the workspace.
#[test]
fn open_and_put_with_bare_filename_uses_cwd() {
- // A static mutex serializes cwd-changing tests so they cannot
- // race each other across the suite.
+ // Serialize cwd-changing tests so they cannot race each other.
static CWD_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _g = CWD_GUARD.lock().unwrap_or_else(|p| p.into_inner());
let dir = tempfile::tempdir().unwrap();
+ // Tighten the cwd-parent so the parent-dir perm check passes (a
+ // umask-0002 tempdir is group-writable at 0o775).
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
+ }
let prior = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
// Tear-down guard so a panic still restores cwd.
@@ -1720,9 +1973,8 @@ mod tests {
assert!(dir.path().join("vault.pwsvault").exists());
}
- /// The lock sidecar must refuse to traverse a pre-existing symlink
- /// at the lock path on Unix. Without `O_NOFOLLOW` an attacker could
- /// redirect the lock file's open to an unrelated inode.
+ /// The lock-sidecar open must refuse a pre-existing symlink
+ /// (`O_NOFOLLOW`) so an attacker can't redirect it to another inode.
#[cfg(unix)]
#[test]
fn vault_lock_rejects_symlink_at_lock_path() {
@@ -1731,9 +1983,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let path = vault_path(dir.path());
let lock = lock_path_for(&path);
- // Point the lock path at /dev/null. Any successful open of the
- // symlink would land on /dev/null's inode; O_NOFOLLOW makes the
- // open itself fail with ELOOP.
+ // O_NOFOLLOW makes the open of this symlink fail with ELOOP.
symlink("/dev/null", &lock).unwrap();
let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct"))
@@ -1743,4 +1993,153 @@ mod tests {
"expected an Io error from O_NOFOLLOW refusal, got {err:?}"
);
}
+
+ /// A group/other-WRITABLE parent is refused at open (it would let a
+ /// peer rename/replace the vault despite its 0600); a read-only 0o750
+ /// parent is fine.
+ #[cfg(unix)]
+ #[test]
+ fn writable_parent_dir_is_refused() {
+ use std::os::unix::fs::PermissionsExt;
+ let dir = tempfile::tempdir().unwrap();
+ let sub = dir.path().join("vaultdir");
+ fs::create_dir(&sub).unwrap();
+ // Group-writable (0o770) trips the write-bit check. Build the path
+ // directly — `vault_path` would tighten the dir back to 0o700.
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o770)).unwrap();
+ let path = sub.join("vault.pwsvault");
+ let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct"))
+ .expect_err("writable parent dir must be refused");
+ assert!(
+ matches!(err, SecretStoreError::InsecureParentDir { mode } if mode & 0o022 != 0),
+ "got {err:?}"
+ );
+ // Dropping the write bits (still group-readable at 0o750) lets the
+ // open succeed: read-only group access is not a rename threat.
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o750)).unwrap();
+ let _s = store_at(&path);
+ }
+
+ /// An oversized secret is rejected at the write boundary with
+ /// `SecretTooLarge`, and the vault stays openable — the per-secret
+ /// cap prevents the shared document from being inflated past the
+ /// read-side ceiling.
+ #[test]
+ fn oversized_secret_rejected_and_vault_stays_openable() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ {
+ let s = store_at(&path);
+ entry(&s, wid(1), "ok").set_secret(b"small").unwrap();
+ let too_big = vec![0xABu8; MAX_SECRET_LEN + 1];
+ let err = entry(&s, wid(1), "huge").set_secret(&too_big).unwrap_err();
+ // Surfaces through the SPI as BadStoreFormat carrying the
+ // secret-free SecretTooLarge message.
+ assert!(
+ matches!(&err, KeyringError::BadStoreFormat(m)
+ if *m == SecretStoreError::SecretTooLarge {
+ found: MAX_SECRET_LEN + 1,
+ max: MAX_SECRET_LEN,
+ }.to_string()),
+ "got {err:?}"
+ );
+ // The earlier good entry is still readable on this handle.
+ assert_eq!(entry(&s, wid(1), "ok").get_secret().unwrap(), b"small");
+ }
+ // The vault reopens cleanly — the oversized put never landed.
+ let s2 = store_at(&path);
+ assert_eq!(entry(&s2, wid(1), "ok").get_secret().unwrap(), b"small");
+ assert!(matches!(
+ entry(&s2, wid(1), "huge").get_secret(),
+ Err(KeyringError::NoEntry)
+ ));
+ }
+
+ /// An in-bounds KDF-param shift on a correct-passphrase vault is
+ /// rejected at open with `WrongPassphrase` — driven by the changed
+ /// DERIVED KEY, not the AAD binding (which `verify_aad_binds_salt_and_
+ /// kdf_params` covers).
+ #[test]
+ fn header_tamper_kdf_shift_smoke_test() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ {
+ let s = store_at(&path);
+ entry(&s, wid(1), "seed").set_secret(b"value").unwrap();
+ }
+ let mut vault = read_vault_at(&path).unwrap().unwrap();
+ // Shift to still-valid-but-weaker params (defaults are 64 MiB/t=3;
+ // halving m_kib and dropping t stays above the 19 MiB / t=2 floor).
+ vault.kdf.m_kib /= 2;
+ vault.kdf.t -= 1;
+ assert!(
+ vault.kdf.enforce_bounds().is_ok(),
+ "shift must stay in bounds"
+ );
+ write_vault_at(&path, &vault).unwrap();
+ let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct"))
+ .expect_err("KDF-param shift must fail the verify-token");
+ assert!(
+ matches!(err, SecretStoreError::WrongPassphrase),
+ "got {err:?}"
+ );
+ }
+
+ /// A flipped salt byte on a correct-passphrase vault is rejected at
+ /// open with `WrongPassphrase` — driven by the changed DERIVED KEY
+ /// (salt feeds the KDF), not the AAD binding.
+ #[test]
+ fn header_tamper_flipped_salt_smoke_test() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ {
+ let s = store_at(&path);
+ entry(&s, wid(1), "seed").set_secret(b"value").unwrap();
+ }
+ let mut vault = read_vault_at(&path).unwrap().unwrap();
+ vault.salt[0] ^= 0x01;
+ write_vault_at(&path, &vault).unwrap();
+ let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct"))
+ .expect_err("flipped salt must fail open");
+ assert!(
+ matches!(err, SecretStoreError::WrongPassphrase),
+ "got {err:?}"
+ );
+ }
+
+ /// A flipped entry NONCE byte (verify-token intact) surfaces as
+ /// `Corruption`: the per-entry AEAD-open fails its tag under the
+ /// correct key, mirroring the ciphertext-flip route.
+ #[test]
+ fn flipped_entry_nonce_is_corruption() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = vault_path(dir.path());
+ let s = store_at(&path);
+ entry(&s, wid(1), "seed").set_secret(b"value").unwrap();
+ let mut vault = s.test_read_vault_from_disk().unwrap().unwrap();
+ vault
+ .wallets
+ .get_mut(&wid(1).to_hex())
+ .unwrap()
+ .get_mut("seed")
+ .unwrap()
+ .nonce[0] ^= 0x01;
+ s.test_write_vault_to_disk(&vault).unwrap();
+ s.test_reload_from_disk().unwrap();
+ let err = entry(&s, wid(1), "seed").get_secret().unwrap_err();
+ assert!(is_corruption(&err), "unexpected error: {err:?}");
+ }
+
+ /// A secret exactly at the cap is accepted (boundary is inclusive).
+ #[test]
+ fn secret_exactly_at_cap_is_accepted() {
+ let dir = tempfile::tempdir().unwrap();
+ let s = store_at(&vault_path(dir.path()));
+ let at_cap = vec![0x5Au8; MAX_SECRET_LEN];
+ entry(&s, wid(1), "atcap").set_secret(&at_cap).unwrap();
+ assert_eq!(
+ entry(&s, wid(1), "atcap").get_secret().unwrap().len(),
+ MAX_SECRET_LEN
+ );
+ }
}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/keyring.rs b/packages/rs-platform-wallet-storage/src/secrets/keyring.rs
index 618706276e3..4d58d407656 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/keyring.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/keyring.rs
@@ -1,57 +1,49 @@
//! OS-keyring construction helper.
//!
-//! Built on `keyring-core 1.0.0` (the SPI library) plus the
-//! per-platform credential-store crates; the `keyring` 4.x sample CLI
-//! crate itself is intentionally not a dependency.
+//! Built on `keyring-core 1.0.0` (the SPI) plus the per-platform
+//! credential-store crates; the `keyring` 4.x sample CLI crate is
+//! deliberately not a dependency. There is no crate-local wrapper —
+//! [`default_credential_store`]'s return value is used directly via
+//! [`keyring_core::api::CredentialStoreApi`] or installed as the process
+//! default via [`keyring_core::set_default_store`].
//!
-//! There is no crate-local wrapper around the per-platform store: a
-//! caller takes [`default_credential_store`]'s return value and either
-//! uses it directly via [`keyring_core::api::CredentialStoreApi`] or
-//! installs it as the process default via
-//! [`keyring_core::set_default_store`].
+//! Threat coverage: protects **A1** (other local user) and **A4** (lost
+//! laptop) where the platform encrypts items at rest and scopes them to
+//! the user. Does **not** cover **A2/A3** same-user malware (most OS
+//! keyrings hand the secret to any same-user process) or **A5** keyring
+//! scraping; headless Linux without Secret Service fails closed
+//! ([`keyring_core::Error::NoDefaultStore`]), never degrading to plaintext.
//!
-//! ## Threat coverage
+//! Metadata is enumerable plaintext: entries are keyed by `service =
+//! SERVICE_PREFIX + hex(wallet_id)` and `user = label`, stored as
+//! plaintext keyring metadata. Same-user list-only tooling can enumerate
+//! which wallet ids and slot kinds exist without unlocking a secret —
+//! dominated by the accepted A2/A3 residual, with no portable knob to
+//! redact it. Operators wanting metadata hiding should prefer
+//! [`EncryptedFileStore`](super::EncryptedFileStore), whose
+//! `(wallet_id, label)` map lives only inside the sealed vault.
//!
-//! Covers **A1** (other local user) and **A4** (lost laptop) where the
-//! platform encrypts keyring items at rest and scopes them to the user.
-//! Does **not** cover **A2/A3** same-user malware (most OS keyrings
-//! hand the secret to any same-user process that asks), **A5** if the
-//! keyring daemon itself is scraped, or **headless Linux** with no
-//! Secret Service — that fails closed
-//! ([`keyring_core::Error::NoDefaultStore`]), never degrades to
-//! plaintext.
-//!
-//! ### Per-OS reality
-//!
-//! - **Linux/FreeBSD:** Secret Service (gnome-keyring / KWallet) is the
-//! sole backend. It requires a D-Bus session + unlocked collection;
-//! headless / SSH / CI boxes frequently lack it, in which case the
-//! store fails closed with `NoDefaultStore` and the operator selects
-//! [`EncryptedFileStore`](super::EncryptedFileStore) explicitly.
-//! Items persist `UntilDelete`. Callers that need durable storage on
-//! a headless host should pin
-//! [`EncryptedFileStore`](super::EncryptedFileStore) instead.
-//! - **macOS:** Keychain ACL — a re-signed binary with the same
-//! code-signing identity is an accepted residual risk. Items persist
-//! `UntilDelete`.
-//! - **Windows:** Credential Manager / DPAPI is user-profile scoped; a
-//! same-user process can unprotect it. DPAPI is **not** a defense
-//! against same-user malware, only A1/A4. Items persist
-//! `UntilDelete`.
+//! Per-OS: items persist `UntilDelete` everywhere. Linux/FreeBSD use
+//! Secret Service (gnome-keyring / KWallet), which needs a D-Bus session
+//! and an unlocked collection. macOS Keychain ACL accepts a re-signed
+//! binary with the same code-signing identity (residual). Windows DPAPI
+//! is user-profile scoped and defends A1/A4 only, not same-user malware.
use std::sync::Arc;
use keyring_core::api::CredentialStoreApi;
use keyring_core::Error as KeyringError;
-/// Open the platform's default credential store, failing closed
-/// (typed [`KeyringError::NoDefaultStore`]) when none is reachable
-/// (headless / no Secret Service / no D-Bus). Never panics, never
-/// falls back to a weaker store.
+/// Open the platform's default credential store, failing closed (typed
+/// [`KeyringError::NoDefaultStore`]) when none is reachable (headless / no
+/// Secret Service / no D-Bus). Never panics, never falls back to a weaker
+/// store. The returned `Arc` works with
+/// [`keyring_core::set_default_store`] or builds entries directly.
///
-/// The returned `Arc` may be passed straight to
-/// [`keyring_core::set_default_store`] or used directly to build
-/// entries.
+/// SPI-direct consumers: format the returned [`KeyringError`] with
+/// `Display` (`{}`), **never** `Debug` — upstream `BadEncoding` /
+/// `BadDataFormat` variants embed raw bytes in `Debug` (CWE-209/CWE-532).
+/// The typed [`SecretStore`](super::SecretStore) path avoids the SPI error.
pub fn default_credential_store() -> Result, KeyringError>
{
platform_default_store()
@@ -59,10 +51,8 @@ pub fn default_credential_store() -> Result Result, KeyringError> {
- // Secret Service (gnome-keyring / KWallet) is the only OS backend.
- // No reachable D-Bus session / unlocked collection (headless, SSH,
- // CI) is fail-closed by design — the operator selects
- // EncryptedFileStore explicitly instead.
+ // Secret Service is the only backend; an unreachable D-Bus session
+ // (headless / SSH / CI) is fail-closed by design.
match dbus_secret_service_keyring_store::Store::new() {
Ok(s) => Ok(s),
Err(_) => Err(KeyringError::NoDefaultStore),
@@ -71,11 +61,8 @@ fn platform_default_store() -> Result,
#[cfg(target_os = "macos")]
fn platform_default_store() -> Result, KeyringError> {
- // `apple-native-keyring-store` >= 1.0 with the `keychain` feature
- // exposes `Store` under the `keychain` module, not at the crate
- // root (sibling backends — `dbus-secret-service-keyring-store`,
- // `windows-native-keyring-store` — do put `Store` at the root, hence
- // the asymmetric path).
+ // `apple-native-keyring-store` >= 1.0 exposes `Store` under the
+ // `keychain` module, not the crate root like the sibling backends.
match apple_native_keyring_store::keychain::Store::new() {
Ok(s) => Ok(s),
Err(_) => Err(KeyringError::NoDefaultStore),
diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs
index f44699f6ae7..6989a6e69e6 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs
@@ -1,54 +1,33 @@
//! Out-of-band storage for wallet secret material (mnemonic / seed /
//! xpriv), kept entirely off the SQLite persister's data path.
//!
-//! # Consumer entry point: [`SecretStore`]
-//!
-//! [`SecretStore`] is the public, never-leaking front door. Its read
-//! path ([`SecretStore::get`]) yields a zeroizing [`SecretBytes`] — a raw
-//! `Vec` never crosses this boundary — and its write path
-//! ([`SecretStore::set`]) takes `&SecretBytes`, so a caller cannot pass an
-//! unwrapped buffer. Errors surface as the typed [`SecretStoreError`],
-//! losslessly for the file arm (`WrongPassphrase` vs `Corruption` vs
-//! `AlreadyLocked` stay distinct).
-//!
-//! - [`SecretStore::file`] — Argon2id + XChaCha20-Poly1305 vault file.
-//! Recommended on **headless / server** hosts; fully self-contained.
-//! - [`SecretStore::os`] — the platform OS keyring, fail-closed on
-//! headless Linux. Recommended on **desktop**.
-//!
-//! # Internal SPI
-//!
-//! Below `SecretStore`, the backend SPI is upstream's
-//! [`keyring_core::api::CredentialStoreApi`] / [`CredentialApi`].
-//! [`EncryptedFileStore`] and [`default_credential_store`] expose that
-//! SPI directly; their `keyring_core::Error` projection is **lossy and
-//! string-only** (the typed distinction lives on the `SecretStore` path).
-//! Consumers should prefer `SecretStore`.
-//!
-//! - [`SecretBytes`] / [`SecretString`] — zeroize-on-drop wrappers.
-//! - [`SecretStoreError`] — the typed error returned by `SecretStore`
-//! and both backends, projected into `keyring_core::Error` for the SPI.
+//! Consumers use [`SecretStore`], the public never-leaking front door:
+//! reads yield a zeroizing [`SecretBytes`] (a raw `Vec` never crosses
+//! the boundary), writes take `&SecretBytes`, and errors are the typed
+//! [`SecretStoreError`] (lossless on the file arm). Pick a backend
+//! explicitly — [`SecretStore::file`] (Argon2id + XChaCha20-Poly1305
+//! vault, headless/server) or [`SecretStore::os`] (OS keyring, desktop;
+//! fail-closed on headless Linux). There is no silent fallback.
+//!
+//! Below `SecretStore` the backend SPI is upstream's
+//! [`keyring_core::api::CredentialStoreApi`] / [`CredentialApi`], exposed
+//! directly by [`EncryptedFileStore`] / [`default_credential_store`];
+//! its `keyring_core::Error` projection is lossy and string-only, so
+//! consumers should prefer `SecretStore`.
//!
//! [`CredentialApi`]: keyring_core::api::CredentialApi
//! [`CredentialStoreApi`]: keyring_core::api::CredentialStoreApi
//!
-//! Everything secret-bearing lives under this `src/secrets/` tree by
-//! design: `tests/secrets_scan.rs` scans only `src/sqlite/schema/` +
-//! `migrations/` and exempts this module, so this module owns its own
-//! review discipline (`tests/secrets_guard.rs`).
-//!
-//! # Memory hygiene
-//!
-//! At the SPI seam the upstream `get_secret` returns `Vec`;
-//! [`SecretStore::get`] wraps it via [`SecretBytes::new`] **immediately**
-//! (no named intermediate `Vec` binding) so the bare buffer's window is
-//! zero statements: `SecretBytes::new` moves the `Vec` into a
-//! `Zeroizing>` without copying.
-//!
-//! # Backend selection
+//! This `src/secrets/` tree is the sole secret-bearing module:
+//! `tests/secrets_scan.rs` exempts it, so it owns its own review
+//! discipline via `tests/secrets_guard.rs`.
//!
-//! Selection is an explicit operator decision — there is no silent
-//! fallback between the file vault and the OS keyring.
+//! Cryptographic wire format lives in [`mod@wire`]: the Tier-2
+//! envelope (`wire::envelope`) and the three AAD constructions
+//! (`wire::aad`) are bincode-encoded against a single `WIRE_CONFIG`, so
+//! a future bincode-config drift is caught by the golden-vector tests
+//! in `wire::envelope::tests` rather than silently corrupting every
+//! stored blob.
mod error;
mod file;
@@ -56,10 +35,15 @@ mod keyring;
mod secret;
mod store;
mod validate;
+mod wire;
pub use error::{IoError, OsKeyringErrorKind, SecretStoreError};
-pub use file::{EncryptedFileCredential, EncryptedFileStore, MAX_VAULT_SIZE_BYTES, SERVICE_PREFIX};
+pub use file::{
+ EncryptedFileCredential, EncryptedFileStore, MAX_SECRET_LEN, MAX_VAULT_SIZE_BYTES,
+ SERVICE_PREFIX,
+};
pub use keyring::default_credential_store;
-pub use secret::{SecretBytes, SecretString};
+pub use secret::{SecretBytes, SecretString, MIN_PASSPHRASE_LEN};
pub use store::SecretStore;
pub use validate::WalletId;
+pub use wire::envelope::MAX_PLAINTEXT_LEN;
diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs
index 6a2a593af33..87faf066efa 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs
@@ -9,25 +9,32 @@ use std::fmt;
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, Zeroizing};
-/// Pre-allocation capacity for [`SecretString`] buffers.
-///
-/// `mlock` is page-granular, so a sub-page buffer locks a whole page
-/// regardless; 4096 bytes also makes `String` reallocation (which
-/// leaves an un-zeroed freed buffer the allocator owns) virtually
-/// impossible for any human-entered passphrase or mnemonic.
+/// Pre-allocation capacity for [`SecretString`] buffers. `mlock` is
+/// page-granular (a sub-page buffer locks a whole page anyway), and 4096
+/// bytes makes a reallocation — which would leave an un-zeroed freed
+/// buffer behind — virtually impossible for any human-entered secret.
const DEFAULT_CAPACITY: usize = 4096;
+/// Minimal post-trim length floor for a vault passphrase or a Tier-2
+/// object password, in bytes. A **coarse** guard only: `1` means "merely
+/// non-blank" (the same outcome [`SecretString::is_blank`] enforces).
+///
+/// The library deliberately ships **no** password-strength estimator. The
+/// real entropy policy — zxcvbn-style strength, dictionary checks, UX
+/// feedback — is locale- and threat-specific and therefore the
+/// **consumer's** responsibility (documented in `SECRETS.md`). Baking a
+/// fixed estimator into a storage crate would be both too weak for some
+/// callers and too rigid for others.
+pub const MIN_PASSPHRASE_LEN: usize = 1;
+
/// Zeroize-on-drop wrapper for secret UTF-8 strings (BIP-39 mnemonic,
/// `EncryptedFileStore` passphrase).
///
-/// `Display`, `Deref`, `DerefMut`, `Serialize`, `PartialEq`, `Eq` are
-/// intentionally **not** implemented; read access is the explicit
-/// [`expose_secret`] only, and equality goes through
-/// [`subtle::ConstantTimeEq`] (`==` on secret bytes is forbidden, no
-/// exception, so future bridge code cannot inherit a non-constant-time
-/// path). `Debug` is redacted. `Zeroizing`
-/// wipes the buffer over its full capacity on drop; the buffer is
-/// best-effort `mlock`ed against swap.
+/// Read access is [`expose_secret`] only; equality goes through
+/// [`subtle::ConstantTimeEq`] (`==` is forbidden so bridge code cannot
+/// inherit a non-constant-time path). `Display`/`Deref`/`Serialize`/`Eq`
+/// are deliberately absent, `Debug` is redacted, and the buffer wipes
+/// over its full capacity on drop and is best-effort `mlock`ed.
///
/// [`expose_secret`]: SecretString::expose_secret
///
@@ -38,9 +45,8 @@ const DEFAULT_CAPACITY: usize = 4096;
/// let _ = a == b; // `==` on SecretString is forbidden; use ConstantTimeEq::ct_eq
/// ```
pub struct SecretString {
- // Field order is load-bearing: `inner` drops (and `Zeroizing` wipes
- // it) before `_lock` releases the page, so the buffer is wiped while
- // still mlock'ed.
+ // Field order is load-bearing: `inner` drops (Zeroizing wipes it)
+ // before `_lock` releases the page, so the wipe runs while mlock'ed.
inner: Zeroizing,
_lock: Option,
}
@@ -53,6 +59,10 @@ impl SecretString {
let cap = source.len().max(DEFAULT_CAPACITY);
let mut buf = String::with_capacity(cap);
buf.push_str(&source);
+ // Do not remove: wipes the moved-in plaintext source before it drops.
+ // A direct freed-buffer scan would require `unsafe`, which this crate
+ // forbids; the test `secret_string_new_zeroizes_string_source` instead
+ // pins the `String::zeroize` primitive and this call site.
source.zeroize();
let lock = region::lock(buf.as_ptr(), buf.capacity())
.map_err(|e| {
@@ -93,6 +103,18 @@ impl SecretString {
pub fn trimmed(&self) -> Self {
Self::new(self.inner.trim().to_string())
}
+
+ /// Whether the secret is empty or all Unicode-whitespace.
+ ///
+ /// Returns only blank-ness — never a borrowed view of the plaintext —
+ /// and uses [`str::trim`] (the Unicode `White_Space` property), so a
+ /// NBSP (`U+00A0`) trims to blank but a ZWSP (`U+200B`, not
+ /// `White_Space`) does not. This is the enforcement primitive behind
+ /// the Tier-1 blank-passphrase guard and the Tier-2 blank-object-
+ /// password reject. Always available — **not** feature-gated.
+ pub fn is_blank(&self) -> bool {
+ self.inner.trim().is_empty()
+ }
}
impl Default for SecretString {
@@ -120,9 +142,8 @@ impl fmt::Debug for SecretString {
}
impl ConstantTimeEq for SecretString {
- /// Constant-time compare over the equal-length region. Unequal
- /// lengths return `0` without revealing where they differ; the
- /// only observable is the (non-secret) length difference.
+ /// Constant-time compare. Unequal lengths return `0` without
+ /// revealing where they differ; the only leak is the non-secret length.
fn ct_eq(&self, other: &Self) -> subtle::Choice {
self.expose_secret()
.as_bytes()
@@ -130,6 +151,14 @@ impl ConstantTimeEq for SecretString {
}
}
+impl Zeroize for SecretString {
+ /// Wipe the buffer in place on a live value. `Drop` runs the same
+ /// wipe automatically; this lets a holder zeroize early.
+ fn zeroize(&mut self) {
+ self.inner.zeroize();
+ }
+}
+
impl From for SecretString {
fn from(s: String) -> Self {
Self::new(s)
@@ -142,18 +171,95 @@ impl From<&str> for SecretString {
}
}
+/// Deserialize a UTF-8 secret (a vault passphrase or a Tier-2 object
+/// password arriving via config), routing the owned `String` through
+/// [`SecretString::new`] — which zeroizes its source — so no
+/// intermediate plaintext buffer **we own** lingers (CWE-316).
+///
+/// Gated behind the dedicated, default-off `secret-serde` feature, NOT the
+/// crate's internal `serde` dep (which `secrets` already pulls): the gate
+/// is on the IMPL, so the impl is absent unless explicitly opted in, even
+/// though `serde` itself is compiled. There is deliberately **no**
+/// `Serialize` companion (a secret is read-from-config, never written
+/// back / round-tripped / logged), so this type cannot leak out through
+/// serde under any feature combination.
+///
+/// **Residual (documented, not closeable here):** the deserializer's own
+/// input buffer holds the cleartext before this visitor runs and is
+/// outside `SecretString`'s ownership, so it cannot be wiped here — feed
+/// secrets from a zeroizing source. Mirrors the Argon2 `Block` residual
+/// noted at `crypto::derive_key`.
+#[cfg(feature = "secret-serde")]
+impl<'de> serde::Deserialize<'de> for SecretString {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: serde::Deserializer<'de>,
+ {
+ struct SecretStringVisitor;
+
+ impl<'v> serde::de::Visitor<'v> for SecretStringVisitor {
+ type Value = SecretString;
+
+ fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("a secret string")
+ }
+
+ fn visit_str(self, v: &str) -> Result
+ where
+ E: serde::de::Error,
+ {
+ // Take ownership of the borrowed bytes, then hand the owned
+ // `String` to the zeroizing constructor below.
+ self.visit_string(v.to_owned())
+ }
+
+ fn visit_string(self, v: String) -> Result
+ where
+ E: serde::de::Error,
+ {
+ // `SecretString::new` zeroizes the moved-in `String`.
+ Ok(SecretString::new(v))
+ }
+ }
+
+ deserializer.deserialize_string(SecretStringVisitor)
+ }
+}
+
+/// Render the JSON schema as a plain `string` carrying **no** length or
+/// value policy: no `minLength`/`maxLength`/`pattern`/`format` (would leak
+/// a length policy) and no `example`/`default` (would embed a value)
+/// A short, value-free `description` marks sensitivity.
+///
+/// Gated behind the default-off `secret-schemars` feature (which implies
+/// `secret-serde`). Pulls in no `Serialize`/`Display` path.
+#[cfg(feature = "secret-schemars")]
+impl schemars::JsonSchema for SecretString {
+ fn schema_name() -> std::borrow::Cow<'static, str> {
+ std::borrow::Cow::Borrowed("SecretString")
+ }
+
+ fn schema_id() -> std::borrow::Cow<'static, str> {
+ std::borrow::Cow::Borrowed("platform_wallet_storage::secrets::SecretString")
+ }
+
+ fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
+ schemars::json_schema!({
+ "type": "string",
+ "description": "A secret string. Write-only: never serialized, never echoed."
+ })
+ }
+}
+
/// Zeroize-on-drop wrapper for secret **bytes**: BIP-32 seed
-/// (`[u8; 64]`), xpriv, Argon2 output, AEAD key, decrypted plaintext,
-/// ciphertext-in-flight.
+/// (`[u8; 64]`), xpriv, Argon2 output, AEAD key, decrypted plaintext.
///
-/// Not `Copy`; `Clone` is intentionally absent to enforce copy
-/// minimization — move it, or `expose_secret()` and copy
-/// deliberately into another wrapper. `Display`, `Deref`, `Serialize`,
-/// `PartialEq`, `Eq` are intentionally **not** implemented; equality
-/// goes through [`subtle::ConstantTimeEq`] only (`==` on secret bytes is
-/// forbidden, no exception, so future bridge code cannot inherit a
-/// non-constant-time path). `Debug` is redacted; the
-/// buffer is wiped on drop and best-effort `mlock`ed.
+/// `Clone` is absent to force deliberate copies (move it, or
+/// `expose_secret()` into another wrapper). Equality goes through
+/// [`subtle::ConstantTimeEq`] only (`==` is forbidden so bridge code
+/// cannot inherit a non-constant-time path). `Display`/`Deref`/`Serialize`
+/// /`Eq` are absent, `Debug` is redacted, and the buffer wipes on drop
+/// and is best-effort `mlock`ed.
///
/// ```compile_fail
/// use platform_wallet_storage::secrets::SecretBytes;
@@ -162,9 +268,8 @@ impl From<&str> for SecretString {
/// let _ = a == b; // `==` on SecretBytes is forbidden; use ConstantTimeEq::ct_eq
/// ```
pub struct SecretBytes {
- // Field order is load-bearing: `inner` drops (and `Zeroizing` wipes
- // it) before `_lock` releases the page, so the buffer is wiped while
- // still mlock'ed.
+ // Field order is load-bearing: `inner` drops (Zeroizing wipes it)
+ // before `_lock` releases the page, so the wipe runs while mlock'ed.
inner: Zeroizing>,
_lock: Option,
}
@@ -173,8 +278,8 @@ impl SecretBytes {
/// Wrap a byte vector, moving it into the wrapper and best-effort
/// `mlock`ing the buffer.
pub fn new(bytes: Vec) -> Self {
- // Lock only a non-empty allocation: an empty `Vec`'s `as_ptr()`
- // is dangling, and `region::lock` rejects a 0-length region.
+ // Skip an empty allocation: an empty `Vec`'s `as_ptr()` is
+ // dangling and `region::lock` rejects a 0-length region.
let lock = if bytes.capacity() > 0 {
region::lock(bytes.as_ptr(), bytes.capacity())
.map_err(|e| {
@@ -187,9 +292,6 @@ impl SecretBytes {
} else {
None
};
- // The move transfers ownership of the allocation into
- // `Zeroizing`; the source buffer is not copied, so there is
- // nothing left behind to wipe.
Self {
inner: Zeroizing::new(bytes),
_lock: lock,
@@ -230,15 +332,22 @@ impl SecretBytes {
}
impl ConstantTimeEq for SecretBytes {
- /// Fixed-width constant-time compare over the byte region — no
- /// length early-return. `subtle::ConstantTimeEq` on
- /// unequal-length slices yields `0` without leaking *where* they
- /// differ; the only observable is the (non-secret) length.
+ /// Constant-time compare, no length early-return. Unequal lengths
+ /// yield `0` without leaking *where* they differ; only the non-secret
+ /// length is observable.
fn ct_eq(&self, other: &Self) -> subtle::Choice {
self.inner.as_slice().ct_eq(other.inner.as_slice())
}
}
+impl Zeroize for SecretBytes {
+ /// Wipe the buffer in place on a live value. `Drop` runs the same
+ /// wipe automatically; this lets a holder zeroize early.
+ fn zeroize(&mut self) {
+ self.inner.zeroize();
+ }
+}
+
impl fmt::Debug for SecretBytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SecretBytes([REDACTED; {}])", self.inner.len())
@@ -264,6 +373,21 @@ mod tests {
assert_eq!(s.trimmed().expose_secret(), "abandon ability");
}
+ /// Two sound checks (a direct freed-buffer scan would be use-after-free,
+ /// and this crate forbids `unsafe`): (1) `String::zeroize` empties a
+ /// buffer — the primitive `new` relies on; (2) `new` copies the content
+ /// into the wrapper faithfully. That `new` actually calls
+ /// `source.zeroize()` on its moved-in source is pinned by the
+ /// do-not-remove comment at that call site, not asserted here.
+ #[test]
+ fn secret_string_new_zeroizes_string_source() {
+ let mut source = String::from("super secret seed material");
+ source.zeroize();
+ assert!(source.is_empty(), "String::zeroize must empty the source");
+ let s = SecretString::new(String::from("super secret seed material"));
+ assert_eq!(s.expose_secret(), "super secret seed material");
+ }
+
#[test]
fn secret_string_ct_eq_is_value_based() {
// Equality goes through `ConstantTimeEq` only.
@@ -281,6 +405,112 @@ mod tests {
assert_eq!(SecretString::default().len(), 0);
}
+ /// `is_blank()` truth table. The boundary deliberately
+ /// exercises Unicode whitespace — `str::trim` uses the `White_Space`
+ /// property, so NBSP (`U+00A0`) trims to blank but ZWSP (`U+200B`,
+ /// not `White_Space`) does not.
+ #[test]
+ fn is_blank_truth_table() {
+ // Blank inputs.
+ assert!(SecretString::empty().is_blank());
+ assert!(SecretString::new("").is_blank());
+ assert!(SecretString::new(" ").is_blank());
+ assert!(SecretString::new("\t\r\n ").is_blank());
+ assert!(
+ SecretString::new("\u{00A0}").is_blank(),
+ "NBSP is White_Space"
+ );
+ // Non-blank inputs.
+ assert!(!SecretString::new("pw").is_blank());
+ assert!(!SecretString::new(" pw ").is_blank());
+ assert!(
+ !SecretString::new("\u{200B}").is_blank(),
+ "ZWSP is NOT White_Space"
+ );
+ }
+
+ /// `is_blank` returns a `bool` and exposes no borrowed
+ /// plaintext, callable with only `secrets` (no serde/schemars).
+ #[test]
+ fn is_blank_signature_returns_bool_no_borrow() {
+ let f: fn(&SecretString) -> bool = SecretString::is_blank;
+ assert!(f(&SecretString::new("")));
+ assert!(!f(&SecretString::new("x")));
+ }
+
+ /// `SecretString` must never implement
+ /// `Serialize` or `Display`, even with serde compiled in. This is a
+ /// compile-time `!impl` assertion — adding either impl breaks the
+ /// build. `serde::Serialize` is nameable here because `secrets` always
+ /// pulls the `serde` dep.
+ #[test]
+ fn secret_string_has_no_serialize_no_display() {
+ static_assertions::assert_not_impl_any!(SecretString: serde::Serialize, std::fmt::Display);
+ }
+
+ /// Regression: the `serde` DEP is on under
+ /// `secrets`, yet the `Deserialize` IMPL stays ABSENT because it is
+ /// gated on the dedicated `secret-serde` feature — proving the
+ /// default-off gate is satisfiable even while serde is compiled.
+ #[cfg(not(feature = "secret-serde"))]
+ #[test]
+ fn deserialize_absent_without_secret_serde_even_though_serde_dep_on() {
+ static_assertions::assert_not_impl_any!(
+ SecretString: serde::de::DeserializeOwned
+ );
+ }
+
+ /// With `secret-serde` on, the `Deserialize` impl is
+ /// present (and `Serialize` is still absent — see the always-on test).
+ #[cfg(feature = "secret-serde")]
+ #[test]
+ fn deserialize_present_with_secret_serde() {
+ static_assertions::assert_impl_all!(SecretString: serde::de::DeserializeOwned);
+ static_assertions::assert_not_impl_any!(SecretString: serde::Serialize);
+ }
+
+ /// `Deserialize` round-trips the value through the
+ /// zeroizing constructor; the result `ct_eq`s a directly-built secret
+ /// and has the right length.
+ #[cfg(feature = "secret-serde")]
+ #[test]
+ fn deserialize_routes_value_through_zeroizing_constructor() {
+ let s: SecretString = serde_json::from_str("\"correct horse battery staple\"").unwrap();
+ assert!(bool::from(
+ s.ct_eq(&SecretString::new("correct horse battery staple"))
+ ));
+ assert_eq!(s.len(), 28);
+ }
+
+ /// `JsonSchema` renders a plain `string` and leaks no
+ /// length/value policy — no `minLength`/`maxLength`/`pattern`/`format`,
+ /// no `example`/`default`/`enum`.
+ #[cfg(feature = "secret-schemars")]
+ #[test]
+ fn json_schema_is_plain_string_no_policy_leak() {
+ let schema = schemars::schema_for!(SecretString);
+ let v = serde_json::to_value(&schema).unwrap();
+ assert_eq!(v["type"], serde_json::json!("string"));
+ for forbidden in [
+ "minLength",
+ "maxLength",
+ "pattern",
+ "format",
+ "example",
+ "default",
+ "enum",
+ ] {
+ assert!(
+ v.get(forbidden).is_none(),
+ "schema leaked `{forbidden}`: {v}"
+ );
+ }
+ // Any description present must carry no example/secret value.
+ if let Some(desc) = v.get("description").and_then(|d| d.as_str()) {
+ assert!(!desc.contains("horse"));
+ }
+ }
+
#[test]
fn secret_bytes_debug_redacted() {
let b = SecretBytes::from_slice(&[1, 2, 3, 4, 5]);
@@ -301,8 +531,7 @@ mod tests {
#[test]
fn empty_secret_bytes_constructs_without_mlocking_dangling_ptr() {
// A capacity-0 `Vec` has a dangling `as_ptr()`; `new` must not
- // pass it to `region::lock`. Constructing must not panic and the
- // wrapper must round-trip as empty.
+ // pass it to `region::lock` or panic.
let b = SecretBytes::new(Vec::new());
assert!(b.is_empty());
assert_eq!(b.len(), 0);
@@ -336,53 +565,31 @@ mod tests {
assert!(std::mem::needs_drop::());
};
- /// Best-effort runtime check that `Drop` wipes the full `SecretString`
- /// capacity. Reads freed memory — UB in the strict sense, flaky under
- /// parallelism; run single-threaded:
- /// `cargo test --features secrets -- secret_string_drop_zeroes --ignored --test-threads=1`
+ /// Proves zeroize wipes the buffer. Every read is on a STILL-LIVE
+ /// value (no post-free deref / UB); the in-place slice wipe also
+ /// proves the bytes go to zero with the length preserved.
#[test]
- #[ignore]
- fn secret_string_drop_zeroes_full_capacity() {
- let ptr: *const u8;
- let cap: usize;
- {
- let s = SecretString::new("sensitive_seed_material");
- ptr = s.inner.as_ptr();
- cap = s.inner.capacity();
- // SAFETY: live allocation, read for `cap` bytes pre-drop.
- #[allow(unsafe_code)]
- let pre = unsafe { std::slice::from_raw_parts(ptr, cap) };
- assert!(pre.iter().any(|&b| b != 0));
- }
- // SAFETY: best-effort post-free read; single-thread makes page
- // reuse before this read unlikely.
- #[allow(unsafe_code)]
- let post = unsafe { std::slice::from_raw_parts(ptr, cap) };
- assert!(post.iter().all(|&b| b == 0), "buffer not zeroed on drop");
- }
-
- /// Best-effort runtime check that `Drop` wipes `SecretBytes`. Same
- /// caveat as above; run single-threaded with `--ignored`. A
- /// page-sized buffer is used so the allocator is unlikely to reuse
- /// the freed page before the post-drop read (a tiny `Vec` would be
- /// recycled immediately, making the check meaningless).
- #[test]
- #[ignore]
- fn secret_bytes_drop_zeroes() {
- let ptr: *const u8;
- let cap: usize;
- {
- let b = SecretBytes::from_slice(&[0xAB; 4096]);
- ptr = b.inner.as_ptr();
- cap = b.inner.capacity();
- // SAFETY: live allocation, read for `cap` bytes pre-drop.
- #[allow(unsafe_code)]
- let pre = unsafe { std::slice::from_raw_parts(ptr, cap) };
- assert!(pre.iter().any(|&x| x != 0));
- }
- // SAFETY: best-effort post-free read; see note above.
- #[allow(unsafe_code)]
- let post = unsafe { std::slice::from_raw_parts(ptr, cap) };
- assert!(post.iter().all(|&x| x == 0), "buffer not zeroed on drop");
+ fn manual_zeroize_wipes_live_buffer() {
+ let mut b = SecretBytes::from_slice(&[0xABu8; 64]);
+ assert!(b.expose_secret().iter().any(|&x| x != 0));
+ b.expose_secret_mut().zeroize();
+ assert_eq!(b.len(), 64, "in-place wipe must preserve length");
+ assert!(
+ b.expose_secret().iter().all(|&x| x == 0),
+ "SecretBytes buffer not zeroed by manual zeroize"
+ );
+
+ // SecretBytes wrapper-level zeroize empties the buffer.
+ let mut b2 = SecretBytes::from_slice(&[0xCDu8; 32]);
+ b2.zeroize();
+ assert!(b2.is_empty(), "SecretBytes::zeroize must empty the buffer");
+
+ // SecretString wrapper-level zeroize empties the buffer; the
+ // exposed view holds no residual plaintext.
+ let mut s = SecretString::new("sensitive_seed_material");
+ assert!(!s.is_empty());
+ s.zeroize();
+ assert!(s.is_empty(), "SecretString::zeroize must empty the buffer");
+ assert_eq!(s.expose_secret(), "");
}
}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs
index b4e75512e9e..7f0147f1659 100644
--- a/packages/rs-platform-wallet-storage/src/secrets/store.rs
+++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs
@@ -1,17 +1,11 @@
//! [`SecretStore`] — the public, never-leaking secrets entry point.
//!
-//! Consumers use this enum, not the `keyring_core` SPI. Its read path
-//! ([`SecretStore::get`]) yields a zeroizing [`SecretBytes`]; a raw
-//! `Vec` never crosses this boundary, and the write path
-//! ([`SecretStore::set`]) takes `&SecretBytes` so a caller cannot pass an
-//! unwrapped buffer (M-STRONG-TYPES).
-//!
-//! Errors surface as the typed [`SecretStoreError`] — losslessly for the
-//! [`SecretStore::File`] arm (so `WrongPassphrase` vs `Corruption` vs
-//! `AlreadyLocked` stay distinct), and as a best-effort projection of
-//! `keyring_core::Error` for the [`SecretStore::Os`] arm. The internal
-//! `keyring_core::api::CredentialApi` / `CredentialStoreApi` impls remain
-//! the backend SPI; `SecretStore` delegates through them.
+//! Consumers use this enum, not the `keyring_core` SPI it delegates to.
+//! Reads yield a zeroizing [`SecretBytes`] and writes take `&SecretBytes`
+//! so a raw buffer never crosses the boundary. Errors are the typed
+//! [`SecretStoreError`] — lossless on the [`SecretStore::File`] arm, a
+//! best-effort projection of `keyring_core::Error` on the
+//! [`SecretStore::Os`] arm.
use std::sync::Arc;
@@ -19,16 +13,19 @@ use keyring_core::api::CredentialStoreApi;
use keyring_core::{Entry, Error as KeyringError};
use super::error::{OsKeyringErrorKind, SecretStoreError};
-use super::secret::SecretBytes;
+use super::secret::{SecretBytes, SecretString};
use super::validate::WalletId;
-use super::{default_credential_store, EncryptedFileStore, SERVICE_PREFIX};
+use super::wire::envelope;
+use super::{default_credential_store, EncryptedFileStore, MAX_SECRET_LEN, SERVICE_PREFIX};
/// A passphrase-or-OS-keyring backed store for wallet secret material.
///
-/// The only public read path is [`get`](SecretStore::get), which yields a
-/// zeroizing [`SecretBytes`] — a raw `Vec` never crosses this
-/// boundary. Backend selection is an explicit operator decision; there is
-/// no silent fallback between the two arms.
+/// Every read path ([`get`](SecretStore::get),
+/// [`get_secret`](SecretStore::get_secret), and the read inside
+/// [`reprotect`](SecretStore::reprotect)) yields a zeroizing
+/// [`SecretBytes`] — a raw `Vec` never crosses this boundary. Backend
+/// selection is an explicit operator decision; there is no silent fallback
+/// between the two arms.
pub enum SecretStore {
/// Self-contained Argon2id + XChaCha20-Poly1305 vault file.
/// Recommended on headless / server hosts.
@@ -49,52 +46,143 @@ impl SecretStore {
Ok(Self::File(EncryptedFileStore::open(path, passphrase)?))
}
+ /// Open (or create) a **deliberately keyless** file-backed vault — the
+ /// only door that takes no passphrase. Obfuscation, not confidentiality
+ /// (the key derives from an empty passphrase under the public salt): use
+ /// it where the stored secrets carry their own Tier-2 object password,
+ /// or as a staging step before [`EncryptedFileStore::rekey`] to a real
+ /// passphrase. [`file`](SecretStore::file) rejects a blank passphrase;
+ /// this is the explicit keyless alternative.
+ pub fn file_unprotected(path: impl AsRef) -> Result {
+ Ok(Self::File(EncryptedFileStore::open_unprotected(path)?))
+ }
+
/// Open the platform's default OS keyring, failing closed when none
/// is reachable (headless / no Secret Service).
pub fn os() -> Result {
Ok(Self::Os(default_credential_store().map_err(map_spi)?))
}
- /// Store `secret` under `(service, label)`, overwriting any prior
- /// value. Takes `&SecretBytes` so the caller cannot pass an unwrapped
- /// buffer; the wrapped bytes are exposed to the SPI only at the last
- /// moment.
+ /// Store `secret` under `(service, label)` UNPROTECTED (Tier-2
+ /// scheme-0), overwriting any prior value — a `set_secret(.., None)`
+ /// wrapper kept for non-breaking back-compat. Takes `&SecretBytes` so
+ /// the caller cannot pass an unwrapped buffer.
pub fn set(
&self,
service: &WalletId,
label: &str,
secret: &SecretBytes,
+ ) -> Result<(), SecretStoreError> {
+ self.set_secret(service, label, secret, None)
+ }
+
+ /// Store `secret` under `(service, label)`, overwriting any prior value.
+ ///
+ /// `password` selects the protection: `None` writes an unprotected
+ /// envelope; `Some(pw)` seals the bytes under the object password `pw`
+ /// (Argon2id + XChaCha20-Poly1305) **before** they reach the backend, so
+ /// a protected object stays confidential even under a full backend
+ /// compromise. A blank `pw` is rejected
+ /// ([`BlankPassphrase`](SecretStoreError::BlankPassphrase)).
+ ///
+ /// **No recovery (availability):** if a protected object's password is
+ /// lost, the object is permanently unrecoverable — there is no reset
+ /// path. The UX must state this plainly.
+ ///
+ /// **Entropy is the caller's:** a protected object's confidentiality
+ /// rests entirely on the password's entropy against an offline Argon2id
+ /// attacker who already holds the backend. This crate enforces only
+ /// non-blank; strength estimation / policy is the caller's job.
+ ///
+ /// The write is a same-slot overwrite that leaves the prior value intact
+ /// on a crash: on the `File` arm via the vault's atomic replace; on the
+ /// `Os` arm via the backend's single-item-replace contract.
+ /// Add/change/remove flows go through [`reprotect`](SecretStore::reprotect).
+ pub fn set_secret(
+ &self,
+ service: &WalletId,
+ label: &str,
+ secret: &SecretBytes,
+ password: Option<&SecretString>,
+ ) -> Result<(), SecretStoreError> {
+ // Wrap above the backend: the backend only ever stores the opaque
+ // envelope (ciphertext for a protected object).
+ let blob = envelope::wrap(service, label, password, secret.expose_secret())?;
+ self.put_raw(service, label, &blob)
+ }
+
+ /// Store the already-enveloped opaque `blob` under `(service, label)`.
+ /// The shared write seam under [`set`] and [`set_secret`].
+ ///
+ /// [`set`]: SecretStore::set
+ fn put_raw(
+ &self,
+ service: &WalletId,
+ label: &str,
+ blob: &SecretBytes,
) -> Result<(), SecretStoreError> {
match self {
- // File arm: the inherent typed path — no lossy SPI seam.
- // `put_bytes` takes `&SecretBytes` directly, so the
- // bare-buffer view never crosses this boundary.
- Self::File(s) => s.put_bytes(service, label, secret),
+ // Inherent typed path — no lossy SPI seam, no bare buffer.
+ Self::File(s) => s.put_bytes(service, label, blob),
Self::Os(store) => {
let entry = build_os(store, service, label)?;
- entry.set_secret(secret.expose_secret()).map_err(map_spi)
+ entry.set_secret(blob.expose_secret()).map_err(map_spi)
}
}
}
- /// Retrieve the secret stored under `(service, label)`, or `Ok(None)`
- /// if absent. The plaintext is wrapped into [`SecretBytes`] at the
- /// seam with no named `Vec` intermediate, so the bare-buffer window is
- /// zero statements.
+ /// Retrieve the UNPROTECTED secret stored under `(service, label)`, or
+ /// `Ok(None)` if absent — a `get_secret(.., None)` wrapper kept for
+ /// non-breaking back-compat. A scheme-1 (password-protected) object read
+ /// through this path returns
+ /// [`NeedsPassword`](SecretStoreError::NeedsPassword); use
+ /// [`get_secret`](SecretStore::get_secret) with the object password.
pub fn get(
&self,
service: &WalletId,
label: &str,
+ ) -> Result, SecretStoreError> {
+ self.get_secret(service, label, None)
+ }
+
+ /// Read the opaque bytes stored under `(service, label)`, or
+ /// `Ok(None)` if absent — the raw backend value, always a Tier-2
+ /// envelope (writes go through
+ /// [`set_secret`](SecretStore::set_secret)). The typed-vs-SPI
+ /// distinction is preserved exactly as the pre-Tier-2 path did. This
+ /// is the shared seam under [`get`] and [`get_secret`]; it does NOT
+ /// interpret the envelope.
+ ///
+ /// [`get`]: SecretStore::get
+ fn get_raw(
+ &self,
+ service: &WalletId,
+ label: &str,
) -> Result , SecretStoreError> {
match self {
- // File arm: the inherent typed path keeps `WrongPassphrase`
- // vs `Corruption` distinct (lossless). Plaintext rides as
- // `SecretBytes` all the way; no rewrap needed.
+ // Inherent typed path: keeps WrongPassphrase vs Corruption
+ // distinct; plaintext rides as SecretBytes, no rewrap.
Self::File(s) => s.get_bytes(service, label),
Self::Os(store) => {
let entry = build_os(store, service, label)?;
match entry.get_secret() {
- Ok(v) => Ok(Some(SecretBytes::new(v))),
+ Ok(v) => {
+ // Defense-in-depth: reject an oversized backend blob
+ // before it reaches the envelope parse/derive path.
+ // The File arm's stored bytes are already capped at
+ // MAX_SECRET_LEN by `put_bytes`; the Os backend has no
+ // such ceiling, so cap here. A legitimate envelope
+ // never exceeds MAX_SECRET_LEN; the overhead is
+ // headroom.
+ let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD;
+ if v.len() > cap {
+ return Err(SecretStoreError::SecretTooLarge {
+ found: v.len(),
+ max: cap,
+ });
+ }
+ Ok(Some(SecretBytes::new(v)))
+ }
Err(KeyringError::NoEntry) => Ok(None),
Err(e) => Err(map_spi(e)),
}
@@ -102,18 +190,95 @@ impl SecretStore {
}
}
- /// Delete the secret stored under `(service, label)`. Absent entries
- /// are a no-op (`Ok(())`), so deletion is idempotent.
- pub fn delete(&self, service: &WalletId, label: &str) -> Result<(), SecretStoreError> {
+ /// Retrieve the secret under `(service, label)` applying the strict,
+ /// fail-closed read, or `Ok(None)` if absent.
+ ///
+ /// `password` IS the caller's protection assertion — supply `Some(pw)`
+ /// for an object the caller's trusted model says is protected, `None`
+ /// otherwise. The expectation lives ONLY here, never in the stored
+ /// blob (see [`envelope::unwrap`]):
+ ///
+ /// - `Some(pw)` + a protected blob → the secret (or
+ /// [`WrongPassword`](SecretStoreError::WrongPassword) on tag fail);
+ /// - `Some(pw)` + an unprotected blob →
+ /// [`ExpectedProtectedButUnsealed`](SecretStoreError::ExpectedProtectedButUnsealed)
+ /// — a strip/downgrade, refused, no bytes returned;
+ /// - `None` + a protected blob →
+ /// [`NeedsPassword`](SecretStoreError::NeedsPassword) (never ciphertext);
+ /// - `None` + an unprotected blob → the secret.
+ ///
+ /// **Documented residual:** an attacker who ALSO rewrites the
+ /// consumer's trusted DB so the caller passes `None` for a stripped
+ /// object can still downgrade — out of this library's reach by
+ /// construction (the protection expectation is the caller's; see
+ /// `SECRETS.md`). The expectation is NEVER persisted by the library.
+ pub fn get_secret(
+ &self,
+ service: &WalletId,
+ label: &str,
+ password: Option<&SecretString>,
+ ) -> Result , SecretStoreError> {
+ // Absence is availability-only (deletion = DoS, never injection):
+ // a missing entry is Ok(None) under either password argument.
+ let Some(stored) = self.get_raw(service, label)? else {
+ return Ok(None);
+ };
+ envelope::unwrap(service, label, password, stored.expose_secret()).map(Some)
+ }
+
+ /// Add / change / remove an object password in one same-slot
+ /// unwrap→rewrap→overwrite — the canonical re-protection flow.
+ ///
+ /// Reads the object under the `current` expectation (so a strip is
+ /// caught fail-closed before any rewrap), then re-writes it under
+ /// `new`:
+ /// - **add:** `current = None`, `new = Some(pw)`;
+ /// - **change:** `current = Some(old)`, `new = Some(pw_new)`;
+ /// - **remove:** `current = Some(old)`, `new = None`.
+ ///
+ /// An absent object returns [`Err(NoEntry)`][SecretStoreError::NoEntry] —
+ /// `reprotect` is operational; absence means the caller's protection-status
+ /// record disagrees with the backend, which is a signal not to be silently
+ /// dropped. The rewrite is the same-slot overwrite of [`set_secret`], so a
+ /// crash between the read and the commit leaves the prior value intact
+ /// and readable under `current`. After a successful call the consumer MUST
+ /// update its own trusted protection-status record (the protection
+ /// expectation lives there).
+ ///
+ /// **No recovery:** changing or removing requires the `current`
+ /// password; if it is lost the object cannot be re-protected or read,
+ /// and is permanently unrecoverable (availability trade-off).
+ ///
+ /// **Entropy is the caller's:** the `new` password's entropy is the
+ /// whole confidentiality guarantee for the re-protected object; this
+ /// crate enforces only non-blank, not strength.
+ pub fn reprotect(
+ &self,
+ service: &WalletId,
+ label: &str,
+ current: Option<&SecretString>,
+ new: Option<&SecretString>,
+ ) -> Result<(), SecretStoreError> {
+ let Some(secret) = self.get_secret(service, label, current)? else {
+ return Err(SecretStoreError::NoEntry);
+ };
+ self.set_secret(service, label, &secret, new)
+ }
+
+ /// Delete the secret stored under `(service, label)`.
+ ///
+ /// Returns `Ok(true)` if a credential was removed, `Ok(false)` if no
+ /// credential existed under `(service, label)`. Idempotent for callers
+ /// that don't care — `.delete(...)?;` still discards the bool;
+ /// race-detecting callers can `match delete()?`.
+ pub fn delete(&self, service: &WalletId, label: &str) -> Result {
match self {
- Self::File(s) => {
- s.delete_bytes(service, label)?;
- Ok(())
- }
+ Self::File(s) => s.delete_bytes(service, label),
Self::Os(store) => {
let entry = build_os(store, service, label)?;
match entry.delete_credential() {
- Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
+ Ok(()) => Ok(true),
+ Err(KeyringError::NoEntry) => Ok(false),
Err(e) => Err(map_spi(e)),
}
}
@@ -123,12 +288,10 @@ impl SecretStore {
/// Build the SPI [`Entry`] for `(service, label)` on the OS-keyring arm.
///
-/// The reject-not-sanitize label allowlist (`^[A-Za-z0-9._-]{1,64}$`)
-/// is enforced here before the call crosses into the OS backend.
-/// Different OS keyrings accept, normalize, or reject non-allowlisted
-/// bytes inconsistently; enforcing the allowlist at
-/// this shim keeps `(service, label)` invariants identical to the
-/// `File` arm and across every OS backend.
+/// Enforces the label allowlist (`^[A-Za-z0-9._-]{1,64}$`) before the
+/// call crosses into the OS backend, so the `(service, label)` invariant
+/// stays identical to the `File` arm and across every OS keyring (each
+/// accepts / normalizes / rejects non-allowlisted bytes differently).
fn build_os(
store: &Arc,
service: &WalletId,
@@ -140,12 +303,9 @@ fn build_os(
}
impl std::fmt::Debug for SecretStore {
- /// Surfaces the backend engine/service identity without exposing any
- /// secret material. The `Os` arm reports the SPI
- /// `vendor()`/`id()` — non-secret backend tags (e.g. which OS keyring
- /// is wired up) — rather than an opaque `Os(..)`. The `File` arm
- /// delegates to [`EncryptedFileStore`]'s redacting `Debug` (path
- /// only, no key/passphrase).
+ /// Surfaces the backend identity without any secret material: the `Os`
+ /// arm reports the SPI `vendor()`/`id()` tags; the `File` arm delegates
+ /// to [`EncryptedFileStore`]'s redacting `Debug` (path only).
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::File(s) => f.debug_tuple("SecretStore::File").field(s).finish(),
@@ -159,20 +319,14 @@ impl std::fmt::Debug for SecretStore {
}
/// Project an OS-keyring SPI [`KeyringError`] into the typed
-/// [`SecretStoreError`] for the [`Os`](SecretStore::Os) arm.
-///
-/// The OS keyring has no typed `SecretStoreError` origin, so its variants
-/// map best-effort into [`SecretStoreError::OsKeyring`] (carrying only a
-/// non-secret discriminant) or the closest existing variant. Secret-
-/// bearing keyring variants (`BadEncoding`, `BadDataFormat`) are
-/// collapsed to a discriminant — their raw bytes never enter
-/// `SecretStoreError`. (The [`File`](SecretStore::File) arm never reaches
-/// this projection: it uses the inherent typed path.)
+/// [`SecretStoreError`] for the [`Os`](SecretStore::Os) arm. Best-effort:
+/// variants map into [`SecretStoreError::OsKeyring`] (non-secret
+/// discriminant only) or the closest existing variant; byte-bearing
+/// keyring variants are collapsed so their bytes never enter the type.
+/// The [`File`](SecretStore::File) arm never reaches this projection.
fn map_spi(e: KeyringError) -> SecretStoreError {
match e {
- KeyringError::NoEntry => SecretStoreError::OsKeyring {
- kind: OsKeyringErrorKind::NoEntry,
- },
+ KeyringError::NoEntry => SecretStoreError::NoEntry,
KeyringError::NoStorageAccess(_) => SecretStoreError::OsKeyring {
kind: OsKeyringErrorKind::NoStorageAccess,
},
@@ -197,7 +351,18 @@ mod tests {
use crate::secrets::SecretString;
fn file_store(dir: &std::path::Path) -> SecretStore {
- SecretStore::file(dir.join("vault.pwsvault"), SecretString::new("pw-correct")).unwrap()
+ SecretStore::file(secure_vault_path(dir), SecretString::new("pw-correct")).unwrap()
+ }
+
+ /// Tighten the umask-0002 tempdir (0o775) to 0o700 so it passes the
+ /// parent-dir perm check, then return a vault path inside it.
+ fn secure_vault_path(dir: &std::path::Path) -> std::path::PathBuf {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
+ }
+ dir.join("vault.pwsvault")
}
fn wid(b: u8) -> WalletId {
@@ -226,34 +391,43 @@ mod tests {
}
#[test]
- fn delete_is_idempotent() {
+ fn delete_returns_false_on_absent_true_on_present() {
let dir = tempfile::tempdir().unwrap();
let s = file_store(dir.path());
- // Absent → Ok, no error.
- s.delete(&wid(1), "seed").unwrap();
+ // Absent → Ok(false), no error.
+ assert!(!s.delete(&wid(1), "seed").unwrap());
s.set(&wid(1), "seed", &SecretBytes::from_slice(b"x"))
.unwrap();
- s.delete(&wid(1), "seed").unwrap();
+ // Present → Ok(true).
+ assert!(s.delete(&wid(1), "seed").unwrap());
assert!(s.get(&wid(1), "seed").unwrap().is_none());
- // Second delete on the now-absent entry is still Ok.
- s.delete(&wid(1), "seed").unwrap();
+ // Second delete on the now-absent entry is Ok(false).
+ assert!(!s.delete(&wid(1), "seed").unwrap());
+ }
+
+ #[test]
+ fn reprotect_absent_returns_no_entry() {
+ let dir = tempfile::tempdir().unwrap();
+ let s = file_store(dir.path());
+ let err = s
+ .reprotect(&wid(1), "seed", None, Some(&SecretString::new("pw")))
+ .unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::NoEntry),
+ "expected NoEntry on absent reprotect, got {err:?}"
+ );
}
#[test]
fn wrong_passphrase_surfaces_typed_lossless() {
- // Resident-vault model: the passphrase is verified at open()
- // time (header verify-token), so a wrong-pass reopen fails at
- // open() rather than on the first get(). The typed distinction
- // still survives losslessly on the public path.
+ // Resident-vault model verifies the passphrase at open() (header
+ // verify-token), so a wrong-pass reopen fails at open(), losslessly.
let dir = tempfile::tempdir().unwrap();
file_store(dir.path())
.set(&wid(1), "seed", &SecretBytes::from_slice(b"orig"))
.unwrap();
- let err = SecretStore::file(
- dir.path().join("vault.pwsvault"),
- SecretString::new("pw-wrong"),
- )
- .expect_err("wrong pass must fail open");
+ let err = SecretStore::file(secure_vault_path(dir.path()), SecretString::new("pw-wrong"))
+ .expect_err("wrong pass must fail open");
assert!(
matches!(err, SecretStoreError::WrongPassphrase),
"expected WrongPassphrase, got {err:?}"
@@ -266,9 +440,8 @@ mod tests {
let s = file_store(dir.path());
s.set(&wid(1), "seed", &SecretBytes::from_slice(b"value"))
.unwrap();
- // Corrupt the entry ciphertext while leaving the verify-token
- // intact: the passphrase is still correct, so this is corruption,
- // not a wrong passphrase. The lossless typed path keeps them apart.
+ // Corrupt the entry ciphertext but leave the verify-token intact:
+ // passphrase still correct, so this is Corruption, not WrongPassphrase.
let SecretStore::File(ref fs) = s else {
unreachable!()
};
@@ -291,11 +464,10 @@ mod tests {
#[test]
fn already_locked_surfaces_typed_lossless() {
- // Resident-vault model: a second open() of the same path while
- // the first store is alive returns AlreadyLocked. The typed
- // distinction survives losslessly on the public path.
+ // A second open() of a path the first store still holds returns
+ // AlreadyLocked, losslessly on the public path.
let dir = tempfile::tempdir().unwrap();
- let path = dir.path().join("vault.pwsvault");
+ let path = secure_vault_path(dir.path());
let _s1 = SecretStore::file(&path, SecretString::new("pw")).unwrap();
let err = SecretStore::file(&path, SecretString::new("pw")).unwrap_err();
assert!(
@@ -312,16 +484,10 @@ mod tests {
assert!(!dbg.contains("pw-correct"));
}
- /// The OS-keyring shim must enforce the label allowlist BEFORE
- /// handing the value to the OS backend. The per-backend label
- /// policies (macOS Keychain vs Windows
- /// Credential Manager vs Secret Service) differ in what they accept,
- /// normalize, or reject; the shim must keep the `(service, label)`
- /// invariant uniform across every arm.
- ///
- /// A mock `CredentialStoreApi` that panics if its `build()` is
- /// invoked proves the bad label never crosses the SPI seam — the
- /// shim rejects with `SecretStoreError::InvalidLabel` first.
+ /// The shim must enforce the label allowlist before reaching the OS
+ /// backend (per-backend policies differ). A `CredentialStoreApi` that
+ /// panics on `build()` proves a bad label is rejected with
+ /// `InvalidLabel` before it ever crosses the SPI seam.
#[test]
fn build_os_rejects_invalid_label_before_spi() {
use std::any::Any;
@@ -355,9 +521,8 @@ mod tests {
let store: Arc = Arc::new(PanickingStore);
let os = SecretStore::Os(store);
- // Every operation on the OS arm goes through `build_os`; the
- // allowlist rejection MUST fire here, so the panicking SPI is
- // never reached.
+ // Every OS-arm op goes through `build_os`, so the allowlist
+ // rejection fires before the panicking SPI is reached.
for bad in ["lab el", "../escape", "", "a:b", "a/b", "lab\0el"] {
let err = os
.set(&wid(1), bad, &SecretBytes::from_slice(b"x"))
@@ -378,4 +543,675 @@ mod tests {
);
}
}
+
+ // ===== Tier-2 strict fail-closed read =====
+ //
+ // Parameterised over BOTH arms. The "attacker who can write the
+ // backend" is modelled per arm by `Backend::place_raw`: on File it
+ // re-seals the chosen blob under the resident vault key via `put_bytes`
+ // (a cold/backup-swap actor could only corrupt → DoS, so the strip
+ // requires the vault key — the File-arm asymmetry); on Os it overwrites
+ // the keychain item directly (the bare envelope, no second AEAD — where
+ // the strip residual bites hardest). The writable Os fixture is the
+ // upstream `keyring_core::mock::Store` (a raw SPI `set_secret` bypasses
+ // the envelope), so no bespoke mock is needed.
+
+ use keyring_core::mock;
+
+ use crate::secrets::file::crypto::{KdfParams, ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P};
+ use crate::secrets::file::format::KDF_ID_ARGON2ID;
+
+ /// Argon2id floor params — fast enough for these tests.
+ fn floor() -> KdfParams {
+ KdfParams {
+ id: KDF_ID_ARGON2ID,
+ m_kib: ARGON2_MIN_M_KIB,
+ t: ARGON2_MIN_T,
+ p: ARGON2_P,
+ }
+ }
+
+ fn protected(w: &WalletId, label: &str, pw: &str, secret: &[u8]) -> Vec {
+ envelope::wrap_with_params(w, label, Some(&SecretString::new(pw)), secret, floor())
+ .unwrap()
+ .expose_secret()
+ .to_vec()
+ }
+
+ fn unprotected(w: &WalletId, label: &str, secret: &[u8]) -> Vec {
+ envelope::wrap(w, label, None, secret)
+ .unwrap()
+ .expose_secret()
+ .to_vec()
+ }
+
+ /// A backend under test plus the raw-write hook that plays the
+ /// backend-write attacker.
+ struct Backend {
+ store: SecretStore,
+ _dir: Option,
+ mock: Option>,
+ name: &'static str,
+ }
+
+ impl Backend {
+ /// Write `blob` to `(w, label)` as opaque backend bytes (the
+ /// attacker's primitive / the protected-enrol setup). On Os this is
+ /// a raw SPI `set_secret` on the shared mock store, bypassing the
+ /// `SecretStore` envelope layer exactly as a breached keychain write
+ /// would.
+ fn place_raw(&self, w: &WalletId, label: &str, blob: &[u8]) {
+ match (&self.store, &self.mock) {
+ (SecretStore::File(fs), _) => fs
+ .put_bytes(w, label, &SecretBytes::from_slice(blob))
+ .unwrap(),
+ (SecretStore::Os(_), Some(mock)) => {
+ let service = format!("{SERVICE_PREFIX}{}", w.to_hex());
+ mock.build(&service, label, None)
+ .unwrap()
+ .set_secret(blob)
+ .unwrap();
+ }
+ _ => unreachable!("os backend must carry its mock"),
+ }
+ }
+ }
+
+ fn file_backend() -> Backend {
+ let dir = tempfile::tempdir().unwrap();
+ let store = file_store(dir.path());
+ Backend {
+ store,
+ _dir: Some(dir),
+ mock: None,
+ name: "File",
+ }
+ }
+
+ fn os_backend() -> Backend {
+ // The upstream in-memory mock store. The clone handed to
+ // `SecretStore::Os` and the handle kept for raw attacker writes
+ // share the same backing credentials by `Arc`.
+ let mock = mock::Store::new().unwrap();
+ let store = SecretStore::Os(mock.clone());
+ Backend {
+ store,
+ _dir: None,
+ mock: Some(mock),
+ name: "Os",
+ }
+ }
+
+ /// The strict-read quadrant.
+ fn run_quadrant(b: &Backend) {
+ let w = wid(1);
+ let pw = SecretString::new("object-pw");
+
+ // scheme-0 + None → bytes (the ONLY byte-returning quadrant).
+ b.place_raw(&w, "u0", &unprotected(&w, "u0", b"plain-seed"));
+ assert_eq!(
+ b.store
+ .get_secret(&w, "u0", None)
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"plain-seed",
+ "[{}] scheme-0 + None",
+ b.name
+ );
+
+ // scheme-1 + None → NeedsPassword (never ciphertext).
+ b.place_raw(&w, "p1", &protected(&w, "p1", "object-pw", b"real-seed"));
+ assert!(
+ matches!(
+ b.store.get_secret(&w, "p1", None).unwrap_err(),
+ SecretStoreError::NeedsPassword
+ ),
+ "[{}] scheme-1 + None",
+ b.name
+ );
+
+ // scheme-1 + Some(correct) → secret.
+ assert_eq!(
+ b.store
+ .get_secret(&w, "p1", Some(&pw))
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"real-seed",
+ "[{}] scheme-1 + Some(correct)",
+ b.name
+ );
+
+ // scheme-1 + Some(wrong) → WrongPassword.
+ assert!(
+ matches!(
+ b.store
+ .get_secret(&w, "p1", Some(&SecretString::new("nope")))
+ .unwrap_err(),
+ SecretStoreError::WrongPassword
+ ),
+ "[{}] scheme-1 + Some(wrong)",
+ b.name
+ );
+
+ // scheme-0 + Some(pw) → ExpectedProtectedButUnsealed (fail closed).
+ assert!(
+ matches!(
+ b.store.get_secret(&w, "u0", Some(&pw)).unwrap_err(),
+ SecretStoreError::ExpectedProtectedButUnsealed
+ ),
+ "[{}] scheme-0 + Some",
+ b.name
+ );
+
+ // Truncated envelope (below the bincode minimum) → Corruption,
+ // both with and without a password — no magic byte to peek at.
+ b.place_raw(&w, "broken", &[0x01]);
+ for arg in [None, Some(&pw)] {
+ assert!(
+ matches!(
+ b.store.get_secret(&w, "broken", arg).unwrap_err(),
+ SecretStoreError::Corruption
+ ),
+ "[{}] truncated envelope ({:?})",
+ b.name,
+ arg.map(|_| "Some")
+ );
+ }
+
+ // Raw, non-envelope bytes → Corruption under either password
+ // arg: every read goes through the bincode decoder.
+ b.place_raw(&w, "raw", b"raw-bytes-not-a-valid-envelope");
+ for arg in [None, Some(&pw)] {
+ assert!(
+ matches!(
+ b.store.get_secret(&w, "raw", arg).unwrap_err(),
+ SecretStoreError::Corruption
+ ),
+ "[{}] raw non-envelope bytes ({:?})",
+ b.name,
+ arg.map(|_| "Some")
+ );
+ }
+
+ // absent entry → Ok(None) under either arg (deletion = DoS).
+ assert!(b.store.get_secret(&w, "absent", None).unwrap().is_none());
+ assert!(b
+ .store
+ .get_secret(&w, "absent", Some(&pw))
+ .unwrap()
+ .is_none());
+ }
+
+ #[test]
+ fn l1_quadrant_file() {
+ run_quadrant(&file_backend());
+ }
+
+ #[test]
+ fn l1_quadrant_os() {
+ run_quadrant(&os_backend());
+ }
+
+ /// The non-vacuous strip-injection regression. The single
+ /// test the whole feature exists to make pass.
+ fn run_strip_injection(b: &Backend) {
+ let w = wid(2);
+ let pw = SecretString::new("object-pw");
+
+ // Enrol protected: stored = a valid scheme-1 envelope of S_real.
+ b.place_raw(
+ &w,
+ "seed",
+ &protected(&w, "seed", "object-pw", b"REAL-SEED-S_real"),
+ );
+ assert_eq!(
+ b.store
+ .get_secret(&w, "seed", Some(&pw))
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"REAL-SEED-S_real",
+ "[{}] legit protected read",
+ b.name
+ );
+
+ // Attacker overwrites the slot with a fresh, internally-valid
+ // scheme-0 envelope carrying a DIFFERENT seed S_evil.
+ let attacker_blob = unprotected(&w, "seed", b"EVIL-SEED-S_evil");
+ b.place_raw(&w, "seed", &attacker_blob);
+
+ // A password-supplied read of the stripped slot fails closed;
+ // S_evil is NEVER returned.
+ let err = b.store.get_secret(&w, "seed", Some(&pw)).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::ExpectedProtectedButUnsealed),
+ "[{}] strip must fail closed, got {err:?}",
+ b.name
+ );
+
+ // Non-vacuity: the attacker blob IS a valid unprotected envelope
+ // that WOULD decode to S_evil under `None` — so the refusal above is
+ // caused SOLELY by the Some(pw)+scheme-0 strict rule, not by any
+ // malformation (without the strict rule, S_evil would be returned).
+ let would_be = envelope::unwrap(&w, "seed", None, &attacker_blob).unwrap();
+ assert_eq!(
+ would_be.expose_secret(),
+ b"EVIL-SEED-S_evil",
+ "[{}] non-vacuity: blob decodes to S_evil under None",
+ b.name
+ );
+ }
+
+ #[test]
+ fn l1_strip_injection_file() {
+ run_strip_injection(&file_backend());
+ }
+
+ #[test]
+ fn l1_strip_injection_os() {
+ run_strip_injection(&os_backend());
+ }
+
+ /// A consumer bug alone fails closed in BOTH directions.
+ fn run_both_det_bug_directions(b: &Backend) {
+ let w = wid(3);
+ let pw = SecretString::new("pw");
+ // (a) over-supply a password on a genuinely unprotected object.
+ b.place_raw(&w, "u", &unprotected(&w, "u", b"x"));
+ assert!(matches!(
+ b.store.get_secret(&w, "u", Some(&pw)).unwrap_err(),
+ SecretStoreError::ExpectedProtectedButUnsealed
+ ));
+ // (b) under-supply on a genuinely protected object.
+ b.place_raw(&w, "p", &protected(&w, "p", "pw", b"y"));
+ assert!(matches!(
+ b.store.get_secret(&w, "p", None).unwrap_err(),
+ SecretStoreError::NeedsPassword
+ ));
+ }
+
+ #[test]
+ fn l1_both_det_bug_directions_file() {
+ run_both_det_bug_directions(&file_backend());
+ }
+
+ #[test]
+ fn l1_both_det_bug_directions_os() {
+ run_both_det_bug_directions(&os_backend());
+ }
+
+ /// The expectation is NEVER inferred from the blob's scheme
+ /// byte — identical scheme-1 blobs diverge solely on the password arg.
+ fn run_expectation_not_inferred(b: &Backend) {
+ let w = wid(4);
+ let pw = SecretString::new("pw");
+ let blob = protected(&w, "a", "pw", b"seed");
+ b.place_raw(&w, "a", &blob);
+ b.place_raw(&w, "b", &blob);
+ assert_eq!(
+ b.store
+ .get_secret(&w, "a", Some(&pw))
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"seed"
+ );
+ assert!(matches!(
+ b.store.get_secret(&w, "b", None).unwrap_err(),
+ SecretStoreError::NeedsPassword
+ ));
+ }
+
+ #[test]
+ fn l1_expectation_not_inferred_file() {
+ run_expectation_not_inferred(&file_backend());
+ }
+
+ #[test]
+ fn l1_expectation_not_inferred_os() {
+ run_expectation_not_inferred(&os_backend());
+ }
+
+ /// Unprotected→protected upgrade confusion is availability-
+ /// only, fail-closed (NeedsPassword), no leak / no injection.
+ fn run_upgrade_confusion(b: &Backend) {
+ let w = wid(5);
+ b.place_raw(&w, "x", &protected(&w, "x", "attacker-pw", b"whatever"));
+ assert!(matches!(
+ b.store.get_secret(&w, "x", None).unwrap_err(),
+ SecretStoreError::NeedsPassword
+ ));
+ }
+
+ #[test]
+ fn l1_upgrade_confusion_file() {
+ run_upgrade_confusion(&file_backend());
+ }
+
+ #[test]
+ fn l1_upgrade_confusion_os() {
+ run_upgrade_confusion(&os_backend());
+ }
+
+ /// A scheme-flip from `Password` → `Unprotected`: `Some(pw)` is
+ /// caught by the strict rule regardless; `None` reads the body as
+ /// scheme-0 opaque bytes (never the real seed) — a known residual,
+ /// dominated by the consumer-DB residual; pinned, not "fixed".
+ fn run_scheme_flip(b: &Backend) {
+ use crate::secrets::wire::config::WIRE_CONFIG;
+ use crate::secrets::wire::envelope::{Envelope, Payload};
+
+ let w = wid(6);
+ let pw = SecretString::new("pw");
+ let blob = protected(&w, "x", "pw", b"real-seed");
+ let (env, _): (Envelope, usize) = bincode::decode_from_slice(&blob, WIRE_CONFIG).unwrap();
+ let flipped = match env.payload {
+ Payload::Password { ciphertext, .. } => Envelope {
+ version: env.version,
+ payload: Payload::Unprotected(ciphertext),
+ },
+ Payload::Unprotected(_) => panic!("protected() must yield a Password payload"),
+ };
+ let flipped_blob = bincode::encode_to_vec(&flipped, WIRE_CONFIG).unwrap();
+ b.place_raw(&w, "x", &flipped_blob);
+
+ assert!(matches!(
+ b.store.get_secret(&w, "x", Some(&pw)).unwrap_err(),
+ SecretStoreError::ExpectedProtectedButUnsealed
+ ));
+ let got = b.store.get_secret(&w, "x", None).unwrap().unwrap();
+ assert_ne!(
+ got.expose_secret(),
+ b"real-seed",
+ "the real seed must never surface from a flipped scheme byte"
+ );
+ }
+
+ #[test]
+ fn l1_scheme_flip_file() {
+ run_scheme_flip(&file_backend());
+ }
+
+ #[test]
+ fn l1_scheme_flip_os() {
+ run_scheme_flip(&os_backend());
+ }
+
+ // ===== Add / change / remove password + arm matrix =====
+ //
+ // These exercise the PUBLIC set_secret/get_secret/reprotect API, so the
+ // protected writes/reads run the real (default 64 MiB) Argon2 — kept to
+ // a small number of derivations per test.
+
+ /// The full enrol → change → remove lifecycle, each
+ /// step verified through the strict read.
+ fn run_pw_lifecycle(b: &Backend) {
+ let w = wid(10);
+ let pw1 = SecretString::new("pw-one");
+ let pw2 = SecretString::new("pw-two");
+
+ // ADD: start unprotected, enrol a password.
+ b.store
+ .set(&w, "seed", &SecretBytes::from_slice(b"SEED"))
+ .unwrap();
+ assert_eq!(
+ b.store.get(&w, "seed").unwrap().unwrap().expose_secret(),
+ b"SEED"
+ );
+ b.store.reprotect(&w, "seed", None, Some(&pw1)).unwrap();
+ assert!(
+ matches!(
+ b.store.get(&w, "seed").unwrap_err(),
+ SecretStoreError::NeedsPassword
+ ),
+ "[{}] after add, None read needs a password",
+ b.name
+ );
+ assert_eq!(
+ b.store
+ .get_secret(&w, "seed", Some(&pw1))
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"SEED"
+ );
+
+ // CHANGE: rotate to a new password (unwrap-old → rewrap-new).
+ b.store
+ .reprotect(&w, "seed", Some(&pw1), Some(&pw2))
+ .unwrap();
+ assert_eq!(
+ b.store
+ .get_secret(&w, "seed", Some(&pw2))
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"SEED"
+ );
+ assert!(
+ matches!(
+ b.store.get_secret(&w, "seed", Some(&pw1)).unwrap_err(),
+ SecretStoreError::WrongPassword
+ ),
+ "[{}] old password no longer unlocks after change",
+ b.name
+ );
+
+ // REMOVE: back to unprotected.
+ b.store.reprotect(&w, "seed", Some(&pw2), None).unwrap();
+ assert_eq!(
+ b.store.get(&w, "seed").unwrap().unwrap().expose_secret(),
+ b"SEED"
+ );
+ assert!(
+ matches!(
+ b.store.get_secret(&w, "seed", Some(&pw2)).unwrap_err(),
+ SecretStoreError::ExpectedProtectedButUnsealed
+ ),
+ "[{}] after remove, a password read fails closed until the consumer updates its DB",
+ b.name
+ );
+ }
+
+ #[test]
+ fn pw_lifecycle_file() {
+ run_pw_lifecycle(&file_backend());
+ }
+
+ #[test]
+ fn pw_lifecycle_os() {
+ run_pw_lifecycle(&os_backend());
+ }
+
+ /// Losing the object password bricks the object — no recovery
+ /// path exists, every read fails closed.
+ fn run_pw_no_recovery(b: &Backend) {
+ let w = wid(11);
+ let pw = SecretString::new("the-only-pw");
+ b.store
+ .set_secret(&w, "seed", &SecretBytes::from_slice(b"SEED"), Some(&pw))
+ .unwrap();
+ assert!(matches!(
+ b.store
+ .get_secret(&w, "seed", Some(&SecretString::new("guess")))
+ .unwrap_err(),
+ SecretStoreError::WrongPassword
+ ));
+ assert!(matches!(
+ b.store.get(&w, "seed").unwrap_err(),
+ SecretStoreError::NeedsPassword
+ ));
+ }
+
+ #[test]
+ fn pw_no_recovery_file() {
+ run_pw_no_recovery(&file_backend());
+ }
+
+ #[test]
+ fn pw_no_recovery_os() {
+ run_pw_no_recovery(&os_backend());
+ }
+
+ /// `set`/`get` are additive `..,None` wrappers — `set`
+ /// writes a scheme-0 envelope, `get` reads it byte-exact, and a
+ /// password-supplied read of that unprotected object fails closed.
+ fn run_set_get_wrappers(b: &Backend) {
+ let w = wid(12);
+ b.store
+ .set(&w, "seed", &SecretBytes::from_slice(b"plain"))
+ .unwrap();
+ assert_eq!(
+ b.store.get(&w, "seed").unwrap().unwrap().expose_secret(),
+ b"plain"
+ );
+ assert!(matches!(
+ b.store
+ .get_secret(&w, "seed", Some(&SecretString::new("pw")))
+ .unwrap_err(),
+ SecretStoreError::ExpectedProtectedButUnsealed
+ ));
+ }
+
+ #[test]
+ fn set_get_wrappers_file() {
+ run_set_get_wrappers(&file_backend());
+ }
+
+ #[test]
+ fn set_get_wrappers_os() {
+ run_set_get_wrappers(&os_backend());
+ }
+
+ /// The Os arm has no passphrase concept; the Tier-1 blank
+ /// guard never fires and the round-trip is byte-exact.
+ #[test]
+ fn os_arm_roundtrip_no_blank_guard() {
+ let b = os_backend();
+ let w = wid(13);
+ b.store
+ .set(&w, "seed", &SecretBytes::from_slice(b"abc"))
+ .unwrap();
+ assert_eq!(
+ b.store.get(&w, "seed").unwrap().unwrap().expose_secret(),
+ b"abc"
+ );
+ b.store.delete(&w, "seed").unwrap();
+ assert!(b.store.get(&w, "seed").unwrap().is_none());
+ }
+
+ /// [File]: a crash (disk-write failure) between the unwrap
+ /// and the overwrite-commit leaves the OLD protected value intact and
+ /// readable — no half-rotated / unprotected state.
+ #[cfg(unix)]
+ #[test]
+ fn pw_change_crash_safety_leaves_old_intact_file() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = tempfile::tempdir().unwrap();
+ let s = file_store(dir.path());
+ let w = wid(14);
+ let old = SecretString::new("old-pw");
+ let new = SecretString::new("new-pw");
+
+ s.set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old))
+ .unwrap();
+
+ // Make the vault's parent read-only so the atomic temp-write fails
+ // mid-change (mirrors rekey_does_not_corrupt_on_disk_temp_failure).
+ std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
+ let err = s.reprotect(&w, "seed", Some(&old), Some(&new)).unwrap_err();
+ assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}");
+
+ // Restore write so the resident store can sync/clean up at drop.
+ std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
+
+ // The OLD value is still readable under the OLD password; the new
+ // password does not unlock it (no half-rotation).
+ assert_eq!(
+ s.get_secret(&w, "seed", Some(&old))
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"REAL"
+ );
+ assert!(matches!(
+ s.get_secret(&w, "seed", Some(&new)).unwrap_err(),
+ SecretStoreError::WrongPassword
+ ));
+ }
+
+ /// [Os]: a backend failure during the rewrite's write (after the read
+ /// succeeds) leaves the OLD value intact — no half-rotation. The mock's
+ /// one-shot error injection fails the next write, simulating a crash
+ /// mid-rewrite. `reprotect` is read-then-`set_secret`, split here so the
+ /// error lands on the write.
+ #[test]
+ fn os_rewrite_mid_write_failure_leaves_old_intact() {
+ let mock = mock::Store::new().unwrap();
+ let store = SecretStore::Os(mock.clone());
+ let w = wid(15);
+ let old = SecretString::new("old-pw");
+ let new = SecretString::new("new-pw");
+ store
+ .set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old))
+ .unwrap();
+
+ // Read succeeds (the rewrite's first step) …
+ let secret = store.get_secret(&w, "seed", Some(&old)).unwrap().unwrap();
+ // … then inject a one-shot backend error so the write fails.
+ let service = format!("{SERVICE_PREFIX}{}", w.to_hex());
+ let entry = mock.build(&service, "seed", None).unwrap();
+ let cred: &mock::Cred = entry.as_any().downcast_ref().unwrap();
+ cred.set_error(KeyringError::PlatformFailure(Box::new(
+ std::io::Error::other("simulated backend write failure"),
+ )));
+ let err = store
+ .set_secret(&w, "seed", &secret, Some(&new))
+ .unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::OsKeyring { .. }),
+ "got {err:?}"
+ );
+
+ // The OLD value is still readable; nothing rotated to `new`.
+ assert_eq!(
+ store
+ .get_secret(&w, "seed", Some(&old))
+ .unwrap()
+ .unwrap()
+ .expose_secret(),
+ b"REAL"
+ );
+ assert!(matches!(
+ store.get_secret(&w, "seed", Some(&new)).unwrap_err(),
+ SecretStoreError::WrongPassword
+ ));
+ }
+
+ /// [Os]: the read-size guard rejects an oversized backend blob (a
+ /// malicious keychain returning more than a legitimate envelope ever
+ /// could) BEFORE it reaches the envelope parse/derive path. The bound is
+ /// `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD`; both the `get_secret` and
+ /// legacy `get` read paths enforce it.
+ #[test]
+ fn os_read_rejects_oversized_blob() {
+ let b = os_backend();
+ let w = wid(16);
+ let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD;
+ // Attacker writes a blob one byte over the cap straight to the slot.
+ b.place_raw(&w, "seed", &vec![0u8; cap + 1]);
+ let err = b.store.get_secret(&w, "seed", None).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap),
+ "get_secret got {err:?}"
+ );
+ // The legacy `get` path is bounded too.
+ assert!(matches!(
+ b.store.get(&w, "seed").unwrap_err(),
+ SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap
+ ));
+ }
}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs
new file mode 100644
index 00000000000..f6b750eef76
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs
@@ -0,0 +1,252 @@
+//! Bincode-encoded AAD structs for the three contexts that authenticate
+//! ciphertexts under `secrets/`: Tier-2 scheme-1 envelopes, vault entry
+//! bodies, and the vault passphrase-verify token.
+//!
+//! Each struct is `Encode`-only — AAD is producer-side; the decoder
+//! re-builds it from the surrounding context and bincode-encodes again
+//! against [`WIRE_CONFIG`]. Pair-wise byte disjointness is guaranteed by
+//! the three domain constants declared in [`super::config`] and pinned
+//! empirically by the tests `tier2_and_entry_aad_byte_disjoint`,
+//! `tier2_and_verify_aad_byte_disjoint`, and
+//! `entry_and_verify_aad_byte_disjoint`.
+
+use crate::secrets::file::crypto::SALT_LEN;
+use crate::secrets::wire::kdf::KdfParamsEncoded;
+
+/// AAD bound into every scheme-1 (password-protected) Tier-2 envelope.
+/// Binds object identity (`wallet_id` + `label`) + header
+/// (`envelope_version`, `scheme_discriminant`, `kdf`, `salt`) so any
+/// in-place edit of those fields fails the AEAD tag.
+///
+/// `scheme_discriminant` is explicit (not inferred from a Rust enum
+/// variant tag) so the AAD shape is stable under a future `Payload`
+/// re-ordering.
+#[derive(bincode::Encode)]
+pub(crate) struct Tier2Aad<'a> {
+ /// Domain tag — `TIER2_DOMAIN_V2`. Length-prefixed by bincode and
+ /// byte-disjoint from `ENTRY_DOMAIN_V2` / `VERIFY_DOMAIN_V2` by
+ /// content past the common prefix; pinned by the disjointness tests
+ /// in [`super::aad::tests`].
+ pub domain: &'static [u8],
+ /// Envelope wire version (`ENVELOPE_VERSION`).
+ pub envelope_version: u32,
+ /// `0 = Unprotected`, `1 = Password`. Authenticates the scheme byte
+ /// independently of the enum's bincode-derived tag.
+ pub scheme_discriminant: u8,
+ /// The exact bytes encoded into the envelope's `Payload::Password`
+ /// body — AAD == body, so a wire-edited KDF header fails the tag.
+ pub kdf: KdfParamsEncoded,
+ /// Per-wrap CSPRNG salt.
+ pub salt: [u8; SALT_LEN],
+ /// 32-byte wallet correlation id (public, not secret).
+ pub wallet_id: [u8; 32],
+ /// Caller-allowlisted slot label.
+ pub label: &'a str,
+}
+
+/// AAD bound into every vault entry's AEAD seal. Replaces the
+/// hand-rolled `format::aad()` byte concatenation; binds slot identity
+/// (`wallet_id` + `label`) at a stable `format_version`. A relocated
+/// or version-rolled-back blob fails the tag.
+#[derive(bincode::Encode)]
+pub(crate) struct EntryAad<'a> {
+ /// Domain tag — `ENTRY_DOMAIN_V2`.
+ pub domain: &'static [u8],
+ /// Vault `FORMAT_VERSION` (the compiled-in dispatch version,
+ /// never the parsed JSON version).
+ pub format_version: u32,
+ /// 32-byte wallet correlation id.
+ pub wallet_id: [u8; 32],
+ /// Caller-allowlisted slot label.
+ pub label: &'a str,
+}
+
+/// AAD bound into the vault passphrase-verify token's AEAD seal.
+/// Binds salt + KDF header so a flipped salt or KDF-param shift fails
+/// the token tag (surfaces as `WrongPassphrase` — a tampered header
+/// also yields a different derived key).
+#[derive(bincode::Encode)]
+pub(crate) struct VerifyAad {
+ /// Domain tag — `VERIFY_DOMAIN_V2`.
+ pub domain: &'static [u8],
+ /// Vault `FORMAT_VERSION`.
+ pub format_version: u32,
+ /// Vault-wide CSPRNG salt.
+ pub salt: [u8; SALT_LEN],
+ /// Vault-wide KDF parameters (the same wire image used by every
+ /// scheme-1 Tier-2 envelope).
+ pub kdf: KdfParamsEncoded,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::secrets::file::crypto::KdfParams;
+ use crate::secrets::wire::config::{
+ ENTRY_DOMAIN_V2, TIER2_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG,
+ };
+
+ fn floor_kdf() -> KdfParamsEncoded {
+ KdfParamsEncoded::from(KdfParams::default_target())
+ }
+
+ fn tier2_with_domain(domain: &'static [u8]) -> Vec {
+ let aad = Tier2Aad {
+ domain,
+ envelope_version: 1,
+ scheme_discriminant: 1,
+ kdf: floor_kdf(),
+ salt: [0x77u8; SALT_LEN],
+ wallet_id: [0x11u8; 32],
+ label: "seed",
+ };
+ bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap()
+ }
+
+ fn tier2_with_version(envelope_version: u32) -> Vec {
+ let aad = Tier2Aad {
+ domain: TIER2_DOMAIN_V2,
+ envelope_version,
+ scheme_discriminant: 1,
+ kdf: floor_kdf(),
+ salt: [0x77u8; SALT_LEN],
+ wallet_id: [0x11u8; 32],
+ label: "seed",
+ };
+ bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap()
+ }
+
+ fn tier2_with_scheme(scheme_discriminant: u8) -> Vec {
+ let aad = Tier2Aad {
+ domain: TIER2_DOMAIN_V2,
+ envelope_version: 1,
+ scheme_discriminant,
+ kdf: floor_kdf(),
+ salt: [0x77u8; SALT_LEN],
+ wallet_id: [0x11u8; 32],
+ label: "seed",
+ };
+ bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap()
+ }
+
+ fn entry(format_version: u32, wallet_id: [u8; 32], label: &str) -> Vec {
+ let aad = EntryAad {
+ domain: ENTRY_DOMAIN_V2,
+ format_version,
+ wallet_id,
+ label,
+ };
+ bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap()
+ }
+
+ fn verify(salt: [u8; SALT_LEN], kdf: KdfParamsEncoded) -> Vec {
+ let aad = VerifyAad {
+ domain: VERIFY_DOMAIN_V2,
+ format_version: 1,
+ salt,
+ kdf,
+ };
+ bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap()
+ }
+
+ /// Two byte strings where neither is a prefix of the other.
+ fn assert_prefix_disjoint(a: &[u8], b: &[u8]) {
+ assert!(
+ !a.starts_with(b) && !b.starts_with(a),
+ "prefix containment: a.len={} b.len={}",
+ a.len(),
+ b.len()
+ );
+ }
+
+ /// TC-014 — Tier2Aad.domain is bincode-encoded.
+ #[test]
+ fn tier2_aad_domain_field_binds_bytes() {
+ let a = tier2_with_domain(TIER2_DOMAIN_V2);
+ let b = tier2_with_domain(b"PWSEV-TIER2-AAD-vX");
+ assert_ne!(a, b);
+ assert_prefix_disjoint(&a, &b);
+ }
+
+ /// TC-015 — Tier2Aad.envelope_version is bincode-encoded.
+ #[test]
+ fn tier2_aad_envelope_version_field_binds_bytes() {
+ assert_ne!(tier2_with_version(1), tier2_with_version(2));
+ }
+
+ /// TC-016 — Tier2Aad.scheme_discriminant is bincode-encoded and
+ /// explicit (not inferred from a Rust enum tag).
+ #[test]
+ fn tier2_aad_scheme_discriminant_field_binds_bytes() {
+ assert_ne!(tier2_with_scheme(0), tier2_with_scheme(1));
+ }
+
+ /// TC-025 — Tier2Aad and EntryAad are byte-disjoint at the prefix.
+ #[test]
+ fn tier2_and_entry_aad_byte_disjoint() {
+ let t = tier2_with_domain(TIER2_DOMAIN_V2);
+ let e = entry(1, [0x11u8; 32], "seed");
+ assert_prefix_disjoint(&t, &e);
+ }
+
+ /// TC-026 — Tier2Aad and VerifyAad are byte-disjoint at the prefix.
+ #[test]
+ fn tier2_and_verify_aad_byte_disjoint() {
+ let t = tier2_with_domain(TIER2_DOMAIN_V2);
+ let v = verify([0x77u8; SALT_LEN], floor_kdf());
+ assert_prefix_disjoint(&t, &v);
+ }
+
+ /// TC-027 — EntryAad and VerifyAad are byte-disjoint at the prefix.
+ /// Now backed by an explicit domain constant on top of the existing
+ /// VERIFY_LABEL leading-NUL trick at the `format.rs` call site.
+ #[test]
+ fn entry_and_verify_aad_byte_disjoint() {
+ let e = entry(1, [0u8; 32], "\0verify");
+ let v = verify([0x77u8; SALT_LEN], floor_kdf());
+ assert_prefix_disjoint(&e, &v);
+ }
+
+ /// TC-037 — EntryAad binds (format_version, wallet_id, label) and
+ /// the label encoding carries its length prefix (`"a"+"b"` vs
+ /// `"ab"` are distinct).
+ #[test]
+ fn entry_aad_binds_format_version_wallet_id_and_label() {
+ let base = entry(1, [1u8; 32], "a");
+ assert_ne!(base, entry(2, [1u8; 32], "a"));
+ assert_ne!(base, entry(1, [2u8; 32], "a"));
+ assert_ne!(base, entry(1, [1u8; 32], "b"));
+ // Length-prefix sanity: "ab" must not equal the concatenation of
+ // the encoding of "a" with the literal byte `b`.
+ let ab = entry(1, [1u8; 32], "ab");
+ let mut a_plus_b = base.clone();
+ a_plus_b.extend_from_slice(b"b");
+ assert_ne!(ab, a_plus_b);
+ }
+
+ /// TC-038 — VerifyAad binds salt + KDF; identical inputs produce
+ /// identical bytes (determinism).
+ #[test]
+ fn verify_aad_binds_salt_and_kdf_params() {
+ let salt = [7u8; SALT_LEN];
+ let kdf = floor_kdf();
+ let base = verify(salt, kdf);
+ let mut salt2 = salt;
+ salt2[0] ^= 0x01;
+ assert_ne!(base, verify(salt2, kdf));
+
+ let kdf_mkib = KdfParamsEncoded {
+ m_kib: kdf.m_kib / 2,
+ ..kdf
+ };
+ assert_ne!(base, verify(salt, kdf_mkib));
+ let kdf_t = KdfParamsEncoded {
+ t: kdf.t - 1,
+ ..kdf
+ };
+ assert_ne!(base, verify(salt, kdf_t));
+
+ // Determinism: identical inputs ⇒ identical bytes.
+ assert_eq!(base, verify(salt, kdf));
+ }
+}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs
new file mode 100644
index 00000000000..b6c6031a63f
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs
@@ -0,0 +1,43 @@
+//! Single bincode configuration + domain / version constants every
+//! encoder in `secrets/wire/` uses.
+//!
+//! `WIRE_CONFIG` matches the platform-wide
+//! `bincode::config::standard().with_big_endian().with_no_limit()`
+//! (`rs-platform-serialization`) — big-endian for human-readable hex
+//! dumps, varint integer encoding, no decode limit.
+//!
+//! Changing this constant invalidates every stored Tier-2 blob; the
+//! golden-vector tests in [`super::envelope::tests`] catch any drift.
+
+use bincode::config::{BigEndian, Configuration, NoLimit, Varint};
+
+/// The one bincode config used to encode every wire byte under
+/// `secrets/wire/` (envelope payload + the three AAD structs).
+pub(crate) const WIRE_CONFIG: Configuration =
+ bincode::config::standard()
+ .with_big_endian()
+ .with_no_limit();
+
+/// Tier-2 envelope wire version — bumped only on a breaking layout
+/// change, independent of the vault `FORMAT_VERSION`. Bound into every
+/// scheme-1 envelope's AAD so a forged version byte fails the tag.
+pub(crate) const ENVELOPE_VERSION: u32 = 1;
+
+/// Domain-separation tag leading the Tier-2 scheme-1 AAD. `-v2` marks the
+/// wire-format break from the pre-bincode hand-rolled `PWSEV-TIER2-AAD-v1`.
+pub(crate) const TIER2_DOMAIN_V2: &[u8] = b"PWSEV-TIER2-AAD-v2";
+
+/// Domain-separation tag leading every vault `EntryAad`. Pre-bincode
+/// `aad()` had no domain tag; bound here for symmetry + cross-context
+/// disjointness with [`TIER2_DOMAIN_V2`] and [`VERIFY_DOMAIN_V2`].
+pub(crate) const ENTRY_DOMAIN_V2: &[u8] = b"PWSV-ENTRY-AAD-v2";
+
+/// Domain-separation tag leading every vault `VerifyAad`. Disjoint
+/// from [`TIER2_DOMAIN_V2`] and [`ENTRY_DOMAIN_V2`] by **content past
+/// the common prefix** (the three tags are NOT length-distinct —
+/// TIER2 and VERIFY are both 18 bytes; ENTRY is 17). Pair-wise
+/// byte-disjointness is pinned by the tests
+/// `tier2_and_verify_aad_byte_disjoint`,
+/// `tier2_and_entry_aad_byte_disjoint`, and
+/// `entry_and_verify_aad_byte_disjoint`.
+pub(crate) const VERIFY_DOMAIN_V2: &[u8] = b"PWSV-VERIFY-AAD-v2";
diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs
new file mode 100644
index 00000000000..2aacea03291
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs
@@ -0,0 +1,1100 @@
+//! Tier-2 envelope wire format — bincode-encoded `Envelope` / `Payload`
+//! plus the [`wrap`] / [`wrap_with_params`] / [`unwrap`] API.
+//!
+//! Every byte that crosses the AEAD seam is produced by
+//! `bincode::encode_to_vec` against [`WIRE_CONFIG`], so a future config
+//! drift surfaces in the golden-vector tests, not in silently corrupted
+//! blobs. Decoding goes through [`DECODE_CONFIG`] — the same
+//! configuration with a byte limit, so a hostile blob declaring a
+//! multi-GiB length prefix is rejected before any allocation.
+
+use bincode::config::{BigEndian, Configuration, Limit, Varint};
+
+use crate::secrets::error::SecretStoreError;
+use crate::secrets::file::crypto::{self, KdfParams, NONCE_LEN, SALT_LEN};
+use crate::secrets::secret::{SecretBytes, SecretString};
+use crate::secrets::validate::WalletId;
+use crate::secrets::wire::aad::Tier2Aad;
+use crate::secrets::wire::config::{ENVELOPE_VERSION, TIER2_DOMAIN_V2, WIRE_CONFIG};
+use crate::secrets::wire::kdf::KdfParamsEncoded;
+use crate::secrets::MAX_SECRET_LEN;
+
+/// On-disk Tier-2 wire envelope. The whole struct is bincode-encoded
+/// in one call; a wire-edited `version` is gated to
+/// `SecretStoreError::UnsupportedEnvelopeVersion` before dispatch.
+#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)]
+pub(crate) struct Envelope {
+ /// Envelope wire version (`ENVELOPE_VERSION`).
+ pub version: u32,
+ /// Tagged payload selecting unprotected vs password-protected.
+ pub payload: Payload,
+}
+
+/// Tagged payload: scheme-0 ships the plaintext as-is (the backend's
+/// own at-rest crypto is the only defence); scheme-1 ships the AEAD
+/// triple under an object-password-derived key.
+#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)]
+pub(crate) enum Payload {
+ /// Scheme 0 — unprotected passthrough; the bytes are the secret.
+ Unprotected(Vec),
+ /// Scheme 1 — sealed under an Argon2id-derived key with
+ /// XChaCha20-Poly1305. The AAD bound at seal time is
+ /// [`crate::secrets::wire::aad::Tier2Aad`].
+ Password {
+ /// Argon2 parameters used to derive the key.
+ kdf: KdfParamsEncoded,
+ /// Per-wrap CSPRNG salt fed into Argon2.
+ salt: [u8; SALT_LEN],
+ /// Per-wrap CSPRNG nonce fed into XChaCha20-Poly1305.
+ nonce: [u8; NONCE_LEN],
+ /// Ciphertext + 16-byte Poly1305 tag.
+ ciphertext: Vec,
+ },
+}
+
+/// Upper bound on the bincode-encoded envelope overhead over its
+/// plaintext (header + KDF + salt + nonce + AEAD tag + bincode framing).
+/// Pinned by a runtime cross-check in `tests::max_envelope_overhead_matches_runtime`
+/// so any bincode-config drift surfaces immediately. The smallest
+/// scheme-1 envelope (empty plaintext sealed → 16-byte tag) measures
+/// 81 bytes; rounded up to the next 16-byte boundary that satisfies a
+/// 16-byte safety margin (81 + 16 = 97 → 112) for headroom against a
+/// future header field.
+pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 112;
+
+/// Plaintext cap at the envelope boundary: `MAX_SECRET_LEN −
+/// MAX_ENVELOPE_OVERHEAD`. Capping the plaintext (uniformly for both
+/// schemes) keeps the user-visible limit stable AND guarantees the
+/// enveloped bytes always fit the backend vault's own `MAX_SECRET_LEN`
+/// `put_bytes` cap.
+pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD;
+
+/// Decode-side budget: caps the bytes the bincode decoder will consume
+/// from a single envelope. Equal to the on-disk cap.
+const DECODE_BUDGET: usize = MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD;
+
+/// Bincode decode config — derived from [`WIRE_CONFIG`] but with a
+/// [`DECODE_BUDGET`] byte limit applied.
+///
+/// **Asymmetric on purpose, security-positive deviation from
+/// design-brief NF2** (which locks the wire config to
+/// `with_no_limit()`). The deviation exists for hostile-decode
+/// hardening: an attacker-controlled length prefix in the blob would
+/// otherwise drive `Vec::with_capacity` to a multi-GiB allocation
+/// before any tag check. With `Limit`, bincode refuses the
+/// allocation up front and the unwrap fails closed as `Corruption`.
+///
+/// The encoder retains [`WIRE_CONFIG`] (no limit) because AAD and
+/// envelope encoding are producer-only — every input is library-owned
+/// and bounded by `MAX_PLAINTEXT_LEN`, so a limit there has no
+/// security benefit and would be a foot-gun against legitimate
+/// at-cap secrets.
+const DECODE_CONFIG: Configuration> =
+ WIRE_CONFIG.with_limit::();
+
+/// Wrap `plaintext` for `(wallet_id, label)` using the shipped default
+/// Argon2 target when a password is supplied.
+///
+/// `None` → an unprotected (scheme-0) envelope; `Some(pw)` → a scheme-1
+/// envelope sealed under `pw`. A blank password is rejected at enrol
+/// (`SecretStoreError::BlankPassphrase`).
+///
+/// Returns the envelope inside a zeroizing [`SecretBytes`].
+pub(crate) fn wrap(
+ wallet_id: &WalletId,
+ label: &str,
+ password: Option<&SecretString>,
+ plaintext: &[u8],
+) -> Result {
+ wrap_with_params(
+ wallet_id,
+ label,
+ password,
+ plaintext,
+ KdfParams::default_target(),
+ )
+}
+
+/// [`wrap`] with explicit Argon2 `params` (tests use floor params for
+/// speed). `params` is ignored when `password` is `None`.
+pub(crate) fn wrap_with_params(
+ wallet_id: &WalletId,
+ label: &str,
+ password: Option<&SecretString>,
+ plaintext: &[u8],
+ params: KdfParams,
+) -> Result {
+ // Cap the PLAINTEXT (before overhead) uniformly for both schemes so
+ // the enveloped bytes always fit the backend cap.
+ if plaintext.len() > MAX_PLAINTEXT_LEN {
+ return Err(SecretStoreError::SecretTooLarge {
+ found: plaintext.len(),
+ max: MAX_PLAINTEXT_LEN,
+ });
+ }
+
+ let Some(pw) = password else {
+ let envelope = Envelope {
+ version: ENVELOPE_VERSION,
+ payload: Payload::Unprotected(plaintext.to_vec()),
+ };
+ return Ok(SecretBytes::new(encode_envelope(&envelope)));
+ };
+
+ // Reject a blank object password BEFORE any salt / derive.
+ if pw.is_blank() {
+ return Err(SecretStoreError::BlankPassphrase);
+ }
+
+ let mut salt = [0u8; SALT_LEN];
+ crypto::random_bytes(&mut salt)?;
+ let key = crypto::derive_key(pw, &salt, params)?;
+ let kdf = KdfParamsEncoded::from(params);
+ let aad = encode_tier2_aad(wallet_id, label, kdf, &salt);
+ let (nonce, ciphertext) = crypto::seal(&key, &aad, plaintext)?;
+
+ let envelope = Envelope {
+ version: ENVELOPE_VERSION,
+ payload: Payload::Password {
+ kdf,
+ salt,
+ nonce,
+ ciphertext,
+ },
+ };
+ Ok(SecretBytes::new(encode_envelope(&envelope)))
+}
+
+/// Bincode-encode the scheme-1 AAD against [`WIRE_CONFIG`]. Shared by
+/// [`wrap_with_params`] and [`unwrap_password_payload`] so the encode
+/// and decode AADs cannot drift apart.
+pub(crate) fn encode_tier2_aad(
+ wallet_id: &WalletId,
+ label: &str,
+ kdf: KdfParamsEncoded,
+ salt: &[u8; SALT_LEN],
+) -> Vec {
+ let aad = Tier2Aad {
+ domain: TIER2_DOMAIN_V2,
+ envelope_version: ENVELOPE_VERSION,
+ scheme_discriminant: 1,
+ kdf,
+ salt: *salt,
+ wallet_id: *wallet_id.as_bytes(),
+ label,
+ };
+ // AAD encode is infallible — every field is owned/borrowed bincode-
+ // Encode-able. A failure would be a logic bug.
+ bincode::encode_to_vec(aad, WIRE_CONFIG).expect("Tier2Aad encode is infallible")
+}
+
+/// Bincode-encode the whole envelope. Wrapping in `SecretBytes::new`
+/// keeps the (possibly plaintext-bearing) scheme-0 buffer zeroizing.
+fn encode_envelope(envelope: &Envelope) -> Vec {
+ bincode::encode_to_vec(envelope, WIRE_CONFIG).expect("Envelope encode is infallible")
+}
+
+/// Unwrap `blob` for `(wallet_id, label)`, applying the strict
+/// fail-closed read.
+///
+/// `password` carries the caller's protection assertion — never the
+/// blob's scheme byte. Decode errors (truncated, garbage bytes, unknown
+/// enum tag) collapse to `Corruption`; an envelope version this build
+/// does not recognise yields `UnsupportedEnvelopeVersion` ahead of
+/// dispatch.
+///
+/// | `password` | `payload` | result |
+/// |---|---|---|
+/// | `Some(pw)` | `Password { .. }` | the secret, or `WrongPassword` on tag fail |
+/// | `Some(pw)` | `Unprotected(_)` | `ExpectedProtectedButUnsealed` (strip/downgrade) |
+/// | `None` | `Password { .. }` | `NeedsPassword` (never ciphertext) |
+/// | `None` | `Unprotected(pt)` | the secret |
+pub(crate) fn unwrap(
+ wallet_id: &WalletId,
+ label: &str,
+ password: Option<&SecretString>,
+ blob: &[u8],
+) -> Result {
+ let (envelope, consumed) = bincode::decode_from_slice::(blob, DECODE_CONFIG)
+ .map_err(|_| SecretStoreError::Corruption)?;
+ // Trailing bytes after a valid decode are a truncation/extension
+ // probe — fail closed.
+ if consumed != blob.len() {
+ return Err(SecretStoreError::Corruption);
+ }
+
+ if envelope.version != ENVELOPE_VERSION {
+ // `found` keeps the historical u8 — the error API stayed u8 for
+ // back-compat; an out-of-range u32 wraps but the decoder above
+ // already accepts every u32 so this only narrows the diagnostic.
+ return Err(SecretStoreError::UnsupportedEnvelopeVersion {
+ found: envelope.version as u8,
+ });
+ }
+
+ match (envelope.payload, password) {
+ (Payload::Unprotected(plaintext), None) => {
+ // Enforce the same cap the wrap side applies. DECODE_BUDGET is
+ // larger than MAX_PLAINTEXT_LEN (by MAX_ENVELOPE_OVERHEAD), so a
+ // tampered blob can pass the bincode budget check yet exceed the
+ // application-level plaintext ceiling; reject it here.
+ if plaintext.len() > MAX_PLAINTEXT_LEN {
+ return Err(SecretStoreError::SecretTooLarge {
+ found: plaintext.len(),
+ max: MAX_PLAINTEXT_LEN,
+ });
+ }
+ Ok(SecretBytes::new(plaintext))
+ }
+ // Caller asserted protection but blob is unprotected: strip /
+ // downgrade — fail closed, never return the bytes.
+ (Payload::Unprotected(_), Some(_)) => Err(SecretStoreError::ExpectedProtectedButUnsealed),
+ (Payload::Password { .. }, None) => Err(SecretStoreError::NeedsPassword),
+ (
+ Payload::Password {
+ kdf,
+ salt,
+ nonce,
+ ciphertext,
+ },
+ Some(pw),
+ ) => unwrap_password_payload(wallet_id, label, pw, kdf, salt, nonce, &ciphertext),
+ }
+}
+
+/// Decrypt a `Payload::Password` body. The KDF params, salt and nonce
+/// come from the (attacker-controllable) envelope; `enforce_bounds`
+/// AND a stricter per-read `default_target` ceiling gate the params
+/// BEFORE `derive_key` allocates.
+fn unwrap_password_payload(
+ wallet_id: &WalletId,
+ label: &str,
+ password: &SecretString,
+ kdf_encoded: KdfParamsEncoded,
+ salt: [u8; SALT_LEN],
+ nonce: [u8; NONCE_LEN],
+ ciphertext: &[u8],
+) -> Result {
+ // (a0) Mirror wrap's invariant: a blank object password is rejected on
+ // read as well as enrol, so a backend-write attacker who plants a
+ // scheme-1 envelope sealed under the blank password cannot inject
+ // plaintext into a caller that accidentally forwards Some(empty).
+ if password.is_blank() {
+ return Err(SecretStoreError::BlankPassphrase);
+ }
+ // (a) Wider Argon2 floors/ceilings — refuses an inflated header
+ // before any allocation.
+ let kdf = KdfParams::try_from(kdf_encoded)?;
+ // (b) Per-read ceiling tighter than `enforce_bounds`: a header
+ // declaring more memory OR more time than this build's shipped
+ // target is refused before `derive_key` allocates. Closes the gaps
+ // between `ARGON2_MAX_M_KIB` (1 GiB) / `ARGON2_MAX_T` (16) and the
+ // shipped 64 MiB / t=3 default — bounds the worst-case forged read
+ // at the shipped target on both axes (no headroom for an attacker
+ // to inflate memory by 16× or CPU by 5.3×).
+ let target = KdfParams::default_target();
+ if kdf.m_kib > target.m_kib || kdf.t > target.t {
+ return Err(SecretStoreError::KdfFailure);
+ }
+ // (c) AAD binds identity + header — the same bytes the encoder
+ // produced, by construction.
+ let aad = encode_tier2_aad(wallet_id, label, kdf_encoded, &salt);
+ let key = crypto::derive_key(password, &salt, kdf)?;
+ match crypto::open(&key, &nonce, &aad, ciphertext) {
+ Ok(plaintext) => {
+ // A wrap-produced blob is always within MAX_PLAINTEXT_LEN; a
+ // plaintext that exceeds the cap after successful AEAD
+ // authentication must have been produced by a different build or
+ // is tampered — fail closed.
+ if plaintext.len() > MAX_PLAINTEXT_LEN {
+ return Err(SecretStoreError::SecretTooLarge {
+ found: plaintext.len(),
+ max: MAX_PLAINTEXT_LEN,
+ });
+ }
+ Ok(plaintext)
+ }
+ // Tag failure (wrong password, relocated blob, header tamper):
+ // no plaintext ever materialises (CWE-347).
+ Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassword),
+ Err(e) => Err(e),
+ }
+}
+
+/// Test-only deterministic encoder: takes pre-supplied `salt` and
+/// `nonce` instead of pulling from the CSPRNG, so golden-vector tests
+/// produce reproducible bytes. Production callers MUST use
+/// [`wrap_with_params`].
+#[cfg(test)]
+pub(crate) fn wrap_with_params_for_test(
+ wallet_id: &WalletId,
+ label: &str,
+ pw: &SecretString,
+ plaintext: &[u8],
+ params: KdfParams,
+ salt: [u8; SALT_LEN],
+ nonce: [u8; NONCE_LEN],
+) -> Result {
+ if plaintext.len() > MAX_PLAINTEXT_LEN {
+ return Err(SecretStoreError::SecretTooLarge {
+ found: plaintext.len(),
+ max: MAX_PLAINTEXT_LEN,
+ });
+ }
+ if pw.is_blank() {
+ return Err(SecretStoreError::BlankPassphrase);
+ }
+ let key = crypto::derive_key(pw, &salt, params)?;
+ let kdf = KdfParamsEncoded::from(params);
+ let aad = encode_tier2_aad(wallet_id, label, kdf, &salt);
+ let (nonce, ciphertext) = crypto::seal_with_nonce(&key, nonce, &aad, plaintext)?;
+ let envelope = Envelope {
+ version: ENVELOPE_VERSION,
+ payload: Payload::Password {
+ kdf,
+ salt,
+ nonce,
+ ciphertext,
+ },
+ };
+ Ok(SecretBytes::new(encode_envelope(&envelope)))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::secrets::file::crypto::{ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P};
+ use crate::secrets::file::format::KDF_ID_ARGON2ID;
+
+ /// Captured once from the runtime encoder; a subsequent CI failure
+ /// here means a wire-format drift to investigate, NOT to "fix" by
+ /// re-generating the constant.
+ ///
+ /// Decoding: 0x01 envelope.version=1, 0x00 Payload::Unprotected,
+ /// 0x05 Vec length=5, "hello".
+ const SCHEME0_GOLDEN_HEX: &str = "01000568656c6c6f";
+
+ /// scheme-1 deterministic golden: wid=[0;32], label="seed",
+ /// pw="pw", plaintext="hello", floor params, salt=[0x11;32],
+ /// nonce=[0x22;24]. Bytes: version + Payload::Password tag +
+ /// kdf(id,m_kib,t,p as varints) + salt[32] + nonce[24] +
+ /// ciphertext-with-tag length + ciphertext+tag(21B).
+ const SCHEME1_GOLDEN_HEX: &str = "010101fb4c000201111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222215e2ffdf3f0476b6bfb99b4f71b3039ff965132b92f0";
+
+ fn wid(b: u8) -> WalletId {
+ WalletId::from([b; 32])
+ }
+
+ fn pw(s: &str) -> SecretString {
+ SecretString::new(s)
+ }
+
+ fn floor() -> KdfParams {
+ KdfParams {
+ id: KDF_ID_ARGON2ID,
+ m_kib: ARGON2_MIN_M_KIB,
+ t: ARGON2_MIN_T,
+ p: ARGON2_P,
+ }
+ }
+
+ /// TC-033 — blank object password rejected at enrol (wrap-side).
+ #[test]
+ fn blank_object_password_rejected_at_wrap() {
+ for blank in [SecretString::empty(), pw(""), pw(" "), pw("\t\n")] {
+ let err =
+ wrap_with_params(&wid(1), "seed", Some(&blank), b"seed", floor()).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::BlankPassphrase),
+ "got {err:?}"
+ );
+ }
+ }
+
+ /// Symmetric guard on the read side: a `Some(blank)` password reaching
+ /// `unwrap_password_payload` is refused with `BlankPassphrase` BEFORE
+ /// any KDF or AEAD work — never `WrongPassword`, never `Decrypt`,
+ /// never plaintext. Pins the contract that closes the asymmetry where
+ /// a backend-write attacker could plant a scheme-1 envelope sealed
+ /// under the blank password and have a caller that accidentally
+ /// forwards `Some(SecretString::empty())` accept attacker-controlled
+ /// plaintext.
+ #[test]
+ fn unwrap_password_payload_rejects_some_blank_password() {
+ let blob = scheme1_blob(&pw("good"));
+ let err = unwrap(&wid(1), "seed", Some(&SecretString::empty()), &blob).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::BlankPassphrase),
+ "blank object password must be refused before KDF/AEAD, got {err:?}"
+ );
+ }
+
+ /// TC-034 — plaintext cap accept at MAX_PLAINTEXT_LEN, reject at
+ /// +1, for both schemes.
+ #[test]
+ fn plaintext_cap_accept_then_reject() {
+ let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN];
+ let over = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1];
+
+ // Scheme 0
+ assert!(wrap(&wid(1), "seed", None, &at_cap).is_ok());
+ assert!(matches!(
+ wrap(&wid(1), "seed", None, &over).unwrap_err(),
+ SecretStoreError::SecretTooLarge { found, max }
+ if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN
+ ));
+
+ // Scheme 1 — cap check fires before any derivation.
+ let p = pw("pw");
+ assert!(matches!(
+ wrap_with_params(&wid(1), "seed", Some(&p), &over, floor()).unwrap_err(),
+ SecretStoreError::SecretTooLarge { found, max }
+ if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN
+ ));
+
+ // Scheme-0 enveloped bytes for an at-cap plaintext fit the backend cap.
+ let enveloped = wrap(&wid(1), "seed", None, &at_cap).unwrap();
+ assert!(enveloped.len() <= MAX_SECRET_LEN);
+ }
+
+ /// TC-035 (size-budget half) — scheme-1 accepts plaintext at the
+ /// exact MAX_PLAINTEXT_LEN boundary; the enveloped bytes fit the
+ /// backend cap. The round-trip half is `scheme1_at_cap_round_trips_within_backend_cap`.
+ #[test]
+ fn scheme1_at_cap_envelope_fits_backend_cap() {
+ let p = pw("pw");
+ let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN];
+ let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap();
+ assert!(
+ blob.len() <= MAX_SECRET_LEN,
+ "enveloped bytes ({} B) exceed backend cap ({} B)",
+ blob.len(),
+ MAX_SECRET_LEN
+ );
+ }
+
+ /// TC-028 — golden hex vector for the scheme-0 wire bytes. Any
+ /// bincode-config drift (endianness, varint mode, limit) trips this.
+ #[test]
+ fn scheme0_golden_vector_matches_const() {
+ let blob = wrap(&WalletId::from([0u8; 32]), "seed", None, b"hello").unwrap();
+ let actual = hex::encode(blob.expose_secret());
+ assert_eq!(actual, SCHEME0_GOLDEN_HEX);
+ }
+
+ /// TC-029 — golden hex vector for the scheme-1 wire bytes, produced
+ /// via the deterministic encoder seam.
+ #[test]
+ fn scheme1_golden_vector_matches_const() {
+ let blob = wrap_with_params_for_test(
+ &WalletId::from([0u8; 32]),
+ "seed",
+ &pw("pw"),
+ b"hello",
+ floor(),
+ [0x11u8; SALT_LEN],
+ [0x22u8; NONCE_LEN],
+ )
+ .unwrap();
+ let actual = hex::encode(blob.expose_secret());
+ assert_eq!(actual, SCHEME1_GOLDEN_HEX);
+ }
+
+ /// Minimum overhead within budget AND the budget not absurdly above
+ /// the actual encoding — bound on both sides so the constant stays
+ /// honest as the wire shape evolves.
+ const SAFETY_MARGIN: usize = 16;
+
+ /// TC-030 — `MAX_ENVELOPE_OVERHEAD` cross-checks the runtime
+ /// bincode encoding of the smallest possible scheme-1 envelope
+ /// (empty plaintext sealed → ciphertext == 16-byte AEAD tag).
+ #[test]
+ fn max_envelope_overhead_matches_runtime() {
+ let blob = wrap_with_params_for_test(
+ &WalletId::from([0u8; 32]),
+ "seed",
+ &pw("pw"),
+ b"",
+ floor(),
+ [0x11u8; SALT_LEN],
+ [0x22u8; NONCE_LEN],
+ )
+ .unwrap();
+ let actual = blob.len();
+ assert!(
+ actual + SAFETY_MARGIN <= MAX_ENVELOPE_OVERHEAD,
+ "overhead {} + margin {} exceeds const {}",
+ actual,
+ SAFETY_MARGIN,
+ MAX_ENVELOPE_OVERHEAD
+ );
+ assert!(
+ MAX_ENVELOPE_OVERHEAD - actual < 64,
+ "MAX_ENVELOPE_OVERHEAD {} is more than 64 B above the runtime measurement {} — tighten it",
+ MAX_ENVELOPE_OVERHEAD,
+ actual
+ );
+ }
+
+ // ===== Decoder: dispatch / wire-flip / fuzz / property =====
+
+ use crate::secrets::file::crypto::{ARGON2_MAX_M_KIB, ARGON2_MAX_T};
+ use crate::secrets::wire::config::WIRE_CONFIG;
+ use subtle::ConstantTimeEq;
+
+ /// Decode a real envelope so wire-flip tests can mutate one field
+ /// and re-encode.
+ fn decode(blob: &[u8]) -> Envelope {
+ bincode::decode_from_slice::(blob, WIRE_CONFIG)
+ .unwrap()
+ .0
+ }
+
+ fn encode(envelope: &Envelope) -> Vec {
+ bincode::encode_to_vec(envelope, WIRE_CONFIG).unwrap()
+ }
+
+ /// Build a fresh scheme-1 envelope (under wid(1)/"seed"/pw=`p`) and
+ /// hand back the bytes for mutation tests.
+ fn scheme1_blob(p: &SecretString) -> Vec {
+ wrap_with_params(&wid(1), "seed", Some(p), b"seed", floor())
+ .unwrap()
+ .expose_secret()
+ .to_vec()
+ }
+
+ /// TC-001 — scheme-0 round-trip preserves plaintext.
+ #[test]
+ fn scheme0_round_trip_preserves_plaintext() {
+ let blob = wrap(&wid(1), "seed", None, b"top secret seed bytes").unwrap();
+ let got = unwrap(&wid(1), "seed", None, blob.expose_secret()).unwrap();
+ assert_eq!(got.expose_secret(), b"top secret seed bytes");
+ }
+
+ /// TC-002 — scheme-1 round-trip preserves plaintext.
+ #[test]
+ fn scheme1_round_trip_preserves_plaintext() {
+ let p = pw("hunter2");
+ let blob = wrap_with_params(
+ &wid(7),
+ "seed",
+ Some(&p),
+ b"correct horse battery staple",
+ floor(),
+ )
+ .unwrap();
+ assert_ne!(blob.expose_secret(), b"correct horse battery staple");
+ let got = unwrap(&wid(7), "seed", Some(&p), blob.expose_secret()).unwrap();
+ assert_eq!(got.expose_secret(), b"correct horse battery staple");
+ }
+
+ /// TC-003 — scheme-1 produces a fresh salt + nonce per wrap.
+ #[test]
+ fn scheme1_uses_fresh_salt_and_nonce_per_wrap() {
+ let p = pw("pw");
+ let a = scheme1_blob(&p);
+ let b = scheme1_blob(&p);
+ let (sa, na) = match decode(&a).payload {
+ Payload::Password { salt, nonce, .. } => (salt, nonce),
+ _ => panic!("scheme-1 wrap must yield Password"),
+ };
+ let (sb, nb) = match decode(&b).payload {
+ Payload::Password { salt, nonce, .. } => (salt, nonce),
+ _ => panic!("scheme-1 wrap must yield Password"),
+ };
+ assert_ne!(sa, sb, "salt must be fresh per wrap");
+ assert_ne!(na, nb, "nonce must be fresh per wrap");
+ }
+
+ /// TC-004 — wrong object password yields WrongPassword.
+ #[test]
+ fn wrong_password_fails_closed() {
+ let blob = scheme1_blob(&pw("right"));
+ let err = unwrap(&wid(1), "seed", Some(&pw("wrong")), &blob).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// Mutate the `Payload::Password` body in-place via decode → patch
+ /// → encode. Returns the new blob.
+ fn mutate_scheme1(
+ blob: &[u8],
+ patch: impl FnOnce(&mut KdfParamsEncoded, &mut [u8; SALT_LEN], &mut [u8; NONCE_LEN]),
+ ) -> Vec {
+ let mut env = decode(blob);
+ match env.payload {
+ Payload::Password {
+ ref mut kdf,
+ ref mut salt,
+ ref mut nonce,
+ ..
+ } => patch(kdf, salt, nonce),
+ _ => panic!("mutate_scheme1 expects a Password payload"),
+ }
+ encode(&env)
+ }
+
+ /// TC-005 — wire-flip of kdf.m_kib (in-bounds shift) yields WrongPassword.
+ #[test]
+ fn wire_flip_kdf_m_kib_fails_closed() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let tampered = mutate_scheme1(&blob, |kdf, _, _| {
+ kdf.m_kib = ARGON2_MIN_M_KIB + 1024;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-006 — wire-flip of kdf.t (in-bounds shift) yields WrongPassword.
+ #[test]
+ fn wire_flip_kdf_t_fails_closed() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let tampered = mutate_scheme1(&blob, |kdf, _, _| {
+ kdf.t = ARGON2_MIN_T + 1;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-007 — wire-flip of kdf.id to an unknown value is rejected by
+ /// `enforce_bounds` BEFORE `derive_key` allocates.
+ #[test]
+ fn wire_flip_kdf_id_unknown_rejected_pre_derive() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let tampered = mutate_scheme1(&blob, |kdf, _, _| {
+ kdf.id = 7;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}");
+ }
+
+ /// TC-008 — wire-flip of salt[0] yields WrongPassword.
+ #[test]
+ fn wire_flip_salt_fails_closed() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let tampered = mutate_scheme1(&blob, |_, salt, _| {
+ salt[0] ^= 0x01;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-009 — wire-flip of nonce[0] yields WrongPassword.
+ #[test]
+ fn wire_flip_nonce_fails_closed() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let tampered = mutate_scheme1(&blob, |_, _, nonce| {
+ nonce[0] ^= 0x01;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-010 — re-binding the unwrap to a different wallet_id rejects.
+ #[test]
+ fn relocation_across_wallet_id_rejected() {
+ let p = pw("pw");
+ let blob = wrap_with_params(&wid(0xA), "seed", Some(&p), b"seed", floor()).unwrap();
+ let err = unwrap(&wid(0xB), "seed", Some(&p), blob.expose_secret()).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-011 — re-binding the unwrap to a different label rejects.
+ #[test]
+ fn relocation_across_label_rejected() {
+ let p = pw("pw");
+ let blob = wrap_with_params(&wid(1), "labelA", Some(&p), b"seed", floor()).unwrap();
+ let err = unwrap(&wid(1), "labelB", Some(&p), blob.expose_secret()).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::WrongPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-012 — wire-flip of envelope.version (via re-encode) is gated
+ /// to UnsupportedEnvelopeVersion before AAD bind.
+ #[test]
+ fn wire_flip_version_rejected_pre_aad() {
+ let blob = scheme1_blob(&pw("pw"));
+ let mut env = decode(&blob);
+ env.version = 2;
+ let tampered = encode(&env);
+ let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &tampered).unwrap_err();
+ assert!(
+ matches!(
+ err,
+ SecretStoreError::UnsupportedEnvelopeVersion { found: 2 }
+ ),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-013 — forged `Payload::Unprotected` with ciphertext bytes +
+ /// `Some(pw)` redirects to ExpectedProtectedButUnsealed.
+ #[test]
+ fn wire_flip_scheme_dispatch_redirects_safely() {
+ let env = Envelope {
+ version: ENVELOPE_VERSION,
+ payload: Payload::Unprotected(vec![0xDEu8; 32]),
+ };
+ let blob = encode(&env);
+ let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::ExpectedProtectedButUnsealed),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-017 — truncated blob (< minimum envelope length) yields
+ /// Corruption.
+ #[test]
+ fn truncated_blob_yields_corruption() {
+ let blob = scheme1_blob(&pw("pw"));
+ let cut = blob.len() / 2;
+ let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob[..cut]).unwrap_err();
+ assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}");
+ }
+
+ /// TC-018 — random-byte blob yields Corruption (both arms).
+ #[test]
+ fn random_garbage_yields_corruption() {
+ let garbage = b"NOTANEVELOPE.........................";
+ let err = unwrap(&wid(1), "seed", None, garbage).unwrap_err();
+ assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}");
+ let err = unwrap(&wid(1), "seed", Some(&pw("pw")), garbage).unwrap_err();
+ assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}");
+ }
+
+ /// TC-019 — a manually-built envelope at version=2 fails closed
+ /// regardless of password.
+ #[test]
+ fn unsupported_version_rejected_for_any_password() {
+ let env = Envelope {
+ version: 2,
+ payload: Payload::Unprotected(b"x".to_vec()),
+ };
+ let blob = encode(&env);
+ for arg in [None, Some(&pw("pw"))] {
+ let err = unwrap(&wid(1), "seed", arg, &blob).unwrap_err();
+ assert!(
+ matches!(
+ err,
+ SecretStoreError::UnsupportedEnvelopeVersion { found: 2 }
+ ),
+ "got {err:?}"
+ );
+ }
+ }
+
+ /// TC-020 — a hand-crafted byte stream with an unknown payload
+ /// enum tag yields Corruption (bincode's natural fail-closed).
+ #[test]
+ fn unknown_scheme_discriminant_yields_corruption() {
+ // envelope.version = 1 (varint = 0x01) then a Payload enum tag
+ // of 7 (varint = 0x07) — the two-variant enum decode rejects.
+ let blob = [0x01u8, 0x07];
+ let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err();
+ assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}");
+ }
+
+ /// TC-021 — Some(pw) + scheme-0 yields ExpectedProtectedButUnsealed.
+ #[test]
+ fn some_pw_on_scheme0_fails_closed() {
+ let blob = wrap(&wid(1), "seed", None, b"attacker-seed").unwrap();
+ let err = unwrap(&wid(1), "seed", Some(&pw("pw")), blob.expose_secret()).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::ExpectedProtectedButUnsealed),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-022 — None + scheme-1 yields NeedsPassword.
+ #[test]
+ fn none_pw_on_scheme1_yields_needs_password() {
+ let blob = scheme1_blob(&pw("pw"));
+ let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err();
+ assert!(
+ matches!(err, SecretStoreError::NeedsPassword),
+ "got {err:?}"
+ );
+ }
+
+ /// TC-023 — inflated KDF param rejected by `enforce_bounds` before
+ /// `derive_key` allocates (a ~4 TiB allocation would OOM the test).
+ #[test]
+ fn kdf_enforce_bounds_rejects_before_derive() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let tampered = mutate_scheme1(&blob, |kdf, _, _| {
+ kdf.m_kib = u32::MAX;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}");
+
+ let tampered = mutate_scheme1(&blob, |kdf, _, _| {
+ kdf.t = ARGON2_MAX_T + 1;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}");
+ }
+
+ /// TC-024 — per-read `default_target` ceiling rejects an envelope
+ /// whose `m_kib` exceeds the shipped target even when still inside
+ /// `enforce_bounds`. Catches inflated headers BEFORE `derive_key`.
+ #[test]
+ fn per_read_default_target_ceiling_rejects_inflated_header() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let bumped = KdfParams::default_target().m_kib * 2;
+ // Sanity: the bumped value stays inside the wider enforce_bounds
+ // ceiling, so only the per-read gate can refuse it.
+ assert!(bumped <= ARGON2_MAX_M_KIB);
+ let tampered = mutate_scheme1(&blob, |kdf, _, _| {
+ kdf.m_kib = bumped;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}");
+ }
+
+ /// Sibling to TC-024 on the `t` axis — per-read `default_target`
+ /// ceiling rejects an envelope whose `t` exceeds the shipped target
+ /// even when still inside `enforce_bounds` (`ARGON2_MAX_T = 16`).
+ /// Closes the CPU-axis gap that would otherwise let a forged header
+ /// run Argon2 at 5.3× the shipped iteration count.
+ #[test]
+ fn kdf_t_ceiling_fires_before_derive() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let target = KdfParams::default_target();
+ let bumped_t = target.t + 1;
+ // Sanity: the bumped t stays inside the wider enforce_bounds
+ // ceiling, so only the per-read gate can refuse it.
+ assert!(bumped_t <= ARGON2_MAX_T);
+ let tampered = mutate_scheme1(&blob, |kdf, _, _| {
+ // Keep m_kib at the shipped default so the m_kib gate
+ // cannot fire — t must be the sole reason this rejects.
+ kdf.m_kib = target.m_kib;
+ kdf.t = bumped_t;
+ });
+ let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err();
+ assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}");
+ }
+
+ /// Trailing bytes appended after a valid envelope are rejected as
+ /// `Corruption` — defends against a truncation/extension probe.
+ #[test]
+ fn decode_rejects_trailing_garbage() {
+ let p = pw("pw");
+ let blob = scheme1_blob(&p);
+ let mut extended = blob.clone();
+ extended.extend_from_slice(&[0xFFu8; 16]);
+ let err = unwrap(&wid(1), "seed", Some(&p), &extended).unwrap_err();
+ assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}");
+
+ // The same blob without the suffix still unwraps cleanly —
+ // proves the rejection is on the trailing bytes, not the
+ // envelope itself.
+ let ok = unwrap(&wid(1), "seed", Some(&p), &blob).unwrap();
+ assert_eq!(ok.expose_secret(), b"seed");
+ }
+
+ /// TC-031 — round-tripped secret matches the original under a
+ /// constant-time compare.
+ #[test]
+ fn round_trip_is_constant_time_equal() {
+ let p = pw("pw");
+ let original = SecretBytes::from_slice(b"seed material");
+ let blob =
+ wrap_with_params(&wid(1), "seed", Some(&p), original.expose_secret(), floor()).unwrap();
+ let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap();
+ assert!(bool::from(got.ct_eq(&original)));
+ }
+
+ /// TC-035 (round-trip half) — scheme-1 at exact MAX_PLAINTEXT_LEN
+ /// round-trips and the enveloped bytes fit the backend cap.
+ #[test]
+ fn scheme1_at_cap_round_trips_within_backend_cap() {
+ let p = pw("pw");
+ let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN];
+ let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap();
+ assert!(blob.len() <= MAX_SECRET_LEN);
+ let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap();
+ assert_eq!(got.expose_secret(), &pt[..]);
+ }
+
+ /// TC-037 — scheme-0 decode rejects an oversize plaintext (> MAX_PLAINTEXT_LEN)
+ /// even though the blob fits within DECODE_BUDGET. A tampered blob that encodes
+ /// a plaintext between MAX_PLAINTEXT_LEN+1 and DECODE_BUDGET would otherwise
+ /// bypass the wrap-side cap on the decode path.
+ #[test]
+ fn scheme0_decode_rejects_oversize_plaintext() {
+ // Build a scheme-0 envelope with plaintext = MAX_PLAINTEXT_LEN + 1 bytes.
+ // encode_envelope bypasses the wrap-side cap (it is a raw encoder), so this
+ // creates the exact tampered-blob scenario.
+ let oversized = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1];
+ let env = Envelope {
+ version: ENVELOPE_VERSION,
+ payload: Payload::Unprotected(oversized.clone()),
+ };
+ let blob = encode(&env);
+ // Confirm the blob fits within DECODE_BUDGET (otherwise the test proves nothing).
+ assert!(
+ blob.len() <= DECODE_BUDGET,
+ "test blob must fit within DECODE_BUDGET to prove the plaintext check fires"
+ );
+ let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err();
+ assert!(
+ matches!(
+ err,
+ SecretStoreError::SecretTooLarge { found, max }
+ if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN
+ ),
+ "expected SecretTooLarge on oversized scheme-0 plaintext, got {err:?}"
+ );
+ }
+
+ /// TC-038 — scheme-0 decode accepts a plaintext at exactly MAX_PLAINTEXT_LEN.
+ #[test]
+ fn scheme0_decode_accepts_at_cap_plaintext() {
+ let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN];
+ let env = Envelope {
+ version: ENVELOPE_VERSION,
+ payload: Payload::Unprotected(at_cap.clone()),
+ };
+ let blob = encode(&env);
+ let got = unwrap(&wid(1), "seed", None, &blob).unwrap();
+ assert_eq!(got.expose_secret(), &at_cap[..]);
+ }
+
+ /// TC-036 — value rollback is intentionally NOT defended.
+ #[test]
+ fn value_rollback_is_not_defended() {
+ let p = pw("pw");
+ let old = wrap_with_params(&wid(1), "seed", Some(&p), b"OLD-VALUE", floor()).unwrap();
+ let _new = wrap_with_params(&wid(1), "seed", Some(&p), b"NEW-VALUE", floor()).unwrap();
+ let got = unwrap(&wid(1), "seed", Some(&p), old.expose_secret()).unwrap();
+ assert_eq!(got.expose_secret(), b"OLD-VALUE");
+ }
+
+ /// TC-032 — random byte mutations and truncations never panic;
+ /// every outcome is a permitted typed variant.
+ #[test]
+ fn fuzz_byte_mutation_and_truncation_never_panics() {
+ let p = pw("fuzz-pw");
+ let valid = scheme1_blob(&p);
+ // Pristine envelope unwraps cleanly.
+ assert_eq!(
+ unwrap(&wid(1), "seed", Some(&p), &valid)
+ .unwrap()
+ .expose_secret(),
+ b"seed"
+ );
+
+ let mut state: u32 = 0x9E37_79B9;
+ let mut next = || {
+ state ^= state << 13;
+ state ^= state >> 17;
+ state ^= state << 5;
+ state
+ };
+
+ let assert_typed = |arg: Option<&SecretString>, buf: &[u8]| {
+ let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ unwrap(&wid(1), "seed", arg, buf)
+ }))
+ .expect("unwrap must never panic on hostile input");
+ match res {
+ Ok(_)
+ | Err(SecretStoreError::Corruption)
+ | Err(SecretStoreError::WrongPassword)
+ | Err(SecretStoreError::NeedsPassword)
+ | Err(SecretStoreError::ExpectedProtectedButUnsealed)
+ | Err(SecretStoreError::UnsupportedEnvelopeVersion { .. })
+ | Err(SecretStoreError::KdfFailure)
+ // A mutated scheme-0 blob can decode a plaintext Vec that
+ // exceeds MAX_PLAINTEXT_LEN while still fitting DECODE_BUDGET.
+ | Err(SecretStoreError::SecretTooLarge { .. }) => {}
+ Err(other) => panic!("unexpected error variant: {other:?}"),
+ }
+ };
+
+ for i in 0..2_000 {
+ let mut buf = valid.clone();
+ let flips = 1 + (next() % 4) as usize;
+ for _ in 0..flips {
+ let idx = (next() as usize) % buf.len();
+ buf[idx] ^= (next() & 0xFF) as u8;
+ }
+ // None path every iteration (cheap, no derive).
+ assert_typed(None, &buf);
+ // Some path on a representative subset (each may derive).
+ if i % 16 == 0 {
+ assert_typed(Some(&p), &buf);
+ }
+ }
+
+ // Truncation at every offset — a short read must never panic.
+ for cut in 0..valid.len() {
+ assert_typed(None, &valid[..cut]);
+ assert_typed(Some(&p), &valid[..cut]);
+ }
+ }
+
+ // TC-040 — proptest: no single-byte flip surfaces the plaintext.
+ // Minimises to the offset that breaks coverage if one exists.
+ proptest::proptest! {
+ #[test]
+ fn prop_single_byte_flip_never_yields_plaintext(
+ (offset, mask) in (0usize..200usize, 1u8..=255u8),
+ ) {
+ // Re-built per case so the proptest harness can shrink
+ // independently of the host RNG.
+ let plaintext: &[u8] = b"goldfinch";
+ let p = pw("pw");
+ let valid = wrap_with_params(&wid(1), "seed", Some(&p), plaintext, floor())
+ .unwrap()
+ .expose_secret()
+ .to_vec();
+ if offset >= valid.len() {
+ // Out-of-bounds offset → skip via prop_assume so proptest
+ // shrinks toward in-bounds offsets.
+ proptest::prop_assume!(offset < valid.len());
+ }
+ let mut buf = valid.clone();
+ buf[offset] ^= mask;
+ match unwrap(&wid(1), "seed", Some(&p), &buf) {
+ Ok(secret) => {
+ proptest::prop_assert_ne!(
+ secret.expose_secret(),
+ plaintext,
+ "single-byte flip at offset {} surfaced the plaintext",
+ offset
+ );
+ }
+ Err(_) => { /* any typed error is fine */ }
+ }
+ }
+ }
+}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs
new file mode 100644
index 00000000000..e869b29159b
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs
@@ -0,0 +1,56 @@
+//! Bincode-encoded wire image of [`KdfParams`] — the Argon2 parameter
+//! header read out of every scheme-1 envelope.
+//!
+//! Kept as a separate type from [`KdfParams`] (the in-memory + JSON-
+//! vault type) so the wire layer owns its own bincode derives and the
+//! in-memory type keeps its serde derives for the human-debuggable JSON
+//! vault format.
+
+use crate::secrets::error::SecretStoreError;
+use crate::secrets::file::crypto::KdfParams;
+
+/// Wire image of [`KdfParams`]: `id ‖ m_kib ‖ t ‖ p`, each a fixed-
+/// width integer under the bincode varint config. Encoded once into
+/// every scheme-1 envelope's `Payload::Password` body AND into the
+/// scheme-1 AAD, so the two cannot disagree without failing the tag.
+#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq, Clone, Copy)]
+pub(crate) struct KdfParamsEncoded {
+ /// Argon2 algorithm discriminator (only `KDF_ID_ARGON2ID = 1`
+ /// today; enforced by [`KdfParams::enforce_bounds`]).
+ pub id: u8,
+ /// Argon2 memory cost (KiB). Bounded.
+ pub m_kib: u32,
+ /// Argon2 time cost (iterations). Bounded.
+ pub t: u32,
+ /// Argon2 parallelism. Pinned to 1.
+ pub p: u32,
+}
+
+impl From for KdfParamsEncoded {
+ fn from(k: KdfParams) -> Self {
+ Self {
+ id: k.id,
+ m_kib: k.m_kib,
+ t: k.t,
+ p: k.p,
+ }
+ }
+}
+
+impl TryFrom for KdfParams {
+ type Error = SecretStoreError;
+
+ /// Convert the wire image into the in-memory [`KdfParams`], gated on
+ /// [`KdfParams::enforce_bounds`] so an inflated header never
+ /// reaches `derive_key`.
+ fn try_from(k: KdfParamsEncoded) -> Result {
+ let out = KdfParams {
+ id: k.id,
+ m_kib: k.m_kib,
+ t: k.t,
+ p: k.p,
+ };
+ out.enforce_bounds()?;
+ Ok(out)
+ }
+}
diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs
new file mode 100644
index 00000000000..d53ff9bbb96
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs
@@ -0,0 +1,36 @@
+//! Bincode wire format for the Tier-2 envelope and the three AAD
+//! constructions used inside `secrets/`.
+//!
+//! Every byte that crosses the AEAD seam — the on-disk Tier-2 blob and the
+//! AAD bound into each ciphertext — is produced by a `#[derive(bincode::
+//! Encode)]` (or `Encode + Decode`) struct in this module, against the
+//! single [`config::WIRE_CONFIG`] constant. A future bincode-config drift
+//! is then caught by the golden vector tests in [`envelope::tests`]
+//! instead of silently corrupting every stored blob.
+//!
+//! Module is `pub(crate)` only — the Tier-2 wire format is an
+//! implementation detail of [`SecretStore`](super::store::SecretStore);
+//! external callers see the unchanged `set_secret` / `get_secret` API.
+//!
+//! Audit-readable layout:
+//!
+//! - [`config`] — the single bincode config + domain-tag / version
+//! constants every encoder uses.
+//! - [`kdf`] — `KdfParamsEncoded`, the wire image of [`KdfParams`].
+//! - [`aad`] — the three AAD structs (`Tier2Aad` / `EntryAad` /
+//! `VerifyAad`).
+//! - [`envelope`] — the `Envelope` + `Payload` structs plus the
+//! `wrap` / `unwrap` API.
+//!
+//! [`KdfParams`]: super::file::crypto::KdfParams
+//!
+//! Domain tags include an explicit `-v2` suffix to mark the
+//! wire-format break from the pre-bincode hand-rolled layout
+//! (`PWSEV-TIER2-AAD-v1` and the implicitly-untagged
+//! `secrets/file/format.rs::aad` / `verify_aad` outputs).
+#![deny(missing_docs)]
+
+pub(crate) mod aad;
+pub(crate) mod config;
+pub(crate) mod envelope;
+pub(crate) mod kdf;
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs
index 064ee3a23a2..7eaca6fe6aa 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs
@@ -12,11 +12,9 @@ use crate::sqlite::error::WalletStorageError;
use crate::sqlite::persister::{PruneReport, RetentionPolicy};
use crate::sqlite::util::permissions::apply_secure_permissions;
-/// Fsync the parent directory of `path` on Unix so the rename entry
-/// that materialised `path` is durable across power loss.
-/// `persist` only fsyncs the file inode; on most Unix filesystems the
-/// dentry update is journalled separately and can be lost on crash
-/// without this step. No-op on non-Unix platforms.
+/// Fsync `path`'s parent dir on Unix so the rename's dentry update is
+/// durable across power loss (`persist` only fsyncs the file inode; the
+/// dentry is journalled separately). No-op on non-Unix.
#[cfg(unix)]
fn fsync_parent_dir(path: &Path) -> Result<(), WalletStorageError> {
if let Some(parent) = path.parent() {
@@ -69,41 +67,27 @@ pub fn auto_backup_filename(kind: BackupKind) -> String {
}
}
-/// Take an online backup of `src` to `dest`. Uses the
-/// `rusqlite::backup::Backup::run_to_completion` page-stepping API
-/// so writers aren't blocked.
+/// Take an online backup of `src` to `dest` via the page-stepping
+/// `Backup::run_to_completion` API so writers aren't blocked.
///
/// # Atomicity
///
-/// The page-stepping copy runs against a `NamedTempFile` staged in
-/// `dest`'s parent directory. The temp is `persist_noclobber`-ed over
-/// `dest` only on success — any failure (open, chmod, backup-stream)
-/// drops the temp without ever materialising a partial `.db` file at
-/// the caller's path. A pre-existing `dest` is rejected atomically by
-/// `persist_noclobber` (no TOCTOU window). On Unix, the parent
-/// directory is `fsync`-ed after the rename so the dentry update
-/// survives power loss; on non-Unix this fsync step is a no-op.
+/// The copy is staged in a `NamedTempFile` next to `dest` and
+/// `persist_noclobber`-ed over `dest` only on success, so a failure never
+/// materialises a partial `.db`. A pre-existing `dest` is rejected
+/// atomically (no TOCTOU window), and the parent dir is fsynced afterward.
pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> {
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
- // Pre-existing-destination rejection happens at the
- // `persist_noclobber` site below — that's atomic against the rename
- // (no TOCTOU window between `dest.exists()` and persist). The
- // CLI's `backup_to(file_path)` still gets the typed
- // `BackupDestinationExists` error; auto-backup callers can't trip
- // it because the filename carries a unique timestamp suffix.
-
- // Stage the backup into an unguessable temp file in the same
- // directory. Same-FS guarantee makes `persist` an atomic rename.
+ // Stage in an unguessable temp file in the same dir; the same-FS
+ // guarantee makes `persist` an atomic rename.
let parent = dest.parent().unwrap_or(Path::new("."));
let tmp = tempfile::NamedTempFile::new_in(parent)?;
- // Tighten the temp's mode to 0o600 BEFORE persist so the
- // destination inherits owner-only permissions via the atomic
- // rename. Running chmod after persist would leave a brief
- // umask-default window where the destination is observable.
+ // chmod 0o600 BEFORE persist so the destination inherits owner-only
+ // mode via the rename; chmod after would leave an observable window.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -111,8 +95,6 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> {
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
- // Page-stepping copy against the temp. The dest Connection has to
- // own its own file handle; rusqlite opens it from a path.
let mut backup_conn =
crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?;
{
@@ -120,15 +102,12 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> {
// 100 pages × 4 KiB = 400 KiB per step on default SQLite page size.
backup.run_to_completion(100, Duration::from_millis(5), None)?;
}
- // Close the backup Connection before persisting so SQLite flushes
- // its own WAL/SHM siblings against the temp path — those go away
- // with the rename since `persist` atomically renames the temp file.
+ // Close before persisting so SQLite flushes its WAL/SHM siblings
+ // against the temp path; the rename then sweeps them away.
drop(backup_conn);
- // `persist_noclobber` is the atomic check-and-rename — SQLite-free,
- // no TOCTOU window between an `exists()` probe and the rename.
- // `AlreadyExists` maps to the typed `BackupDestinationExists` for
- // the CLI's overwrite-refusal contract.
+ // Atomic check-and-rename with no TOCTOU window; `AlreadyExists` maps
+ // to the typed `BackupDestinationExists` overwrite-refusal contract.
tmp.persist_noclobber(dest).map_err(|e| {
if e.error.kind() == std::io::ErrorKind::AlreadyExists {
WalletStorageError::BackupDestinationExists {
@@ -138,86 +117,41 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> {
WalletStorageError::Io(e.error)
}
})?;
- // Fsync the parent directory so the atomic rename's dentry update is
- // durable across power loss. On non-Unix this is a no-op.
fsync_parent_dir(dest)?;
- // Re-tighten in case a non-Unix build (or a future platform-specific
- // tweak) needs to refresh sibling perms after SQLite materialised
- // them. No-op on Unix where the temp already landed at 0o600.
+ // Re-tighten for non-Unix builds; no-op on Unix where the temp
+ // already landed at 0o600.
apply_secure_permissions(dest)?;
Ok(())
}
-/// Restore a `.db` backup over `dest_db_path`. Associated function;
-/// caller must guarantee the destination is not held open by this
-/// process. The caller (the persister's `restore_from_inner`) handles
-/// the pre-restore auto-backup gate.
+/// Restore a `.db` backup over `dest_db_path`. The caller must guarantee
+/// the destination is not held open by this process and owns the
+/// pre-restore auto-backup gate.
///
/// # Atomicity
///
-/// The restore is staged in two phases bounded by a SQLite-native
-/// `BEGIN EXCLUSIVE` transaction on `dest_db_path` (kept across the
-/// entire restore body):
-///
-/// 1. Open the source read-only; run `PRAGMA integrity_check` +
-/// schema-history + max-version sniffs. Any failure here aborts
-/// before the live destination is touched.
-/// 2. Open a short-lived writer connection on the destination and
-/// `BEGIN EXCLUSIVE`. This blocks every other SQLite peer
-/// (other `SqlitePersister` handles in this or sibling processes,
-/// bare `rusqlite::Connection`s, the CLI) from writing the file
-/// until restore completes. Peers waiting for the lock back off
-/// via SQLite's own busy_timeout. The lock conn is DROPPED right
-/// before `persist` so SQLite releases its file handle on the old
-/// inode before the atomic rename takes its place.
-/// 3. Stream the source into a `NamedTempFile` in `dest_db_path`'s
-/// parent directory; re-run integrity + schema gates against the
-/// STAGED bytes (catches a torn `io::copy`); unlink the existing
-/// `-wal` / `-shm` siblings; chmod the temp to 0o600; then
-/// `persist` over `dest_db_path` as an atomic rename.
-///
-/// Either both the main DB and its WAL/SHM siblings are replaced, or
-/// — on any pre-persist failure — none of them are touched. The
-/// SQLite-native lock prevents a racing peer from committing rows
-/// between the staged validation and the rename, which the prior
-/// flock-based approach could not do (flock doesn't see SQLite peers).
-///
-/// On Unix, the parent directory is `fsync`-ed after the rename so the
-/// dentry update is durable across power loss; on non-Unix this is a
-/// no-op.
+/// Validation runs against the source and again against the STAGED bytes,
+/// under a SQLite-native `BEGIN EXCLUSIVE` on `dest_db_path` that blocks
+/// every other SQLite peer (which advisory flock could not). The staged
+/// temp is `persist`-ed as an atomic rename only after all gates pass, so
+/// either the DB and its WAL/SHM siblings are replaced together or nothing
+/// is touched; the parent dir is fsynced afterward. See the numbered steps
+/// in the body for the per-phase rationale.
///
/// # Lock-release-before-rename trade-off
///
-/// The EXCLUSIVE lock is released BEFORE the atomic rename, on
-/// purpose. SQLite keeps a kernel file handle on the destination's
-/// (old) inode for as long as the lock conn is alive; holding that
-/// handle across the rename would leave it pointing at the unlinked
-/// old inode while peers opening the new path would race the rename
-/// itself (on some filesystems the rename can outright fail).
-/// Releasing the lock first lets SQLite drop its old-inode handle
-/// before the rename swaps it.
-///
-/// The trade-off: a microsecond window opens between lock release and
-/// rename in which a peer can acquire its own SQLite lock on the
-/// destination's old inode. Any writes it makes within that window
-/// land in the old inode, which the rename immediately unlinks — the
-/// peer's writes are effectively dropped on the floor (the peer keeps
-/// a handle on an inode that no longer has any directory entry; once
-/// it closes, the bytes are reclaimed). That is acceptable for the
-/// restore contract: callers serialize their own restore intent at
-/// the application layer; the window is too short for a non-malicious
-/// peer to land more than a transient miss, and a malicious peer
-/// cannot escalate beyond losing its own write. Correct file-handle
-/// semantics across the rename matter more than absolute lock
-/// coverage.
+/// The EXCLUSIVE lock is dropped just BEFORE the rename: SQLite holds a
+/// kernel handle on the old inode while the lock conn is alive, and
+/// holding it across the rename would point it at the unlinked inode and
+/// can make the rename fail on some filesystems. The cost is a microsecond
+/// window where a peer could write into the old inode the rename then
+/// unlinks — its own write is lost, nothing escalates. Correct file-handle
+/// semantics across the rename outweigh absolute lock coverage.
pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), WalletStorageError> {
- // 1. Confirm the source is openable, then run cheap pre-staging
- // integrity + schema-history + max-version sniffs against the
- // source itself so an obviously-incompatible input fails before
- // we stream the whole file into the destination's partition.
- // The authoritative schema-history / version gate still re-runs
- // on the STAGED copy (step 4) — that's the TOCTOU-safe check
- // bound to the exact bytes about to be persisted.
+ // 1. Cheap early-out: sniff integrity + schema-history + version +
+ // wallet-identity against the source so an incompatible input fails
+ // before we stream the whole file. The authoritative, TOCTOU-safe
+ // gate re-runs on the STAGED bytes (step 4).
let src = crate::sqlite::conn::open_conn(src_backup, crate::sqlite::conn::Access::ReadOnly)
.map_err(map_source_open_err)?;
run_integrity_check(&src, |report| WalletStorageError::IntegrityCheckFailed {
@@ -227,30 +161,22 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
return Err(WalletStorageError::SchemaHistoryMissing);
}
crate::sqlite::migrations::assert_schema_version_supported(&src)?;
+ crate::sqlite::conn::assert_wallet_application_id(&src)?;
+ crate::sqlite::migrations::assert_schema_history_well_formed(&src)?;
drop(src);
- // 2. SQLite-native exclusion. `BEGIN EXCLUSIVE` against a short-
- // lived writer connection on the destination blocks every other
- // SQLite peer (rusqlite Connection, sibling `SqlitePersister`)
- // until the tx is committed/rolled-back or the conn drops. The
- // prior flock approach was a false promise: advisory locks
- // don't interlock with SQLite's own locking, so a peer mid-write
- // could race the swap. The lock conn is dropped (`take()` + end
- // of scope) BEFORE `tmp.persist` so SQLite releases its file
- // handle on the old inode before the atomic rename — otherwise
- // we'd leave a dangling handle on the unlinked inode.
+ // 2. SQLite-native exclusion: `BEGIN EXCLUSIVE` on a short-lived
+ // writer conn blocks every other SQLite peer until it drops (which
+ // advisory flock could not — it doesn't interlock with SQLite). The
+ // conn is dropped before `persist` (see lock-release trade-off).
let mut dest_lock_conn: Option = if dest_db_path.exists() {
let conn =
crate::sqlite::conn::open_conn(dest_db_path, crate::sqlite::conn::Access::ReadWrite)?;
- // Reuse a sensible busy_timeout so peers don't immediately
- // surface BUSY without a backoff window. The destination DB
- // may not have a persister attached yet (the persister is the
- // CALLER), so this conn applies its own.
+ // The destination has no persister yet (the persister is the
+ // caller), so apply our own busy_timeout for a backoff window.
conn.busy_timeout(std::time::Duration::from_secs(5))?;
- // Take EXCLUSIVE up-front by promoting an immediate tx. If a
- // peer holds the DB, SQLite waits for busy_timeout then
- // returns BUSY — we surface that as `RestoreDestinationLocked`
- // so callers keep their existing branch.
+ // BUSY after busy_timeout becomes `RestoreDestinationLocked` so
+ // callers keep their existing branch.
match conn.execute_batch("BEGIN EXCLUSIVE") {
Ok(()) => Some(conn),
Err(rusqlite::Error::SqliteFailure(err, _))
@@ -267,19 +193,19 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
None
};
- // 3. Stage the source into a NamedTempFile in the destination's
- // parent dir (unguessable name, no symlink-plant TOCTOU).
+ // 3. Stage the source into a NamedTempFile in the destination's parent
+ // dir (unguessable name, no symlink-plant TOCTOU).
let parent = dest_db_path.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
let mut src_file = std::fs::File::open(src_backup)?;
std::io::copy(&mut src_file, tmp.as_file_mut())?;
tmp.as_file().sync_all()?;
- // 4. Re-run integrity_check on the STAGED file before
- // persisting. A torn `std::io::copy` or transient FS error
- // that escaped `sync_all`'s notice would otherwise persist a
- // corrupted database. If the recheck fails, the temp file
- // drops naturally and the live destination stays untouched.
+ // 4. Re-validate the STAGED bytes before persisting: a torn
+ // `io::copy` that escaped `sync_all` would otherwise persist a
+ // corrupt DB, and the recheck failing just drops the temp. Bound to
+ // the staged bytes (not the source handle) so a swap during the
+ // restore window can't slip a forward-version or foreign DB through.
{
let staged =
crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadOnly)
@@ -287,19 +213,17 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
run_integrity_check(&staged, |report| WalletStorageError::IntegrityCheckFailed {
report,
})?;
- // Schema-history presence + max-version gate, bound to the
- // staged bytes (not the first source handle) so a swap during
- // the restore window can't slip a forward-version DB through.
if !crate::sqlite::migrations::has_schema_history(&staged)? {
return Err(WalletStorageError::SchemaHistoryMissing);
}
crate::sqlite::migrations::assert_schema_version_supported(&staged)?;
+ crate::sqlite::conn::assert_wallet_application_id(&staged)?;
+ crate::sqlite::migrations::assert_schema_history_well_formed(&staged)?;
}
- // 5. chmod 600 on the temp BEFORE persist so the destination
- // inherits owner-only mode via the atomic rename. Chmodding
- // post-persist would leave the new DB live at the destination on
- // a chmod failure, contradicting the rolled-back error.
+ // 5. chmod 0o600 on the temp BEFORE persist so the destination
+ // inherits owner-only mode via the rename (post-persist chmod could
+ // fail with the new DB already live).
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -307,33 +231,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
- // 6. Release the SQLite-native EXCLUSIVE lock BEFORE touching the
- // on-disk WAL/SHM siblings or running the rename. On Windows /
- // some FUSE / AV-scanned mounts, `remove_file` against a file
- // still held open by another handle on the same process returns
- // `PermissionDenied`; on Unix the unlinked inodes remain
- // reachable through the open fd but the rename window still
- // benefits from a clean close.
+ // 6. Release the EXCLUSIVE lock before touching siblings/rename: on
+ // Windows / some FUSE mounts `remove_file` on a still-open file
+ // returns `PermissionDenied`, and the rename window wants a clean
+ // close (see lock-release trade-off above).
if let Some(conn) = dest_lock_conn.take() {
- // Best-effort rollback of the empty EXCLUSIVE tx; an error here
- // means SQLite is already in trouble and `drop(conn)` covers
- // the rest. Silent because the conn is about to drop anyway.
let _ = conn.execute_batch("ROLLBACK");
drop(conn);
}
- // 7. Atomicity gate: every staged-file validation has now passed
- // and our writer handle is closed, so it's safe to clear WAL/SHM
- // siblings the replaced DB might have left behind. Doing this
- // BEFORE persist ensures that either both the main DB and its
- // siblings get replaced/cleared, or — if any earlier check
- // failed — none of them are touched.
- //
- // Build sibling paths via `OsString::push` so non-UTF-8 bytes
- // round-trip intact; `remove_file` runs unconditionally and
- // `ErrorKind::NotFound` is a silent no-op (closes the `exists()`
- // TOCTOU gate). Ordering requires the dest lock conn to be dropped
- // first so cross-platform unlink semantics hold.
+ // 7. Clear any WAL/SHM siblings BEFORE persist so the DB and its
+ // siblings are replaced atomically (all-or-nothing). Sibling paths
+ // use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is
+ // a silent no-op. Requires the lock conn dropped first for
+ // cross-platform unlink semantics.
if let Some(file_name) = dest_db_path.file_name() {
for ext in ["-wal", "-shm"] {
let mut sibling_name = file_name.to_os_string();
@@ -351,28 +262,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
tmp.persist(dest_db_path)
.map_err(|e| WalletStorageError::Io(e.error))?;
- // 9. Fsync the destination's parent directory so the atomic rename's
- // dentry update is durable across power loss (no-op on non-Unix).
+ // 9. Make the rename's dentry update durable.
fsync_parent_dir(dest_db_path)?;
- // 10. Re-tighten siblings (SQLite may materialise -wal/-shm on next
- // open; this is idempotent at restore-completion time).
+ // 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm).
apply_secure_permissions(dest_db_path)?;
Ok(())
}
-/// Run `PRAGMA integrity_check` and return `Ok(())` when SQLite reports
-/// the single row `"ok"`. Any other result becomes a typed
-/// `IntegrityCheckFailed` via the caller-supplied builder; an
-/// underlying rusqlite error surfaces as `IntegrityCheckRunFailed`.
-///
-/// SQLite returns one row per detected problem (capped at
-/// `PRAGMA integrity_check(N)`; default 100). All rows are collected
-/// and joined with `\n` so the typed report carries every diagnostic
-/// instead of just the first line.
-///
-/// `pub(crate)` so the persister's open-time A-8 probe shares the
-/// same helper rather than reimplementing the report-rendering rule.
+/// Run `PRAGMA integrity_check` and return `Ok(())` only on the single
+/// row `"ok"`. Any other result becomes a typed `IntegrityCheckFailed` via
+/// the caller-supplied builder; an underlying rusqlite error surfaces as
+/// `IntegrityCheckRunFailed`. SQLite returns one row per detected problem
+/// (default cap 100); all rows are `\n`-joined so the report carries every
+/// diagnostic, not just the first.
pub(crate) fn run_integrity_check(
conn: &Connection,
on_failure: F,
@@ -392,12 +295,9 @@ where
match item {
Ok(s) => rows.push(s),
Err(e) => {
- // Severe corruption can cause SQLite to surface a
- // `DatabaseCorrupt` SqliteFailure partway through the
- // integrity_check stream. Treat it as end-of-stream
- // when we already have diagnostics (the rows we have
- // are still valid); if we have NOTHING, surface the
- // typed `IntegrityCheckRunFailed`.
+ // SQLite can surface a `DatabaseCorrupt` partway through
+ // the stream; treat it as end-of-stream when we already
+ // have diagnostic rows, else surface it below.
trailing_err = Some(e);
break;
}
@@ -458,6 +358,10 @@ pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result = Vec::new();
let mut kept = 0;
for (idx, (ts, path)) in files.into_iter().enumerate() {
+ // `keep_last_n` is a FLOOR: the N newest are always kept even if
+ // `max_age` would evict them, so an age+count policy can't delete
+ // every backup. `None` gives no floor (age-only may prune all).
+ let within_floor = matches!(policy.keep_last_n, Some(n) if idx < n);
let pass_count = match policy.keep_last_n {
Some(n) => idx < n,
None => true,
@@ -466,16 +370,14 @@ pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result now.duration_since(ts).map(|d| d <= max).unwrap_or(true),
None => true,
};
- if pass_count && pass_age {
+ if within_floor || (pass_count && pass_age) {
kept += 1;
} else {
match std::fs::remove_file(&path) {
Ok(()) => removed.push(path),
Err(e) => {
- // A failed `remove_file` leaves the file on disk, so
- // it MUST be counted in `kept`. The invariant
- // `kept + removed.len() == total` then holds and
- // `failed_removals` is a subset of `kept`.
+ // A failed removal leaves the file on disk, so count it
+ // as kept to preserve `kept + removed == total`.
failed_removals.push((path, e));
kept += 1;
}
@@ -559,6 +461,46 @@ mod tests {
assert_eq!(secs, 1767225600);
}
+ /// `backup_timestamp` must extract the embedded timestamp (not fall
+ /// back to mtime) for every `BackupKind` shape, including ones with
+ /// inner `-`. Guards the `rsplit('-')` coupling against a future label
+ /// that shifts the trailing token.
+ #[test]
+ fn backup_timestamp_extracts_embedded_token_for_all_kinds() {
+ let want = parse_compact_timestamp("20260101T000000Z").unwrap();
+ let real_wallet_id = hex::encode([0xABu8; 32]);
+ let names = [
+ "wallet-20260101T000000Z.db".to_string(),
+ // Multiple `-` from the from/to version segments.
+ "pre-migration-1-to-2-20260101T000000Z.db".to_string(),
+ // 64 lowercase hex chars: hex::encode never emits `-`, so the
+ // timestamp stays the last `-`-delimited token.
+ format!("pre-delete-{real_wallet_id}-20260101T000000Z.db"),
+ "pre-restore-20260101T000000Z.db".to_string(),
+ ];
+ for name in names {
+ let got = backup_timestamp(Path::new(&name));
+ assert_eq!(
+ got,
+ Some(want),
+ "backup_timestamp must parse the embedded token, not fall back to mtime, for {name}"
+ );
+ }
+ }
+
+ /// A label with a trailing non-timestamp segment must return `None`
+ /// (prune falls back to mtime) rather than misread a wrong token as a
+ /// valid time — a detectable regression if a future `BackupKind`
+ /// appends a `-`-bearing suffix after the timestamp.
+ #[test]
+ fn backup_timestamp_rejects_trailing_non_timestamp_segment() {
+ assert_eq!(
+ backup_timestamp(Path::new("pre-delete-20260101T000000Z-label.db")),
+ None,
+ "a trailing non-timestamp segment must not parse as a timestamp"
+ );
+ }
+
#[test]
fn is_backup_file_recognises_prefixes() {
assert!(is_backup_file(Path::new("/tmp/wallet-20260101T000000Z.db")));
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/config.rs b/packages/rs-platform-wallet-storage/src/sqlite/config.rs
index 1beb7c2c021..90832bfdc68 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/config.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/config.rs
@@ -40,11 +40,19 @@ impl JournalMode {
}
/// SQLite synchronous mode.
+///
+/// `Normal` (the default, paired with WAL) is **app-crash durable**: a
+/// committed write survives a process crash but NOT a power loss / OS
+/// crash mid-checkpoint, where the last transactions in the WAL can be
+/// lost. Choose `Full` for power-loss durability at the cost of an fsync
+/// per commit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Synchronous {
Off,
+ /// WAL default: durable across application crash, not power loss.
#[default]
Normal,
+ /// fsync on every commit: durable across power loss / OS crash.
Full,
Extra,
}
@@ -109,10 +117,8 @@ impl SqlitePersisterConfig {
/// `/backups/auto/` (or `./backups/auto/` if the DB path has no parent).
///
-/// Public so the CLI binary (a separate compilation unit) can share the
-/// same resolution as the library's `SqlitePersisterConfig::new`. The
-/// preferred narrower visibility would be `pub(super)`, but `pub use`
-/// re-exports up to the crate root cannot expose a `pub(super)` item.
+/// Public so the CLI binary (a separate compilation unit) shares the same
+/// resolution as `SqlitePersisterConfig::new`.
pub fn default_auto_backup_dir(db_path: &Path) -> PathBuf {
let parent = db_path
.parent()
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs
index de8e182f208..e136e34a6c5 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs
@@ -1,23 +1,40 @@
//! Single connection-open choke-point.
//!
-//! `PRAGMA foreign_keys` is per-connection and resets to OFF on every
-//! open — it is not persisted in the database file, and no compile-time
-//! knob in `libsqlite3-sys`'s bundled build forces it on. Enforcement is
-//! therefore a runtime discipline: every connection that mutates rows
-//! must enable it, and we must *prove* it took, because the pragma
-//! silently no-ops on a SQLite built without FK support.
-//!
-//! Every library connection-open site routes through [`open_conn`] so
-//! there is exactly one place that owns flags + FK enforcement. The CLI
-//! binary's read-only `peek_schema_version` probe opens directly — it
-//! never mutates rows, so FK enforcement is moot, and `open_conn` is
-//! `pub(crate)` (not reachable from the separate bin target).
+//! `PRAGMA foreign_keys` is per-connection, defaults to OFF on every open,
+//! and silently no-ops on a SQLite built without FK support — so every
+//! writer connection must enable it and read it back to prove it took.
+//! All library opens route through [`open_conn`]; the CLI's read-only
+//! `peek_schema_version` probe opens directly (no mutations, and
+//! `open_conn` is `pub(crate)`, unreachable from the bin target).
use rusqlite::{Connection, OpenFlags};
use std::path::Path;
use crate::sqlite::error::WalletStorageError;
+/// Magic stamped into the SQLite header `application_id` (offset 68) by
+/// `V001__initial`. ASCII `"PLWT"` (Platform Wallet) big-endian. A
+/// refinery-versioned DB whose `application_id` does not equal this is a
+/// foreign SQLite database, not a wallet-storage DB.
+pub(crate) const APPLICATION_ID: i32 = 0x504C_5754;
+
+/// Read the header `application_id` and assert it equals
+/// [`APPLICATION_ID`]. Returns [`WalletStorageError::NotAWalletDb`] on
+/// mismatch. The caller decides WHEN to run this — `open()` runs it
+/// pre-migration on a refinery-versioned DB; `restore_from` runs it on
+/// the staged copy. A brand-new (unmigrated) DB reports `0` and is the
+/// caller's responsibility to skip (V001 stamps the real value).
+pub(crate) fn assert_wallet_application_id(conn: &Connection) -> Result<(), WalletStorageError> {
+ let found: i32 = conn.pragma_query_value(None, "application_id", |row| row.get(0))?;
+ if found != APPLICATION_ID {
+ return Err(WalletStorageError::NotAWalletDb {
+ expected: APPLICATION_ID,
+ found,
+ });
+ }
+ Ok(())
+}
+
/// How the opened connection will be used.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Access {
@@ -35,10 +52,9 @@ pub(crate) enum Access {
/// For [`Access::ReadWrite`], enables `PRAGMA foreign_keys = ON` and
/// reads it back, returning [`WalletStorageError::ForeignKeysNotEnforced`]
/// if the result is not `1`. For [`Access::ReadOnly`], opens with
-/// `SQLITE_OPEN_READ_ONLY` and performs no pragma. URI filename parsing
-/// is deliberately not enabled: the crate never constructs `file:` URIs,
-/// and leaving it off keeps a path from ever smuggling query parameters
-/// (e.g. `?mode=rwc`) that could defeat the read-only intent.
+/// `SQLITE_OPEN_READ_ONLY` and performs no pragma. URI filename parsing is
+/// deliberately left off so a path can't smuggle query parameters (e.g.
+/// `?mode=rwc`) that defeat the read-only intent.
pub(crate) fn open_conn(path: &Path, access: Access) -> Result {
let conn = match access {
Access::ReadWrite => Connection::open(path)?,
@@ -77,10 +93,8 @@ mod tests {
assert_eq!(on, 1, "read-back must observe FK enforcement is on");
}
- /// The hard-error variant the read-back returns when the pragma is a
- /// no-op is wired and reachable. We can't build a FK-less SQLite in
- /// the bundled build, so assert the typed error renders the intended
- /// message rather than truncating the contract to "untestable".
+ /// The bundled build can't produce a FK-less SQLite, so assert the
+ /// read-back error variant at least renders its intended message.
#[test]
fn foreign_keys_not_enforced_variant_renders() {
let err = WalletStorageError::ForeignKeysNotEnforced;
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs
index c1767d5b7e3..f78cfd6e731 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs
@@ -1,18 +1,13 @@
//! Typed errors for `platform-wallet-storage`.
//!
-//! Every variant carries the upstream error via `#[source]` (or
-//! `#[from]` where the conversion is the only thing the trait does),
-//! never via a stringified copy. Variants never store user-facing
-//! prose — the `#[error("...")]` attribute provides the renderable
-//! `Display` form; the typed fields carry diagnostics.
+//! Variants carry the upstream error via `#[source]`/`#[from]`, never a
+//! stringified copy; the `#[error("...")]` attribute provides `Display`.
//!
-//! At the `PlatformWalletPersistence` trait boundary, this type
-//! converts into `PersistenceError`: `LockPoisoned` keeps its
-//! dedicated variant; everything else flows through
-//! `PersistenceError::Backend { kind, source }` — `kind` is classified
-//! by [`WalletStorageError::persistence_kind`] (Transient / Constraint /
-//! Fatal) and `source` carries the boxed typed error so consumers can
-//! walk `Error::source()` to the underlying `rusqlite` payload.
+//! At the `PlatformWalletPersistence` boundary this converts into
+//! `PersistenceError`: `LockPoisoned` keeps its dedicated variant, and
+//! everything else flows through `Backend { kind, source }` where `kind`
+//! comes from [`WalletStorageError::persistence_kind`] and `source`
+//! preserves the typed error for `Error::source()` walking.
use std::path::PathBuf;
@@ -99,7 +94,7 @@ pub enum WalletStorageError {
},
/// `delete_wallet` (or another wallet-id-keyed operation) was
- /// called with an id that has no matching `wallet_metadata` row.
+ /// called with an id that has no matching `wallets` row.
#[error("wallet not found: {}", hex::encode(wallet_id))]
WalletNotFound { wallet_id: [u8; 32] },
@@ -199,12 +194,20 @@ pub enum WalletStorageError {
#[error("identity entry id disagrees with its map key")]
IdentityEntryIdMismatch,
+ /// An `account_registrations` row's typed `(account_type, account_index)`
+ /// columns disagreed with the decoded `AccountRegistrationEntry` blob.
+ /// Rejected at decode time so the manifest oracle never hands back an
+ /// entry that names a different account type or index than the indexed
+ /// columns it was selected by.
+ #[error(
+ "account_registrations entry fields disagree with typed columns \
+ (typed columns vs blob account_type or account_index mismatch)"
+ )]
+ AccountRegistrationEntryMismatch,
+
/// An `asset_locks` row's typed-column `(outpoint, account_index)`
- /// disagreed with the lifecycle blob's `(out_point, account_index)`.
- /// Mirrors `IdentityKeyEntryMismatch` — a torn write, partial
- /// migration, or restored corruption that survives the per-row
- /// `integrity_check` is still rejected at decode time rather than
- /// mis-bucketing the lock under the wrong account.
+ /// disagreed with the lifecycle blob's. Rejected at decode time rather
+ /// than mis-bucketing the lock under the wrong account.
#[error(
"asset_lock entry fields disagree with typed columns \
(typed outpoint={typed_outpoint}, blob outpoint={blob_outpoint}, \
@@ -217,26 +220,15 @@ pub enum WalletStorageError {
blob_account_index: u32,
},
- /// A blob payload exceeded the configured allocation cap during
- /// decode. Surfaced separately from generic [`Self::BlobDecode`] so
- /// operators can distinguish a hostile or corrupted oversize blob
- /// from a structural decode failure. Defaults to 16 MiB — well
- /// above any legitimate per-row payload.
+ /// A blob exceeded the decode allocation cap (default 16 MiB).
+ /// Separate from [`Self::BlobDecode`] so operators can distinguish an
+ /// oversize blob from a structural decode failure.
#[error("blob exceeded decode size limit ({len_bytes} bytes > {limit_bytes} byte cap)")]
BlobTooLarge {
len_bytes: usize,
limit_bytes: usize,
},
- /// An unspent UTXO named an address absent from
- /// `core_derived_addresses`, so its owning account index can't be
- /// resolved. Persisting it would mis-file live funds under account
- /// 0 with no path back to the real account, so the write is refused.
- /// Spent-only placeholder rows tolerate a missing mapping (they're
- /// excluded from the unspent set) and do not raise this.
- #[error("unspent utxo address {address} is not in core_derived_addresses")]
- UtxoAddressNotDerived { address: String },
-
/// `PRAGMA foreign_keys = ON` was issued on open but the read-back
/// reported the constraint enforcement is still off — the linked
/// SQLite build silently ignores the pragma (no FK support compiled
@@ -244,6 +236,42 @@ pub enum WalletStorageError {
#[error("SQLite foreign-key enforcement could not be enabled on this connection")]
ForeignKeysNotEnforced,
+ /// The requested `journal_mode` read back as a different mode —
+ /// SQLite silently fell back (e.g. WAL→DELETE on some FUSE mounts).
+ /// With `synchronous=NORMAL` that risks corruption on power loss, so
+ /// open hard-errors instead of running downgraded.
+ #[error("journal_mode {requested} could not be applied (SQLite reports {actual})")]
+ JournalModeNotApplied {
+ requested: &'static str,
+ actual: String,
+ },
+
+ /// A pre-existing / restored DB passed `integrity_check` but its
+ /// `refinery_schema_history` carries a malformed row (non-RFC3339
+ /// `applied_on` or non-numeric `checksum`). Probed BEFORE refinery
+ /// runs so a foreign or corrupted-but-integrity-valid input returns
+ /// a typed error instead of refinery panicking on the parse.
+ #[error("refinery_schema_history is malformed: {reason}")]
+ SchemaHistoryMalformed { reason: &'static str },
+
+ /// A restore source / opened DB carries a `refinery_schema_history`
+ /// (so it is refinery-versioned) but its `application_id` header does
+ /// not match the wallet-storage magic — it is a foreign SQLite DB,
+ /// not a wallet database. Rejected before it can be persisted over
+ /// the live wallet DB or migrated in place.
+ #[error(
+ "not a platform-wallet-storage database: application_id {found:#010x} != expected {expected:#010x}"
+ )]
+ NotAWalletDb { expected: i32, found: i32 },
+
+ /// A second [`SqlitePersister`](crate::SqlitePersister) `open()` on a
+ /// path already open in THIS process. Each handle has its own
+ /// `Mutex` and write buffer, so buffered writes on one are
+ /// invisible to the other — silent state divergence. Refused until the
+ /// first persister drops.
+ #[error("a SqlitePersister is already open on {} in this process", path.display())]
+ AlreadyOpen { path: PathBuf },
+
/// A value couldn't be cast to the database's native i64
/// representation without losing magnitude.
#[error("integer overflow casting `{field}` (value={value}) to {target}")]
@@ -253,20 +281,11 @@ pub enum WalletStorageError {
target: SafeCastTarget,
},
- /// Flush failed transiently (e.g. `SQLITE_BUSY` / `SQLITE_LOCKED`)
- /// for `wallet_id`. The buffered changeset has been restored — the
- /// next `flush(wallet_id)` will retry the same data merged with
- /// anything stored in between. Callers should back off and retry
- /// rather than dropping state.
- ///
- /// **Use exponential backoff; do NOT tight-loop on this error** —
- /// hammering the persister at full speed turns a transient lock
- /// contention into a hot CPU spin and delays whoever holds the
- /// lock from releasing it.
- ///
- /// The variant name `FlushRetryable` is intentionally embedded in
- /// the `Display` output so operators grepping production logs can
- /// match on the variant directly.
+ /// Flush failed transiently (e.g. `SQLITE_BUSY` / `SQLITE_LOCKED`) for
+ /// `wallet_id`. The buffered changeset is restored, so the next
+ /// `flush(wallet_id)` retries it merged with anything stored in
+ /// between. Use **exponential backoff** — tight-looping turns lock
+ /// contention into a CPU spin that starves the lock holder.
#[error(
"FlushRetryable: flush failed transiently for wallet {}; buffer preserved for retry",
hex::encode(wallet_id)
@@ -291,31 +310,21 @@ impl From for PersistenceError {
}
impl WalletStorageError {
- /// Construct a typed `BlobDecode` error from a static reason.
- /// Used by schema modules that hit a structural decode error
- /// (e.g. a 32-byte id column with the wrong length, or trailing
- /// bytes after a payload).
+ /// Construct a `BlobDecode` error from a static reason. Used by schema
+ /// modules on a structural decode error (wrong-length id, trailing
+ /// bytes).
pub(crate) fn blob_decode(reason: &'static str) -> Self {
Self::BlobDecode { reason }
}
- /// `true` when the underlying failure is safe to retry — the
- /// caller should preserve in-flight state and call again.
- /// Transient codes:
- /// - `DatabaseBusy` / `DatabaseLocked`: contention.
- /// - `DiskFull`: operator clears disk space.
- /// - `SystemIoFailure`: kernel-level I/O blip (NFS, raid rebuild).
- /// - `OutOfMemory`: transient memory pressure.
- ///
- /// All four classes are recoverable environmental conditions —
- /// dropping buffered state on them would be data loss for a
- /// problem the operator (or kernel) clears on its own.
+ /// `true` when the failure is safe to retry — the caller should
+ /// preserve in-flight state and call again. Transient codes are the
+ /// recoverable environmental ones: `DatabaseBusy`/`DatabaseLocked`
+ /// (contention), `DiskFull`, `SystemIoFailure`, `OutOfMemory`.
///
- /// The OUTER match on `WalletStorageError` is intentionally
- /// wildcard-free: the enum MUST NOT gain `#[non_exhaustive]` so a
- /// future variant forces the author to classify it here. The
- /// INNER match on `rusqlite::ErrorCode` uses a wildcard because
- /// `ErrorCode` is `#[non_exhaustive]` upstream.
+ /// The OUTER match is intentionally wildcard-free so a future variant
+ /// forces explicit classification here; the INNER `ErrorCode` match
+ /// needs a wildcard because that enum is upstream `#[non_exhaustive]`.
pub fn is_transient(&self) -> bool {
use rusqlite::ErrorCode;
match self {
@@ -343,13 +352,10 @@ impl WalletStorageError {
| Self::AutoBackupDirUnwritable { .. }
| Self::WalletNotFound { .. }
| Self::WalletIdMismatch { .. }
- // TODO(qa): `LockPoisoned` is classified as fatal here, but
- // the end-to-end mutex-poison flow has no automated test (a
- // panicking thread + join is hard to reproduce
- // deterministically). Manual verification only via the
- // table-driven test in `tests/sqlite_error_classification`.
- // If you change this classification, re-derive
- // `handle_flush_error`'s fatal-branch behavior to match.
+ // TODO(qa): `LockPoisoned` fatal classification has no e2e
+ // mutex-poison test; verified manually via
+ // `tests/sqlite_error_classification`. Re-check
+ // `handle_flush_error`'s fatal branch if you change it.
| Self::LockPoisoned
| Self::RestoreDestinationLocked
| Self::InvalidWalletIdHex { .. }
@@ -362,28 +368,27 @@ impl WalletStorageError {
| Self::ConsensusCodec { .. }
| Self::BackupDestinationExists { .. }
| Self::ForeignKeysNotEnforced
+ | Self::JournalModeNotApplied { .. }
+ | Self::SchemaHistoryMalformed { .. }
+ | Self::NotAWalletDb { .. }
+ | Self::AlreadyOpen { .. }
| Self::IdentityKeyEntryMismatch
| Self::IdentityEntryIdMismatch
+ | Self::AccountRegistrationEntryMismatch
| Self::AssetLockEntryMismatch { .. }
| Self::BlobTooLarge { .. }
- | Self::UtxoAddressNotDerived { .. }
| Self::IntegerOverflow { .. } => false,
}
}
- /// Trait-boundary classification for the
- /// [`PersistenceError::Backend`] kind field. Three classes:
+ /// Trait-boundary classification for [`PersistenceError::Backend`]:
///
- /// - [`PersistenceErrorKind::Transient`] — every variant where
- /// [`Self::is_transient`] is `true`. Caller MAY retry.
- /// - [`PersistenceErrorKind::Constraint`] — SQL constraint /
- /// FK / NOT NULL / UNIQUE / PK / CHECK violations. Schema /
- /// integrity failure; caller bug, not infra.
+ /// - [`PersistenceErrorKind::Transient`] — [`Self::is_transient`] true; caller MAY retry.
+ /// - [`PersistenceErrorKind::Constraint`] — SQL constraint/FK/CHECK violation; caller bug.
/// - [`PersistenceErrorKind::Fatal`] — everything else.
///
- /// [`Self::LockPoisoned`] is handled by the `From` impl directly
- /// (it maps to [`PersistenceError::LockPoisoned`] rather than
- /// flowing through `Backend`).
+ /// [`Self::LockPoisoned`] never reaches here; the `From` impl maps it
+ /// straight to [`PersistenceError::LockPoisoned`].
pub fn persistence_kind(&self) -> PersistenceErrorKind {
use rusqlite::ErrorCode;
if self.is_transient() {
@@ -395,10 +400,8 @@ impl WalletStorageError {
{
PersistenceErrorKind::Constraint
}
- // Refinery surfaces FK / constraint problems through
- // rusqlite; if that path leaks through here the typed
- // variant lives in `Self::Migration`, which we leave as
- // `Fatal` since a migration failure isn't a caller bug.
+ // A migration failure (`Self::Migration`) isn't a caller bug,
+ // so it stays `Fatal` rather than `Constraint`.
_ => PersistenceErrorKind::Fatal,
}
}
@@ -442,11 +445,15 @@ impl WalletStorageError {
Self::ConsensusCodec { .. } => "consensus_codec",
Self::BackupDestinationExists { .. } => "backup_destination_exists",
Self::ForeignKeysNotEnforced => "foreign_keys_not_enforced",
+ Self::JournalModeNotApplied { .. } => "journal_mode_not_applied",
+ Self::SchemaHistoryMalformed { .. } => "schema_history_malformed",
+ Self::NotAWalletDb { .. } => "not_a_wallet_db",
+ Self::AlreadyOpen { .. } => "already_open",
Self::IdentityKeyEntryMismatch => "identity_key_entry_mismatch",
Self::IdentityEntryIdMismatch => "identity_entry_id_mismatch",
+ Self::AccountRegistrationEntryMismatch => "account_registration_entry_mismatch",
Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch",
Self::BlobTooLarge { .. } => "blob_too_large",
- Self::UtxoAddressNotDerived { .. } => "utxo_address_not_derived",
Self::IntegerOverflow { .. } => "integer_overflow",
}
}
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/kv.rs b/packages/rs-platform-wallet-storage/src/sqlite/kv.rs
index bdf502e5b1a..865b351f269 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/kv.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/kv.rs
@@ -1,24 +1,14 @@
//! SQLite-backed [`KvStore`] implementation for [`SqlitePersister`].
//!
-//! One dedicated table per [`ObjectId`] variant (`meta_global`,
-//! `meta_wallet`, `meta_identity`, `meta_token`, `meta_contact`,
-//! `meta_platform_address`). Each table has a composite PRIMARY KEY of
-//! its id column(s) plus `key`, so uniqueness comes straight from the PK
-//! — no partial indexes, no nullable scope column. None of the tables
-//! carry an FK: a `put` succeeds before its parent object exists. The
-//! `AFTER DELETE` triggers in `V001__initial.rs` clean a scope's
-//! metadata up when its parent is deleted.
+//! One dedicated `meta_*` table per [`ObjectId`] variant, each with a
+//! composite PRIMARY KEY (id columns + `key`) for uniqueness and no FK
+//! (a `put` may precede its parent; `AFTER DELETE` triggers in
+//! `V001__initial.rs` reap metadata when the parent is deleted).
//!
-//! `match scope` resolves each operation to its table and id-column
-//! bindings; the SQL body (length-precheck read, upsert, delete,
-//! prefix-list) is factored once per op and parameterised by table name
-//! and id predicate. Table and column names come from the matched
-//! variant — compile-time constants, never caller input — so they are
-//! `format!`-spliced; the id *values* are bound parameters.
-//!
-//! All operations reuse `SqlitePersister`'s single `Mutex`
-//! via the crate-private `conn()` accessor; no separate connection is
-//! opened.
+//! `format!`-spliced table/column names are always compile-time constants
+//! from the matched variant, never caller input; id *values* and keys are
+//! bound parameters. Operations reuse the persister's single
+//! `Mutex` via `conn()`.
use rusqlite::{OptionalExtension, ToSql};
@@ -133,10 +123,9 @@ impl From for KvError {
WalletStorageError::LockPoisoned => KvError::LockPoisoned,
WalletStorageError::Sqlite(e) => KvError::Sqlite(e),
other => {
- // Other variants don't arise from the `conn()` accessor
- // — the accessor either yields `LockPoisoned` or hands
- // back the guard. Stuff anything else into `Sqlite`
- // via its Display, preserving the source chain.
+ // `conn()` only ever yields `LockPoisoned` or the guard,
+ // so other variants are unreachable here; preserve the
+ // source chain anyway by wrapping into `Sqlite`.
KvError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(other)))
}
}
@@ -152,13 +141,10 @@ impl KvStore for SqlitePersister {
// Bind the id values then the key in placeholder order.
let mut params: Vec<&dyn ToSql> = sql.id_vals.iter().map(|v| v as &dyn ToSql).collect();
params.push(&key);
- // Single-snapshot read: select `length(value)` and `value` in one
- // row. The length (column 0) is checked against `MAX_VALUE_LEN`
- // before `row.get(1)` materialises the BLOB — rusqlite reads the
- // BLOB lazily on that call, so the cap gates the allocation with
- // no cross-snapshot TOCTOU window. The inner `Result` carries the
- // over-cap length out of the closure without ever touching
- // column 1.
+ // Select `length(value)` and `value` in one row: rusqlite reads
+ // the BLOB lazily on `row.get(1)`, so checking the length first
+ // gates the allocation with no TOCTOU window. The inner `Result`
+ // carries an over-cap length out without touching column 1.
let row: Option, usize>> = conn
.query_row(
&format!("SELECT length(value), value FROM {} {where_key}", sql.table),
@@ -185,9 +171,8 @@ impl KvStore for SqlitePersister {
fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> {
validate_key(key)?;
- // Cap the value before it reaches SQL so a `put` can never plant
- // a row that a later `get` would refuse to materialise. The read
- // path gates on the same `MAX_VALUE_LEN`.
+ // Cap before SQL so a `put` can't plant a row a later `get` would
+ // refuse to materialise (same `MAX_VALUE_LEN` on both paths).
if value.len() > MAX_VALUE_LEN {
return Err(KvError::ValueTooLarge {
found: value.len(),
@@ -196,9 +181,8 @@ impl KvStore for SqlitePersister {
}
let sql = ScopeSql::resolve(scope);
let conn = self.conn().map_err(KvError::from)?;
- // Column list / placeholders / conflict target all include the
- // id columns ahead of `key`; the plain composite PK is the
- // conflict target. Upsert refreshes `updated_at` on overwrite.
+ // Columns/placeholders/conflict-target put id columns ahead of
+ // `key` (the composite PK); upsert refreshes `updated_at`.
let mut cols: Vec<&str> = sql.id_cols.to_vec();
cols.push("key");
let col_list = cols.join(", ");
@@ -289,11 +273,11 @@ mod tests {
let wid: WalletId = [id; 32];
let conn = p.lock_conn_for_test();
conn.execute(
- "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \
+ "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \
VALUES (?1, 'testnet', 0)",
params![wid.as_slice()],
)
- .expect("seed wallet_metadata");
+ .expect("seed wallets");
wid
}
@@ -333,8 +317,7 @@ mod tests {
#[test]
fn global_composite_pk_rejects_duplicate() {
- // Direct INSERTs (no ON CONFLICT) must be rejected because the
- // composite PRIMARY KEY enforces per-key uniqueness.
+ // A direct INSERT (no ON CONFLICT) must hit the composite PK.
let (p, _tmp) = open_persister();
let conn = p.lock_conn_for_test();
conn.execute(
@@ -388,9 +371,8 @@ mod tests {
#[test]
fn get_rejects_oversized_value_before_materialising() {
- // A row larger than MAX_VALUE_LEN (planted via direct SQL —
- // bypassing `put`'s cap) must surface as ValueTooLarge instead
- // of OOMing the process.
+ // A row over MAX_VALUE_LEN (planted directly, bypassing `put`)
+ // must surface ValueTooLarge instead of OOMing.
let (p, _tmp) = open_persister();
let oversize = vec![0u8; MAX_VALUE_LEN + 1];
{
@@ -442,7 +424,7 @@ mod tests {
unreachable!()
};
conn.execute(
- "DELETE FROM wallet_metadata WHERE wallet_id = ?1",
+ "DELETE FROM wallets WHERE wallet_id = ?1",
params![wid.as_slice()],
)
.expect("delete wallet");
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
index 1163fe62044..d8600c55c6d 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
@@ -8,9 +8,8 @@ use rusqlite::OptionalExtension;
use crate::sqlite::error::WalletStorageError;
-// `embed_migrations!` generates a `migrations` module with a `runner()`
-// function. The path is relative to the crate root (where `Cargo.toml`
-// lives).
+// Generates a `migrations` module with `runner()`; path is relative to
+// the crate root.
refinery::embed_migrations!("./migrations");
/// Apply every pending migration to `conn`.
@@ -20,10 +19,8 @@ pub fn run(conn: &mut rusqlite::Connection) -> Result Result {
@@ -65,13 +62,15 @@ pub(crate) fn has_schema_history(conn: &rusqlite::Connection) -> Result Result<(), WalletStorageError> {
@@ -98,6 +97,41 @@ pub fn assert_schema_version_supported(
Ok(())
}
+/// Probe `refinery_schema_history` rows BEFORE handing the connection to
+/// refinery, which parses `applied_on` (RFC3339) and `checksum` (`u64`)
+/// with `unwrap()` — a malformed value would abort the process. Surfaces
+/// a typed [`WalletStorageError::SchemaHistoryMalformed`] instead.
+/// Quietly succeeds when the table is absent.
+pub(crate) fn assert_schema_history_well_formed(
+ conn: &rusqlite::Connection,
+) -> Result<(), WalletStorageError> {
+ if !has_schema_history(conn)? {
+ return Ok(());
+ }
+ let mut stmt = conn.prepare("SELECT applied_on, checksum FROM refinery_schema_history")?;
+ let rows = stmt.query_map([], |row| {
+ let applied_on: String = row.get(0)?;
+ let checksum: String = row.get(1)?;
+ Ok((applied_on, checksum))
+ })?;
+ for row in rows {
+ let (applied_on, checksum) = row?;
+ // Validate the way refinery will parse, so a malformed value fails
+ // typed here rather than panicking inside the runner.
+ if chrono::DateTime::parse_from_rfc3339(&applied_on).is_err() {
+ return Err(WalletStorageError::SchemaHistoryMalformed {
+ reason: "applied_on is not a valid RFC3339 timestamp",
+ });
+ }
+ if checksum.parse::().is_err() {
+ return Err(WalletStorageError::SchemaHistoryMalformed {
+ reason: "checksum is not a valid u64",
+ });
+ }
+ }
+ Ok(())
+}
+
/// List `(version, name)` of every embedded migration. Used by tests and
/// the migration-drift hash check.
pub fn embedded_migrations() -> Vec<(i32, String)> {
@@ -109,8 +143,10 @@ pub fn embedded_migrations() -> Vec<(i32, String)> {
}
/// SHA-256 over `(version, name)` of every embedded migration in version
-/// order. Pinning this in tests catches edits to committed migrations
-/// (forbidden by the append-only migration policy).
+/// order. Deliberately content-blind: it hashes the migration set's
+/// identity, not the SQL bodies, so it catches an added/removed/renamed
+/// migration but ignores in-place DDL edits (a content-pinning guard
+/// belongs with the schema freeze at release).
#[cfg(any(test, feature = "__test-helpers"))]
pub fn embedded_migrations_fingerprint() -> [u8; 32] {
use sha2::{Digest, Sha256};
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
index 1cecf885308..cbdc4a3796e 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
@@ -1,7 +1,8 @@
//! [`SqlitePersister`] — the canonical `PlatformWalletPersistence` impl.
+use std::collections::HashSet;
use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex, MutexGuard};
+use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use rusqlite::{Connection, OptionalExtension};
@@ -19,44 +20,39 @@ use crate::sqlite::schema;
use crate::sqlite::util::permissions::{apply_secure_permissions, precreate_secure};
use crate::sqlite::util::safe_cast;
-/// Sub-areas of `ClientStartState` that `load()` does not yet
-/// reconstruct (blocked on upstream `Wallet::from_persisted`).
+/// Persisted-but-not-rehydrated areas, surfaced in the structured
+/// `tracing::info!` summary on every `load()`.
///
-/// Surfaced via the structured `tracing::info!` summary on every
-/// `load()` (`unimplemented` + `wallets_pending_rehydration` fields).
-pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["ClientStartState::wallets"];
+/// - `token_balances`: written by the `token_balances` slot but not read
+/// back by `load()` (no reader wired in yet).
+/// - `dashpay::overlay`: the `dashpay_profiles` /
+/// `dashpay_payments_overlay` tables are a write-only indexed overlay;
+/// DashPay state rehydrates from the identities blob, not these tables.
+pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["token_balances", "dashpay::overlay"];
/// Outcome of a `prune_backups` call.
///
-/// Invariant: `kept == total_eligible - removed.len()`. A file is
-/// counted as `kept` if it survived the policy (retained-by-rule) OR
-/// if `remove_file` failed (`failed_removals` is a subset of `kept`).
-/// Either way, the file is still on disk after this call.
+/// Invariant: `kept == total_eligible - removed.len()`; a file is `kept`
+/// if the policy retained it OR `remove_file` failed (so `failed_removals`
+/// is a subset of `kept`). Either way it's still on disk.
#[derive(Debug)]
pub struct PruneReport {
- /// Paths that were unlinked, sorted oldest-first by filename
- /// timestamp.
+ /// Unlinked paths, oldest-first by filename timestamp.
pub removed: Vec,
- /// Files still on disk after this call. Equals
- /// `total_eligible - removed.len()` and includes every
- /// `failed_removals` entry — a file that couldn't be unlinked is
- /// still on disk and therefore "kept".
+ /// Count still on disk (`total_eligible - removed.len()`), including
+ /// every `failed_removals` entry.
pub kept: usize,
- /// Files we tried to remove but couldn't, paired with the
- /// underlying `io::Error`. Returned as part of `Ok(report)` so a
- /// partial failure surfaces every removed AND every failed entry
- /// — the caller can re-invoke `prune_backups` to retry just the
- /// stragglers.
+ /// Files we couldn't remove, paired with the `io::Error`. Returned in
+ /// `Ok(report)` so the caller can re-invoke to retry the stragglers.
pub failed_removals: Vec<(PathBuf, std::io::Error)>,
}
/// Retention policy for `prune_backups`.
///
-/// **AND-semantics**: a file is kept iff it satisfies BOTH rules. A
-/// policy with `keep_last_n = Some(3)` and `max_age = Some(30d)` keeps
-/// at most the three newest backups AND only those younger than 30
-/// days — a four-day-old backup that's the fifth-newest is removed.
-/// `RetentionPolicy::default()` (both `None`) keeps every file.
+/// `keep_last_n` is a **floor**: the N newest backups are always kept even
+/// if `max_age` would evict them, so a policy setting both can never delete
+/// everything. `keep_last_n = None` gives no floor (age-only may prune
+/// all); `default()` (both `None`) keeps every file.
#[derive(Debug, Clone, Copy, Default)]
pub struct RetentionPolicy {
pub keep_last_n: Option,
@@ -78,24 +74,51 @@ impl RetentionPolicy {
}
}
+/// Canonicalized paths held by a live [`SqlitePersister`] in this process.
+/// Refusing a second in-process open ([`WalletStorageError::AlreadyOpen`])
+/// prevents two handles with independent buffers diverging; cross-process
+/// peers are handled by SQLite's own EXCLUSIVE locking.
+fn open_path_registry() -> &'static Mutex> {
+ static REGISTRY: OnceLock>> = OnceLock::new();
+ REGISTRY.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+/// Insert `path`, returning [`WalletStorageError::AlreadyOpen`] if held.
+/// Recover from a poisoned registry mutex rather than wedging every open.
+fn register_open_path(path: PathBuf) -> Result<(), WalletStorageError> {
+ let mut set = open_path_registry()
+ .lock()
+ .unwrap_or_else(|p| p.into_inner());
+ if set.contains(&path) {
+ return Err(WalletStorageError::AlreadyOpen { path });
+ }
+ set.insert(path);
+ Ok(())
+}
+
+/// Remove `path` from the open-path registry on persister drop.
+fn release_open_path(path: &Path) {
+ let mut set = open_path_registry()
+ .lock()
+ .unwrap_or_else(|p| p.into_inner());
+ set.remove(path);
+}
+
/// SQLite-backed `PlatformWalletPersistence`.
pub struct SqlitePersister {
config: SqlitePersisterConfig,
- // Single connection serializes reads through the write lock.
- // Acceptable for the current workload (per-wallet operations, small
- // read footprint); a read-only pool over the same WAL-mode file is
+ /// Canonicalized DB path held in the process-wide open-path registry.
+ /// Removed from the registry when this persister drops.
+ registered_path: PathBuf,
+ // Single connection serializes reads through the write lock —
+ // acceptable for the current per-wallet workload; a read-only pool is
// the planned follow-up if read contention becomes measurable.
conn: Arc>,
buffer: Buffer,
- /// Test-only one-shot injector for `flush_inner`. Lives on the
- /// struct so `force_next_flush_to_fail` can survive across `&self`
- /// calls. Production builds keep the slot but never write to it
- /// (no public setter outside `#[cfg(any(test, feature = "__test-helpers"))]`).
+ /// Test-only one-shot injector for `flush_inner`.
#[cfg(any(test, feature = "__test-helpers"))]
primed_flush_error: Mutex>,
- /// Test-only one-shot injection consumed by `delete_wallet`'s
- /// pre-flush phase. Lets a test assert the buffer-restore and
- /// skip-backup semantics without provoking a real SQL error.
+ /// Test-only one-shot injector for `delete_wallet`'s pre-flush phase.
#[cfg(any(test, feature = "__test-helpers"))]
primed_pre_flush_error: Mutex >,
}
@@ -139,47 +162,41 @@ impl SqlitePersister {
}
}
- // Pre-create the DB file owner-only (0600) with O_EXCL BEFORE
- // rusqlite opens it: the file is born at 0600 (no umask window)
- // and an attacker-planted symlink at the path makes the create
- // fail rather than redirect (no chmod-by-path TOCTOU). A no-op
- // when the DB already exists. Brings the SQLite path to parity
- // with the secrets-vault file path.
+ // Pre-create owner-only (0600) with O_EXCL before rusqlite opens:
+ // no umask window, and a planted symlink makes the create fail
+ // rather than redirect (no chmod-by-path TOCTOU). No-op if it
+ // already exists.
precreate_secure(&config.path)?;
- // Open the connection AND apply pragmas before checking for
- // pending migrations so the integrity probe sees the configured
- // journal mode and busy timeout. `open_conn` enables foreign-key
- // enforcement and asserts the read-back before any write lands.
+ // Open + apply pragmas before checking pending migrations so the
+ // integrity probe sees the configured journal mode / busy timeout.
let mut conn =
crate::sqlite::conn::open_conn(&config.path, crate::sqlite::conn::Access::ReadWrite)?;
- // Re-tighten to 0600 on Unix (idempotent on re-open) and sweep
- // the WAL/SHM sidecars that SQLite creates after open.
+ // Re-tighten to 0600 and sweep the WAL/SHM sidecars SQLite created.
apply_secure_permissions(&config.path)?;
apply_pragmas(&mut conn, &config)?;
- // Determine whether `schema_history` exists *before* we run
- // migrations — that's the signal for "is this DB pre-existing or
- // brand-new?". Errors from the underlying query are propagated,
- // not silently treated as "no history".
+ // `schema_history` presence is the pre-existing-vs-brand-new
+ // signal; query errors propagate rather than masking as "none".
let had_schema_history = crate::sqlite::migrations::has_schema_history(&conn)?;
- // Run integrity_check on a pre-existing DB BEFORE migrations alter
- // it. Bit-rot or escaped-WAL corruption detected here surfaces as
- // the typed `IntegrityCheckFailed` before any schema mutation
- // lands. The pre-migration auto-backup snapshots the live state,
- // so without this gate a corrupt DB gets backed up and migrated in
- // the same pass — making the auto-backup useless for rollback.
+ // Integrity-check a pre-existing DB BEFORE migrations alter it,
+ // else a corrupt DB gets backed up and migrated in one pass,
+ // making the pre-migration auto-backup useless for rollback.
if had_schema_history {
crate::sqlite::backup::run_integrity_check(&conn, |report| {
WalletStorageError::IntegrityCheckFailed { report }
})?;
}
- // Refuse to open a DB produced by a newer binary — refinery's
- // run() would no-op on pending_count==0, after which blob decoders
- // would see forward-schema bytes. Symmetric with restore_from's
- // max-version gate (both call the same helper).
+ // Refuse a newer-binary DB: refinery's run() no-ops at
+ // pending==0, after which blob decoders would read forward-schema
+ // bytes. Then assert the wallet application_id and a well-formed
+ // schema_history BEFORE refinery, so a foreign or
+ // corrupted-but-integrity-valid DB fails typed instead of being
+ // migrated in place or panicking the runner.
if had_schema_history {
crate::sqlite::migrations::assert_schema_version_supported(&conn)?;
+ crate::sqlite::conn::assert_wallet_application_id(&conn)?;
+ crate::sqlite::migrations::assert_schema_history_well_formed(&conn)?;
}
let pending = crate::sqlite::migrations::embedded_migrations();
let pending_count = if had_schema_history {
@@ -199,11 +216,20 @@ impl SqlitePersister {
)?;
}
- // Apply migrations through the typed-error chokepoint.
let _report = crate::sqlite::migrations::run_for_open(&mut conn)?;
+ // Claim the path LAST so a failed open leaves no stale claim;
+ // canonicalize so symlinks / `.`-segments key the same as a
+ // sibling open would.
+ let registered_path = config
+ .path
+ .canonicalize()
+ .unwrap_or_else(|_| config.path.clone());
+ register_open_path(registered_path.clone())?;
+
Ok(Self {
config,
+ registered_path,
conn: Arc::new(Mutex::new(conn)),
buffer: Buffer::new(),
#[cfg(any(test, feature = "__test-helpers"))]
@@ -244,11 +270,10 @@ impl SqlitePersister {
///
/// # Cross-process rollback caveat
///
- /// The pre-restore auto-backup is taken BEFORE the SQLite-native
- /// `BEGIN EXCLUSIVE` that guards the restore body. Under concurrent
- /// cross-process access the rollback point may therefore miss writes
- /// a peer committed between the snapshot and the lock. Serializing
- /// restore intent across processes is the caller's responsibility.
+ /// The pre-restore auto-backup is taken BEFORE the restore body's
+ /// `BEGIN EXCLUSIVE`, so under concurrent cross-process access the
+ /// rollback point may miss writes a peer committed in between. Callers
+ /// must serialize restore intent across processes.
pub fn restore_from(
dest_db_path: &Path,
src_backup: &Path,
@@ -281,8 +306,7 @@ impl SqlitePersister {
let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled {
operation: AutoBackupOperation::Restore,
})?;
- // Open the destination read-only just long enough to
- // page-stream a snapshot to disk under auto_backup_dir.
+ // Open read-only just long enough to snapshot under auto_backup_dir.
let dest_conn = crate::sqlite::conn::open_conn(
dest_db_path,
crate::sqlite::conn::Access::ReadOnly,
@@ -295,13 +319,10 @@ impl SqlitePersister {
)?;
drop(dest_conn);
}
- // No row-count fingerprint guards the snapshot → EXCLUSIVE
- // window: `backup::restore_from` holds a SQLite-native `BEGIN
- // EXCLUSIVE` over the whole restore body, so peers that race the
- // snapshot are excluded from there on. A count fingerprint would
- // miss in-place UPDATEs on single-row tables and give operators
- // false confidence; callers needing a quiesced rollback point
- // must serialize restore intent at the application layer.
+ // No row-count fingerprint guards the snapshot→EXCLUSIVE window:
+ // `backup::restore_from`'s `BEGIN EXCLUSIVE` covers the body, and a
+ // count would miss in-place UPDATEs and give false confidence.
+ // Callers needing a quiesced point serialize restore intent.
backup::restore_from(dest_db_path, src_backup)
}
@@ -326,22 +347,17 @@ impl SqlitePersister {
///
/// # Cross-process rollback caveat
///
- /// The pre-delete auto-backup is taken BEFORE the SQLite-native
- /// `BEGIN EXCLUSIVE` that guards the cascade. Under concurrent
- /// cross-process access the rollback point may therefore miss writes
- /// a peer committed between the snapshot and the lock. Serializing
- /// delete intent across processes is the caller's responsibility.
+ /// The pre-delete auto-backup is taken BEFORE the cascade's
+ /// `BEGIN EXCLUSIVE`, so under concurrent cross-process access the
+ /// rollback point may miss writes a peer committed in between. Callers
+ /// must serialize delete intent across processes.
///
/// # Racing stores
///
- /// Calls to `store(wallet_id, ...)` for the same wallet while
- /// `delete_wallet` is in progress will be **discarded** after the
- /// delete commits. The store call may return `Ok(())` (in
- /// `FlushMode::Manual` it lands in the buffer), but its data does
- /// not survive the delete — the post-commit re-drain inside
- /// `delete_wallet` removes any buffered changeset that arrived
- /// during the delete window. Synchronize at the caller layer if
- /// you need different semantics.
+ /// A `store(wallet_id, ...)` racing this call is **discarded** after
+ /// the delete commits — it may return `Ok(())` (Manual mode buffers
+ /// it) but a post-commit re-drain removes it. Synchronize at the
+ /// caller layer if you need other semantics.
pub fn delete_wallet(
&self,
wallet_id: WalletId,
@@ -369,24 +385,20 @@ impl SqlitePersister {
wallet_id: WalletId,
skip_backup: bool,
) -> Result {
- // Acquire the connection mutex FIRST so concurrent in-process
- // `store()` calls block on it. Cross-process peers (other
- // rusqlite Connections / sibling `SqlitePersister`s) are excluded
- // by `BEGIN EXCLUSIVE` below — the in-process mutex alone never
- // gave that guarantee.
+ // Take the conn mutex first so in-process `store()` blocks;
+ // cross-process peers are excluded by `BEGIN EXCLUSIVE` below.
let mut conn = self.conn()?;
- // Drain the buffered changeset so a later flush can't
- // resurrect the wallet, and so the wallet counts as existing
- // even when its only state is buffered. Hold the drained value
- // in `drained_slot` and only consume it AFTER tx.commit().
+ // Drain the buffer so a later flush can't resurrect the wallet and
+ // so a buffer-only wallet still counts as existing. Held in
+ // `drained_slot` and consumed only after commit.
let drained = self.buffer.take_for_flush(&wallet_id)?;
let had_buffered = drained.is_some();
let drained_slot: std::cell::Cell> =
std::cell::Cell::new(drained);
- // Helper: any pre-commit failure must restore the changeset so
- // we don't lose pending writes on a delete that didn't happen.
+ // Any pre-commit failure must restore the changeset so a delete
+ // that didn't happen doesn't lose pending writes.
let restore_buffer = |slot: &std::cell::Cell >| {
if let Some(cs) = slot.take() {
if let Err(e) = self.buffer.restore(wallet_id, cs) {
@@ -400,11 +412,11 @@ impl SqlitePersister {
};
let result: Result = (|| {
- // Pre-flight existence check on the bare conn (no tx) so
- // we don't waste a backup file on an unknown wallet.
+ // Existence check before backup so we don't snapshot for an
+ // unknown wallet.
let exists_pre_flush = conn
.query_row(
- "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1",
+ "SELECT 1 FROM wallets WHERE wallet_id = ?1",
rusqlite::params![wallet_id.as_slice()],
|_| Ok(()),
)
@@ -414,36 +426,30 @@ impl SqlitePersister {
return Err(WalletStorageError::WalletNotFound { wallet_id });
}
- // Test-only injector — force the pre-flush below to fail with
- // the primed error without depending on a real SQL failure.
- // Keeps the test free of FK-poisoning scaffolding.
+ // Test-only injector to fail the pre-flush below.
#[cfg(any(test, feature = "__test-helpers"))]
let primed_pre_flush_error = self.consume_primed_pre_flush_error();
- // Flush the drained buffer to disk BEFORE `run_auto_backup`
- // so the pre-delete snapshot includes every pending write.
- // Without this the backup captures only already-persisted
- // state and rollback-from-backup cannot recover the buffered
- // (lost) data.
- //
- // The flush opens its own EXCLUSIVE tx and commits;
- // `run_auto_backup` then runs against the freshly-flushed
- // DB. On flush failure we restore the buffer via the outer
- // `restore_buffer` helper and abort the delete.
- //
- // The cascade-side backup runs BEFORE the cascade's
- // `BEGIN EXCLUSIVE` because rusqlite's `Backup::new` can't
- // establish a backup whose source connection holds an
- // active write tx on its own DB — `sqlite3_backup_step`
- // would deadlock against the in-flight EXCLUSIVE.
+ // Flush the drained buffer (its own EXCLUSIVE tx) BEFORE
+ // `run_auto_backup` so the snapshot includes pending writes;
+ // otherwise rollback-from-backup can't recover them. The backup
+ // must precede the cascade's `BEGIN EXCLUSIVE` because
+ // `Backup::new` deadlocks if the source holds an active write tx.
if let Some(cs) = drained_slot.take() {
#[cfg(any(test, feature = "__test-helpers"))]
if let Some(primed) = primed_pre_flush_error {
drained_slot.set(Some(cs));
return Err(primed);
}
- let pre_flush_tx =
- conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?;
+ let pre_flush_tx = match conn
+ .transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)
+ {
+ Ok(tx) => tx,
+ Err(e) => {
+ drained_slot.set(Some(cs));
+ return Err(WalletStorageError::Sqlite(e));
+ }
+ };
if let Err(e) = apply_changeset_to_tx(&pre_flush_tx, &wallet_id, &cs) {
let _ = pre_flush_tx.rollback();
drained_slot.set(Some(cs));
@@ -455,12 +461,6 @@ impl SqlitePersister {
}
}
- // Concurrent-peer detection relies on the auto-backup taken
- // before the cascade plus the SQLite-native `BEGIN EXCLUSIVE`
- // below — not a row-count fingerprint, which an in-place
- // UPDATE on a single-row table would evade. Pre-flushing
- // before the backup ensures the snapshot captures every
- // buffered write.
let backup_path = if skip_backup {
None
} else {
@@ -472,27 +472,20 @@ impl SqlitePersister {
)?
};
- // SQLite-native EXCLUSIVE for the cascade window. Excludes
- // cross-process peers (other rusqlite Connections, sibling
- // `SqlitePersister`s) that would otherwise commit rows for
- // `wallet_id` during the cascade. The in-process mutex on
- // `conn` alone never gave that guarantee. Peers waiting on
- // the lock back off via SQLite's `busy_timeout`.
+ // EXCLUSIVE for the cascade window excludes cross-process peers
+ // that the in-process conn mutex can't; they back off via
+ // `busy_timeout`.
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?;
- // Deleting the parent `wallet_metadata` row drives the whole
- // cleanup: native `ON DELETE CASCADE` removes every FK-bearing
- // per-wallet/per-identity table, and the AFTER DELETE triggers
- // broom every wallet/identity-scoped `meta_*` row (parentless
- // included). No per-table accounting is needed — the
- // cascade-completeness test asserts no row survives.
- crate::sqlite::schema::wallet_meta::delete(&tx, &wallet_id)?;
+ // Deleting the parent `wallets` row drives all cleanup: native
+ // `ON DELETE CASCADE` clears FK-bearing tables and AFTER DELETE
+ // triggers reap the `meta_*` rows (the completeness test
+ // asserts nothing survives).
+ crate::sqlite::schema::wallets::delete(&tx, &wallet_id)?;
tx.commit()?;
- // Commit succeeded — drop the original drained changeset.
drop(drained_slot.take());
- // Re-drain any changeset a Manual-mode store dropped into the
- // buffer while we held conn. The wallet is gone — these
- // writes are intentionally void.
+ // Discard any changeset a Manual-mode store buffered during the
+ // delete window — the wallet is gone.
if let Ok(Some(_late)) = self.buffer.take_for_flush(&wallet_id) {
tracing::warn!(
wallet_id = %hex::encode(wallet_id),
@@ -511,24 +504,16 @@ impl SqlitePersister {
result
}
- /// Attempt to flush every dirty wallet, regardless of flush mode.
+ /// Flush every dirty wallet regardless of flush mode — the only way
+ /// `Manual` writes become durable, and the retry path for transient
+ /// `Immediate`-mode failures left in the buffer. "Durable" means across
+ /// application crash (WAL + `synchronous=NORMAL`); use
+ /// [`Synchronous::Full`](crate::Synchronous) for power-loss durability.
///
- /// In `Manual` mode this is the only way pending writes become
- /// durable. In `Immediate` mode the buffer is normally empty (each
- /// `store` flushes inline) but a transient failure during `store`
- /// leaves the changeset in the buffer — `commit_writes` is the
- /// retry path that drains those leftovers.
- ///
- /// Continues past per-wallet failures instead of fails-fast.
- /// Each wallet's flush outcome lands on the returned
- /// [`CommitReport`]: `succeeded` for durable writes, `failed` for
- /// the classified `PersistenceError`. `still_pending` only fills
- /// when a `LockPoisoned` short-circuit prevents the loop from
- /// attempting the remaining wallets.
- ///
- /// Returns `Err` ONLY when even enumerating the dirty set fails
- /// (e.g. the buffer mutex is poisoned). Once the loop starts,
- /// every dirty wallet has a slot in the report.
+ /// Continues past per-wallet failures: each outcome lands on the
+ /// [`CommitReport`] (`succeeded` / `failed`), and `still_pending` fills
+ /// only when a `LockPoisoned` short-circuit skips the rest. Returns
+ /// `Err` only when enumerating the dirty set itself fails.
pub fn commit_writes(&self) -> Result {
self.commit_writes_inner()
}
@@ -539,12 +524,9 @@ impl SqlitePersister {
failed: Vec::new(),
still_pending: Vec::new(),
};
- // Even in `FlushMode::Immediate` the buffer can be non-empty:
- // a transient failure during `store()` re-merges the changeset
- // back into the buffer via `handle_flush_error`. The retry path
- // — `commit_writes()` — has to drain that leftover regardless
- // of flush mode, otherwise transient-failure data sits there
- // until the next per-wallet `store` happens to retry it.
+ // Even in `Immediate` mode the buffer can be non-empty: a transient
+ // `store()` failure re-merges the changeset, and only this drains
+ // it regardless of flush mode.
let dirty = self
.buffer
.dirty_wallets()
@@ -554,10 +536,8 @@ impl SqlitePersister {
match self.flush_inner(&id) {
Ok(()) => report.succeeded.push(id),
Err(PersistenceError::LockPoisoned) => {
- // Mutex is gone — no point hammering the remaining
- // wallets. Record this one as failed and shovel the
- // rest into still_pending so the caller knows what
- // was never attempted.
+ // Mutex is gone; record this as failed and the rest as
+ // never-attempted instead of hammering them.
report.failed.push((id, PersistenceError::LockPoisoned));
report.still_pending.extend(iter);
return Ok(report);
@@ -575,18 +555,10 @@ impl SqlitePersister {
.map_err(|_| WalletStorageError::LockPoisoned)
}
- // The feature is named with Cargo's `__` prefix convention to
- // signal "not part of the public API; downstream MUST NOT enable
- // it" (https://doc.rust-lang.org/cargo/reference/features.html).
- // The methods themselves are `#[doc(hidden)]` so they don't show
- // up on docs.rs even when the feature is on.
- /// Test-only: borrow the write connection.
- ///
- /// Tests use this to seed `wallet_metadata` rows directly, run
- /// SELECTs against tables that aren't part of the public surface,
- /// or probe `PRAGMA foreign_keys` / `PRAGMA journal_mode`. Gated
- /// behind `cfg(test)` and the `__test-helpers` feature —
- /// downstream crates MUST NOT enable it.
+ // The `__test-helpers` feature uses Cargo's `__` prefix convention:
+ // not public API, downstream MUST NOT enable it.
+ /// Test-only: borrow the write connection to seed rows or probe
+ /// non-public tables/pragmas. Downstream MUST NOT enable the feature.
#[doc(hidden)]
#[cfg(any(test, feature = "__test-helpers"))]
pub fn lock_conn_for_test(&self) -> MutexGuard<'_, Connection> {
@@ -608,8 +580,7 @@ impl SqlitePersister {
.map_err(PersistenceError::from)?;
let Some(cs) = cs else { return Ok(()) };
- // Test-only injector: surface a primed failure without ever
- // touching SQL so take/restore semantics are exercised end-to-end.
+ // Test-only injector: surface a primed failure without touching SQL.
#[cfg(any(test, feature = "__test-helpers"))]
if let Some(injected) = self.consume_primed_flush_error() {
return self.handle_flush_error(wallet_id, cs, injected);
@@ -637,15 +608,11 @@ impl SqlitePersister {
}
/// Classify the failure: transient errors restore the buffer and
- /// surface as `FlushRetryable`; everything else drops the
- /// changeset and returns the original variant.
+ /// surface as `FlushRetryable`; everything else drops the changeset
+ /// and returns the original variant.
//
- // TODO(qa): the fatal branch below covers `LockPoisoned`, but no
- // end-to-end mutex-poison test exists (a panicking thread plus a join
- // is hard to reproduce deterministically). It is verified by hand via
- // `Mutex::lock` failure injection at the typed-error layer; anyone
- // touching the classification policy or this branch must reconfirm
- // by hand.
+ // TODO(qa): the fatal `LockPoisoned` branch has no e2e mutex-poison
+ // test; verified by hand — reconfirm if you touch the classification.
fn handle_flush_error(
&self,
wallet_id: &WalletId,
@@ -655,9 +622,8 @@ impl SqlitePersister {
let field_count = populated_field_count(&cs);
let kind = err.error_kind_str();
if err.is_transient() {
- // A failed restore (e.g. poisoned buffer mutex) means the
- // buffered changeset is gone — that is itself fatal and
- // must surface, not be masked by the transient signal.
+ // A failed restore loses the changeset — itself fatal, so
+ // surface it instead of the transient signal.
if let Err(restore_err) = self.buffer.restore(*wallet_id, cs) {
tracing::error!(
wallet_id = %hex::encode(wallet_id),
@@ -667,16 +633,13 @@ impl SqlitePersister {
);
return Err(PersistenceError::from(restore_err));
}
- // Narrow the error to its rusqlite source — only
- // `Sqlite(SqliteFailure(BUSY|LOCKED, _))` qualifies for
- // surfacing as `FlushRetryable`.
+ // Narrow to the rusqlite source for `FlushRetryable`.
let source = match err {
WalletStorageError::Sqlite(rusq) => rusq,
WalletStorageError::FlushRetryable { source, .. } => source,
other => {
- // Defensive: classifier said "transient" but source
- // isn't rusqlite. Surface unwrapped — better than
- // lying about the source type.
+ // Defensive: "transient" but non-rusqlite source —
+ // surface raw rather than mislabel the source type.
tracing::warn!(
wallet_id = %hex::encode(wallet_id),
error_kind = kind,
@@ -703,16 +666,13 @@ impl SqlitePersister {
dropped_field_count = field_count,
"flush failed fatally — buffer wiped"
);
- // `cs` dropped here.
drop(cs);
Err(PersistenceError::from(err))
}
}
- /// Test-only: arm a one-shot injection consumed by the next
- /// `flush_inner`. Higher-level than `FailingConnection`; useful
- /// when the test doesn't care which SQL error fires, only how the
- /// wrapper reacts.
+ /// Test-only: arm a one-shot injection for the next `flush_inner`,
+ /// for tests that care only how the wrapper reacts to the error.
#[doc(hidden)]
#[cfg(any(test, feature = "__test-helpers"))]
pub fn force_next_flush_to_fail(&self, err: WalletStorageError) {
@@ -728,9 +688,7 @@ impl SqlitePersister {
}
/// Test-only: arm a one-shot pre-flush failure for the next
- /// `delete_wallet` call. The injection fires only when there is
- /// a drained buffered changeset to flush — i.e. when `delete_wallet`
- /// actually exercises the pre-flush branch.
+ /// `delete_wallet`; fires only when there's a drained changeset to flush.
#[doc(hidden)]
#[cfg(any(test, feature = "__test-helpers"))]
pub fn force_next_pre_flush_to_fail(&self, err: WalletStorageError) {
@@ -748,9 +706,8 @@ impl SqlitePersister {
.take()
}
- /// Test-only: probe whether the wallet has a buffered changeset.
- /// Used to assert the buffer survives a failed pre-flush without
- /// consuming it.
+ /// Test-only: whether the wallet has a buffered changeset (asserts the
+ /// buffer survives a failed pre-flush without consuming it).
#[doc(hidden)]
#[cfg(any(test, feature = "__test-helpers"))]
pub fn buffer_has_changeset_for_test(&self, wallet_id: &WalletId) -> bool {
@@ -761,22 +718,20 @@ impl SqlitePersister {
}
}
-/// When a `Manual`-mode persister is dropped while dirty wallets remain,
-/// log a structured `tracing::error!` so the silent-data-loss footgun
-/// (the buffer dies with the persister) surfaces in operator logs.
-///
-/// We intentionally do NOT auto-flush from `Drop` — `flush_inner`
-/// can fail and `Drop` cannot propagate errors, so a swallow there
-/// would be a worse failure mode than the loud log. `Immediate`-mode
-/// persisters are durable on every `store` so they never trip this.
+/// On drop of a `Manual`-mode persister with dirty wallets, log an error
+/// so the silent-data-loss footgun surfaces. We do NOT auto-flush from
+/// `Drop`: `flush_inner` can fail and `Drop` can't propagate, so swallowing
+/// would be worse than a loud log. `Immediate` mode never trips this.
impl Drop for SqlitePersister {
fn drop(&mut self) {
+ // Release the path claim FIRST so it happens regardless of flush
+ // mode (the warning below early-returns for Immediate).
+ release_open_path(&self.registered_path);
if self.config.flush_mode != FlushMode::Manual {
return;
}
- // `dirty_wallets` only fails on a poisoned buffer mutex. A
- // poisoned mutex on Drop already means the process is wedged;
- // we still try to surface the lost state where we can.
+ // `dirty_wallets` only fails on a poisoned buffer mutex; surface
+ // the lost state where we can.
let dirty = match self.buffer.dirty_wallets() {
Ok(d) => d,
Err(e) => {
@@ -791,11 +746,9 @@ impl Drop for SqlitePersister {
if dirty.is_empty() {
return;
}
- // `take_for_flush` mutates the buffer (drains the changeset).
- // That is intentional here: the persister is being dropped, no
- // future caller can observe the buffer, and `populated_field_count`
- // needs to inspect the changeset to produce the diagnostic. Do
- // NOT treat `impl Drop` as side-effect-free.
+ // `take_for_flush` drains the buffer — intentional in `Drop`: no
+ // future caller can observe it, and we need the changeset to count
+ // fields for the diagnostic.
let total_fields: usize = dirty
.iter()
.filter_map(|id| {
@@ -819,16 +772,14 @@ impl PlatformWalletPersistence for SqlitePersister {
/// Merge `changeset` into the per-wallet buffer.
///
/// Durability matrix:
- /// - In [`FlushMode::Immediate`] the call is **durable on `Ok`** —
- /// one SQLite transaction wraps every populated per-table apply,
- /// so either all sub-changesets land or none do. A transient
- /// failure restores the buffer and surfaces
- /// [`WalletStorageError::FlushRetryable`] wrapped in
- /// `PersistenceError::Backend`.
- /// - In [`FlushMode::Manual`] the call only merges into the
- /// in-memory buffer. Durability requires
- /// [`flush`](Self::flush) (per-wallet) or
- /// [`commit_writes`](Self::commit_writes) (every dirty wallet).
+ /// - [`FlushMode::Immediate`]: on `Ok`, durable across application
+ /// crash — one transaction wraps every per-table apply (all-or-
+ /// nothing). A transient failure restores the buffer and surfaces
+ /// [`WalletStorageError::FlushRetryable`]. Use
+ /// [`Synchronous::Full`](crate::Synchronous) for power-loss durability.
+ /// - [`FlushMode::Manual`]: only merges into the buffer; durability
+ /// needs [`flush`](Self::flush) or
+ /// [`commit_writes`](Self::commit_writes).
fn store(
&self,
wallet_id: WalletId,
@@ -849,27 +800,24 @@ impl PlatformWalletPersistence for SqlitePersister {
/// Load every wallet's start-state from disk.
///
- /// Populates `platform_addresses` per wallet. `wallets` stays empty
- /// pending an upstream `key_wallet::Wallet::from_persisted`
- /// constructor — the count of wallets that *would* be rehydrated is
- /// surfaced as the structured field `wallets_pending_rehydration`
- /// on the `tracing::info!` summary.
+ /// Populates `platform_addresses` and the keyless per-wallet `wallets`
+ /// payload (network, birth height, account manifest, core state,
+ /// identities, `Consumed`-filtered asset locks). Carries **no** `Wallet`
+ /// or key material — the manager rebuilds each wallet watch-only and
+ /// signs later on demand. The `tracing::info!` summary reports
+ /// `wallets_rehydrated`.
///
- /// Fail-hard: any row that fails to decode (or carries a malformed
- /// `wallet_id`) aborts the whole load with a typed
- /// [`WalletStorageError`]. Corruption is never silently skipped.
+ /// Fail-hard: any row that fails to decode (or has a malformed
+ /// `wallet_id`) aborts the whole load — corruption is never skipped.
///
- /// **Query budget.** Constant w.r.t. wallet count: one `SELECT` over
- /// `wallet_metadata` for the wallet-id list plus a fixed set of
- /// grouped scans (sync state, addresses, platform-payment
- /// registrations), not a per-wallet fan-out.
+ /// **Query budget.** Constant w.r.t. wallet count: one `SELECT` for the
+ /// id list plus a fixed set of grouped scans, not a per-wallet fan-out.
///
/// # Concurrency
///
- /// Holds the connection mutex for the duration of the read.
- /// Concurrent `store` / `flush` / `delete_wallet` calls block
- /// until `load` returns. Intended for one-shot use at process
- /// startup, not interleaved with the hot write path.
+ /// Holds the connection mutex for the whole read, so concurrent
+ /// `store` / `flush` / `delete_wallet` block until it returns. Intended
+ /// for one-shot startup use, not the hot write path.
///
/// # Examples
///
@@ -907,23 +855,14 @@ impl PlatformWalletPersistence for SqlitePersister {
/// # }
/// ```
fn load(&self) -> Result {
- // TODO: repopulate ClientStartState.wallets. The
- // identity/contacts/asset-lock readers exist, but the
- // `Wallet::from_persisted` wiring lands with the rehydration work;
- // until then `load()` only rebuilds `platform_addresses` and emits
- // a structured-log summary so operators see the gap.
let conn = self.conn().map_err(PersistenceError::from)?;
let mut state = ClientStartState::default();
let addrs_all = schema::platform_addrs::load_all(&conn).map_err(PersistenceError::from)?;
- let wallets_seen = addrs_all.len();
let mut addresses_loaded: usize = 0;
-
for (wallet_id, (addrs, count)) in addrs_all {
- // Omit a wallet that carries no platform state at all: no
- // per-account registrations, no addresses, and all sync
- // watermarks zero. Such a wallet contributes nothing to a
- // restored provider.
+ // Skip a wallet with no platform state at all (no addresses,
+ // no registrations, all sync watermarks zero).
if count > 0
|| !addrs.per_account.is_empty()
|| addrs.sync_height > 0
@@ -935,11 +874,61 @@ impl PlatformWalletPersistence for SqlitePersister {
}
}
+ // Per-wallet keyless rehydration payload; the manager rebuilds each
+ // wallet watch-only and derives signing keys later on demand.
+ let wallet_ids = schema::wallets::list_ids(&conn).map_err(PersistenceError::from)?;
+ let wallets_seen = wallet_ids.len();
+ for wallet_id in wallet_ids {
+ let (network_str, birth_height) = schema::wallets::fetch(&conn, &wallet_id)
+ .map_err(PersistenceError::from)?
+ .ok_or_else(|| {
+ PersistenceError::backend(format!(
+ "wallets row vanished mid-load for {}",
+ hex::encode(wallet_id)
+ ))
+ })?;
+ let network = schema::wallets::parse_network(&network_str).ok_or_else(|| {
+ PersistenceError::backend(format!(
+ "unknown persisted network {:?} for wallet {}",
+ network_str,
+ hex::encode(wallet_id)
+ ))
+ })?;
+
+ let account_manifest =
+ schema::accounts::load_state(&conn, &wallet_id).map_err(PersistenceError::from)?;
+ let core_state = schema::core_state::load_state(&conn, &wallet_id, network)
+ .map_err(PersistenceError::from)?;
+ let identity_manager = schema::identities::load_state(&conn, &wallet_id)
+ .map_err(PersistenceError::from)?;
+ let unused_asset_locks = schema::asset_locks::load_unconsumed(&conn, &wallet_id)
+ .map_err(PersistenceError::from)?;
+ let contacts = schema::contacts::load_changeset(&conn, &wallet_id)
+ .map_err(PersistenceError::from)?;
+ let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id)
+ .map_err(PersistenceError::from)?;
+
+ state.wallets.insert(
+ wallet_id,
+ platform_wallet::changeset::ClientWalletStartState {
+ network,
+ birth_height,
+ account_manifest,
+ core_state,
+ identity_manager,
+ unused_asset_locks,
+ contacts,
+ identity_keys,
+ },
+ );
+ }
+ let wallets_rehydrated = state.wallets.len();
+
tracing::info!(
wallets_seen,
addresses_loaded,
- wallets_rehydrated = 0usize,
- wallets_pending_rehydration = wallets_seen,
+ wallets_rehydrated,
+ wallets_pending_rehydration = 0usize,
unimplemented = ?LOAD_UNIMPLEMENTED,
"load() summary"
);
@@ -959,14 +948,10 @@ impl PlatformWalletPersistence for SqlitePersister {
}
}
-// ----- Helpers -----
-
-/// Count of top-level slots that carry any data. Feeds the persister's
-/// `restored_field_count` / `dropped_field_count` tracing fields so
-/// operators can see how much was kept or dropped on a flush retry /
-/// fatal failure. Computed here from the public `PlatformWalletChangeSet`
-/// fields + `Merge::is_empty()` so no storage-only helper leaks into
-/// the `rs-platform-wallet` public API.
+/// Count of top-level changeset slots carrying data, for the
+/// `restored_field_count` / `dropped_field_count` tracing fields. Computed
+/// from the public fields so no storage-only helper leaks into the
+/// `rs-platform-wallet` API.
fn populated_field_count(cs: &PlatformWalletChangeSet) -> usize {
[
cs.core.is_empty(),
@@ -995,10 +980,8 @@ fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageEr
reason: "synchronous=Off is rejected (data-loss footgun)",
});
}
- // `journal_mode=Memory` keeps the rollback journal in RAM and
- // `journal_mode=Off` disables it outright. Either turns crash-
- // safety into a coin flip for a wallet DB — reject loudly instead
- // of silently corrupting on the next power loss.
+ // `journal_mode` Memory/Off keeps no on-disk rollback journal, making
+ // a wallet DB crash-unsafe — reject loudly.
match config.journal_mode {
crate::sqlite::config::JournalMode::Memory => {
return Err(WalletStorageError::ConfigInvalid {
@@ -1012,10 +995,8 @@ fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageEr
}
_ => {}
}
- // `busy_timeout=0` makes contended writers fail-fast with BUSY
- // instead of waiting — non-fatal, but the operator almost certainly
- // didn't mean it. Warn rather than reject because a few tests
- // legitimately want the fail-fast behaviour.
+ // `busy_timeout=0` makes contended writers fail-fast with BUSY;
+ // warn (not reject) since a few tests legitimately want that.
if config.busy_timeout.is_zero() {
tracing::warn!(
"SqlitePersisterConfig.busy_timeout=0; contended writers will return BUSY \
@@ -1029,9 +1010,19 @@ fn apply_pragmas(
conn: &mut Connection,
config: &SqlitePersisterConfig,
) -> Result<(), WalletStorageError> {
- // `foreign_keys` is enabled + read-back-asserted in
- // `crate::sqlite::conn::open_conn`, the single open choke-point.
+ // `foreign_keys` is enabled + read-back-asserted in `open_conn`.
conn.pragma_update(None, "journal_mode", config.journal_mode.pragma_value())?;
+ // Read `journal_mode` back: `pragma_update` doesn't error when SQLite
+ // silently falls back (e.g. WAL→DELETE on FUSE), which with
+ // synchronous=NORMAL risks corruption on power loss.
+ let applied_journal: String =
+ conn.pragma_query_value(None, "journal_mode", |row| row.get(0))?;
+ if !applied_journal.eq_ignore_ascii_case(config.journal_mode.pragma_value()) {
+ return Err(WalletStorageError::JournalModeNotApplied {
+ requested: config.journal_mode.pragma_value(),
+ actual: applied_journal,
+ });
+ }
conn.pragma_update(None, "synchronous", config.synchronous.pragma_value())?;
let ms = safe_cast::u64_to_i64(
"busy_timeout_ms",
@@ -1041,25 +1032,25 @@ fn apply_pragmas(
Ok(())
}
-/// Apply every populated sub-changeset of `cs` against the supplied
-/// SQLite transaction. Does not commit; the caller owns the tx
-/// lifecycle. Splitting this out from `write_changeset_in_one_tx`
-/// lets `delete_wallet_inner` flush a drained buffer into a bespoke
-/// pre-delete tx without re-opening the connection.
+/// Apply every populated sub-changeset of `cs` against `tx` without
+/// committing (caller owns the tx). Separate from
+/// `write_changeset_in_one_tx` so `delete_wallet_inner` can flush a drained
+/// buffer into its own pre-delete tx.
fn apply_changeset_to_tx(
tx: &rusqlite::Transaction<'_>,
wallet_id: &WalletId,
cs: &PlatformWalletChangeSet,
) -> Result<(), WalletStorageError> {
if let Some(meta) = cs.wallet_metadata.as_ref() {
- schema::wallet_meta::upsert(tx, wallet_id, meta)?;
+ schema::wallets::upsert(tx, wallet_id, meta)?;
}
if !cs.account_registrations.is_empty() {
schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?;
}
- if !cs.account_address_pools.is_empty() {
- schema::accounts::apply_pools(tx, wallet_id, &cs.account_address_pools)?;
- }
+ // `account_address_pools` is intentionally NOT applied: UTXO attribution
+ // is hardcoded to the default account (index 0) in `core_state`, so the
+ // pool snapshot is no longer a storage input. The changeset field is kept
+ // for API stability and still feeds non-storage consumers.
if let Some(core) = cs.core.as_ref() {
schema::core_state::apply(tx, wallet_id, core)?;
}
@@ -1120,12 +1111,9 @@ fn ensure_dir(dir: &Path) -> Result<(), WalletStorageError> {
}
})?;
}
- // Best-effort writability probe via `NamedTempFile` (unguessable
- // name, no race against concurrent persister opens). This is TOCTOU
- // by construction — the dir CAN flip to unwritable between the probe
- // and `backup::run_to` below — but the real write has its own error
- // path, so the worst case is the operator gets the typed error from
- // the actual backup attempt instead of this fast-fail probe.
+ // Fast-fail writability probe. TOCTOU by construction (the dir can flip
+ // before `run_to`), but the real write has its own error path, so the
+ // worst case is a later typed error instead of this early one.
match tempfile::NamedTempFile::new_in(dir) {
Ok(_probe) => Ok(()),
Err(source) => Err(WalletStorageError::AutoBackupDirUnwritable {
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
index 04e251496f0..9042f243767 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
@@ -1,15 +1,21 @@
-//! `account_registrations` + `account_address_pools` writers + readers.
+//! `account_registrations` writer + keyless reader (platform-payment
+//! registrations and the rehydration account-manifest oracle).
use std::collections::BTreeMap;
use key_wallet::bip32::ExtendedPubKey;
use rusqlite::{params, Connection, Transaction};
-use platform_wallet::changeset::{AccountAddressPoolEntry, AccountRegistrationEntry};
+use platform_wallet::changeset::AccountRegistrationEntry;
use platform_wallet::wallet::platform_wallet::WalletId;
use crate::sqlite::error::WalletStorageError;
use crate::sqlite::schema::blob;
+use crate::sqlite::schema::blob::impl_persistable_blob;
+
+// PUBLIC material only: the account-registration xpub manifest reaching
+// the `account_xpub_bytes` blob column.
+impl_persistable_blob!(AccountRegistrationEntry);
/// Decoded `platform_payment` account registration: the DIP-17 account
/// index and its extended public key, recovered from the bincode-serde
@@ -22,20 +28,16 @@ fn decode_platform_payment_row(
account_index: i64,
xpub_bytes: &[u8],
) -> Result {
- let account_index =
- u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow {
- field: "account_registrations.account_index",
- value: account_index as u64,
- target: crate::sqlite::util::safe_cast::SafeCastTarget::U64,
- })?;
+ let account_index = crate::sqlite::util::safe_cast::i64_to_u32(
+ "account_registrations.account_index",
+ account_index,
+ )?;
let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?;
Ok((account_index, entry.account_xpub))
}
-/// Every `platform_payment` account registration for one wallet, decoded
-/// into `(account_index, xpub)`. The xpub is recovered from the
-/// bincode-serde `AccountRegistrationEntry` `apply_registrations` writes
-/// into `account_xpub_bytes`.
+/// Every `platform_payment` registration for one wallet, decoded into
+/// `(account_index, xpub)`.
#[cfg(any(test, feature = "__test-helpers"))]
pub(crate) fn list_platform_payment_registrations(
conn: &Connection,
@@ -99,10 +101,8 @@ pub fn apply_registrations(
if entries.is_empty() {
return Ok(());
}
- // `account_xpub_bytes` carries the bincode-serde encoded
- // `AccountRegistrationEntry` (xpub + account_type). The
- // separate `account_type` / `account_index` columns mirror
- // the entry for direct SQL lookups.
+ // `account_xpub_bytes` holds the encoded `AccountRegistrationEntry`; the
+ // separate `account_type` / `account_index` columns mirror it for SQL.
let mut stmt = tx.prepare_cached(
"INSERT INTO account_registrations \
(wallet_id, account_type, account_index, account_xpub_bytes) \
@@ -124,52 +124,64 @@ pub fn apply_registrations(
Ok(())
}
-pub fn apply_pools(
- tx: &Transaction<'_>,
+/// Read every `account_registrations` row for `wallet_id` into a keyless
+/// [`AccountRegistrationEntry`] manifest — the rehydration account-set oracle
+/// (which accounts to re-derive + the per-account xpubs the wrong-account gate
+/// checks). PUBLIC material only (xpub + account type), no `Wallet` minted.
+/// Ordered by `(account_type, account_index)` for determinism; a row that
+/// fails to decode is a hard [`WalletStorageError`].
+pub fn load_state(
+ conn: &Connection,
wallet_id: &WalletId,
- entries: &[AccountAddressPoolEntry],
-) -> Result<(), WalletStorageError> {
- if entries.is_empty() {
- return Ok(());
- }
- let mut stmt = tx.prepare_cached(
- "INSERT INTO account_address_pools \
- (wallet_id, account_type, account_index, pool_type, snapshot_blob) \
- VALUES (?1, ?2, ?3, ?4, ?5) \
- ON CONFLICT(wallet_id, account_type, account_index, pool_type) DO UPDATE SET \
- snapshot_blob = excluded.snapshot_blob",
+) -> Result, WalletStorageError> {
+ // Select typed columns alongside the blob so we can cross-check them
+ // against the decoded entry — a row whose blob disagrees with its indexed
+ // columns is a sign of corruption or a schema bug and must be rejected
+ // rather than silently mis-bucketed.
+ let mut stmt = conn.prepare(
+ "SELECT account_type, account_index, account_xpub_bytes FROM account_registrations \
+ WHERE wallet_id = ?1 ORDER BY account_type, account_index",
)?;
- for entry in entries {
- let account_type = account_type_db_label(&entry.account_type);
- let account_index = account_index(&entry.account_type);
- let pool_type = pool_type_db_label(&entry.pool_type);
- let payload = blob::encode(entry)?;
- stmt.execute(params![
- wallet_id.as_slice(),
- account_type,
- i64::from(account_index),
- pool_type,
- payload,
- ])?;
+ let rows = stmt.query_map(params![wallet_id.as_slice()], |row| {
+ Ok((
+ row.get::<_, String>(0)?, // account_type TEXT
+ row.get::<_, i64>(1)?, // account_index INTEGER
+ row.get::<_, Vec>(2)?, // account_xpub_bytes BLOB
+ ))
+ })?;
+ let mut out = Vec::new();
+ for r in rows {
+ let (typed_account_type, typed_account_index, payload) = r?;
+ let entry = blob::decode::(&payload)?;
+ // Cross-check the typed indexed columns vs the decoded blob so
+ // a corruption that passes `PRAGMA integrity_check` is still
+ // caught here rather than feeding a wrong account to the oracle.
+ let blob_type = account_type_db_label(&entry.account_type);
+ let blob_index = account_index(&entry.account_type);
+ let typed_index = crate::sqlite::util::safe_cast::i64_to_u32(
+ "account_registrations.account_index",
+ typed_account_index,
+ )?;
+ if blob_type != typed_account_type.as_str() || blob_index != typed_index {
+ return Err(WalletStorageError::AccountRegistrationEntryMismatch);
+ }
+ out.push(entry);
}
- Ok(())
+ Ok(out)
}
-/// Single source of truth for the `account_type` TEXT-column domain
-/// across `account_registrations`, `account_address_pools`, and
-/// `core_derived_addresses`.
+/// Source of truth for the `account_registrations.account_type` TEXT domain,
+/// mirroring [`key_wallet::account::AccountType`].
+/// `migrations/V001__initial.rs` interpolates it into the table's
+/// `CHECK (account_type IN (...))`; `account_type_labels_match_enum` keeps it
+/// in sync with [`account_type_db_label`].
///
-/// Mirrors every variant of [`key_wallet::account::AccountType`]
-/// (writer side: [`account_type_db_label`]). The migration in
-/// `migrations/V001__initial.rs` interpolates this array into the
-/// `CHECK (account_type IN (...))` clause on each of those tables, so
-/// an unknown label is rejected at insert time rather than landing as
-/// silent garbage. The `account_type_labels_match_enum` unit test
-/// below enforces set-equality between this array and the writer's
-/// output — drift (a renamed/added variant) becomes a failing test,
-/// not a runtime divergence between Rust and SQLite.
+/// `Standard` maps to two distinct labels by `StandardAccountType` variant
+/// (`"standard_bip44"` / `"standard_bip32"`) so BIP44 and BIP32 standard
+/// accounts with the same index never collide on their shared PK columns.
pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[
- "standard",
+ "standard_bip44",
+ "standard_bip32",
"coinjoin",
"identity_registration",
"identity_topup",
@@ -186,30 +198,23 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[
"platform_payment",
];
-/// Single source of truth for the `account_address_pools.pool_type`
-/// TEXT-column domain.
-///
-/// Mirrors every variant of
-/// [`key_wallet::managed_account::address_pool::AddressPoolType`]
-/// (writer side: [`pool_type_db_label`]). See [`ACCOUNT_TYPE_LABELS`]
-/// for the broader rationale and the parity-test contract.
-pub(crate) const POOL_TYPE_LABELS: &[&str] = &["external", "internal", "absent", "absent_hardened"];
-
-/// Stable database label for an `AccountType` variant.
+/// Stable database label for an `AccountType` variant (the `Debug` impl is not
+/// a stable format; this match is the contract). An added upstream variant
+/// fails this match's exhaustiveness check at compile time.
///
-/// Used for the `account_type` text column on `account_registrations`,
-/// `account_address_pools`, and `core_derived_addresses`. The
-/// `Debug` impl on `AccountType` is NOT a stable serialisation
-/// format; this match is the contract. Variants identical in
-/// label are distinguished by the companion `account_index` column.
-///
-/// Adding a variant to upstream `AccountType` makes this match
-/// exhaustive-check fail at compile time, forcing an explicit label
-/// decision rather than silent garbage.
+/// `Standard` maps to two distinct labels by `StandardAccountType` so BIP44
+/// and BIP32 accounts with the same `index` never collapse onto the same PK.
pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'static str {
- use key_wallet::account::AccountType;
+ use key_wallet::account::{AccountType, StandardAccountType};
match at {
- AccountType::Standard { .. } => "standard",
+ AccountType::Standard {
+ standard_account_type: StandardAccountType::BIP44Account,
+ ..
+ } => "standard_bip44",
+ AccountType::Standard {
+ standard_account_type: StandardAccountType::BIP32Account,
+ ..
+ } => "standard_bip32",
AccountType::CoinJoin { .. } => "coinjoin",
AccountType::IdentityRegistration => "identity_registration",
AccountType::IdentityTopUp { .. } => "identity_topup",
@@ -227,24 +232,8 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'
}
}
-/// Stable database label for an `AddressPoolType` variant.
-pub(crate) fn pool_type_db_label(
- pool: &key_wallet::managed_account::address_pool::AddressPoolType,
-) -> &'static str {
- use key_wallet::managed_account::address_pool::AddressPoolType;
- match pool {
- AddressPoolType::External => "external",
- AddressPoolType::Internal => "internal",
- AddressPoolType::Absent => "absent",
- AddressPoolType::AbsentHardened => "absent_hardened",
- }
-}
-
-/// Numeric account index embedded in an `AccountType`.
-///
-/// Persisted in the `account_index` column of `account_registrations`,
-/// `account_address_pools`, and `core_derived_addresses` (the last of
-/// which is the script→account lookup the UTXO writer joins against).
+/// Numeric account index embedded in an `AccountType`, persisted in the
+/// `account_registrations.account_index` column.
pub(crate) fn account_index(at: &key_wallet::account::AccountType) -> u32 {
use key_wallet::account::AccountType;
match at {
@@ -271,11 +260,147 @@ mod tests {
use super::*;
use std::collections::HashSet;
- /// Exhaustive sample of every [`key_wallet::account::AccountType`]
- /// variant. The match arm in the loop below uses no wildcard, so
- /// an upstream-added variant becomes a compile error here and
- /// forces the developer to extend the sample list (and the
- /// matching arm in `account_type_db_label` / [`ACCOUNT_TYPE_LABELS`]).
+ /// Open an in-memory SQLite connection and run the full schema migration
+ /// so tests can insert rows through the production table DDL.
+ fn migrated_conn() -> rusqlite::Connection {
+ let mut conn = rusqlite::Connection::open_in_memory().unwrap();
+ crate::sqlite::migrations::run(&mut conn).unwrap();
+ conn
+ }
+
+ /// A fixed serialised extended public key for use in tests. Taken from the
+ /// BIP-32 mainnet test vector so it is stable and round-trips correctly.
+ fn test_xpub() -> key_wallet::bip32::ExtendedPubKey {
+ key_wallet::bip32::ExtendedPubKey::decode(
+ &hex::decode(
+ "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D\
+ 314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC\
+ 2DF1050E2E8FF49C85C2",
+ )
+ .unwrap(),
+ )
+ .unwrap()
+ }
+
+ /// `load_state` must return `AccountRegistrationEntryMismatch` when the
+ /// typed `account_type` column disagrees with the decoded blob. The test
+ /// inserts a row whose blob encodes a `PlatformPayment` entry but whose
+ /// column is set to `identity_registration`, then verifies the mismatch
+ /// is caught on the read path.
+ #[test]
+ fn load_state_rejects_account_type_column_mismatch() {
+ let conn = migrated_conn();
+ let w = [0x11u8; 32];
+ conn.execute(
+ "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)",
+ rusqlite::params![&w[..]],
+ )
+ .unwrap();
+
+ // Build a valid blob for PlatformPayment (account_index = 0).
+ let entry = AccountRegistrationEntry {
+ account_type: key_wallet::account::AccountType::PlatformPayment {
+ account: 0,
+ key_class: 0,
+ },
+ account_xpub: test_xpub(),
+ };
+ let blob = blob::encode(&entry).unwrap();
+
+ // Insert with a deliberately wrong `account_type` column label so
+ // the typed column and the blob disagree.
+ conn.execute(
+ "INSERT INTO account_registrations \
+ (wallet_id, account_type, account_index, account_xpub_bytes) \
+ VALUES (?1, 'identity_registration', 0, ?2)",
+ rusqlite::params![&w[..], blob],
+ )
+ .unwrap();
+
+ let err = load_state(&conn, &w).expect_err("load_state must fail on type mismatch");
+ assert!(
+ matches!(err, WalletStorageError::AccountRegistrationEntryMismatch),
+ "expected AccountRegistrationEntryMismatch, got {err:?}"
+ );
+ }
+
+ /// `load_state` must return `AccountRegistrationEntryMismatch` when the
+ /// typed `account_index` column disagrees with the decoded blob, even when
+ /// `account_type` matches.
+ #[test]
+ fn load_state_rejects_account_index_column_mismatch() {
+ let conn = migrated_conn();
+ let w = [0x22u8; 32];
+ conn.execute(
+ "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)",
+ rusqlite::params![&w[..]],
+ )
+ .unwrap();
+
+ // Blob encodes PlatformPayment at account index 0.
+ let entry = AccountRegistrationEntry {
+ account_type: key_wallet::account::AccountType::PlatformPayment {
+ account: 0,
+ key_class: 0,
+ },
+ account_xpub: test_xpub(),
+ };
+ let blob = blob::encode(&entry).unwrap();
+
+ // Column says account_index = 1 but blob says 0 — deliberate mismatch.
+ conn.execute(
+ "INSERT INTO account_registrations \
+ (wallet_id, account_type, account_index, account_xpub_bytes) \
+ VALUES (?1, 'platform_payment', 1, ?2)",
+ rusqlite::params![&w[..], blob],
+ )
+ .unwrap();
+
+ let err = load_state(&conn, &w).expect_err("load_state must fail on index mismatch");
+ assert!(
+ matches!(err, WalletStorageError::AccountRegistrationEntryMismatch),
+ "expected AccountRegistrationEntryMismatch, got {err:?}"
+ );
+ }
+
+ /// Baseline: a consistent row (column and blob agree) round-trips cleanly.
+ #[test]
+ fn load_state_accepts_consistent_row() {
+ let conn = migrated_conn();
+ let w = [0x33u8; 32];
+ conn.execute(
+ "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)",
+ rusqlite::params![&w[..]],
+ )
+ .unwrap();
+ let entry = AccountRegistrationEntry {
+ account_type: key_wallet::account::AccountType::PlatformPayment {
+ account: 3,
+ key_class: 0,
+ },
+ account_xpub: test_xpub(),
+ };
+ let blob = blob::encode(&entry).unwrap();
+ conn.execute(
+ "INSERT INTO account_registrations \
+ (wallet_id, account_type, account_index, account_xpub_bytes) \
+ VALUES (?1, 'platform_payment', 3, ?2)",
+ rusqlite::params![&w[..], blob],
+ )
+ .unwrap();
+
+ let loaded = load_state(&conn, &w).expect("consistent row must load cleanly");
+ assert_eq!(loaded.len(), 1);
+ assert!(matches!(
+ loaded[0].account_type,
+ key_wallet::account::AccountType::PlatformPayment { account: 3, .. }
+ ));
+ }
+
+ /// Every [`key_wallet::account::AccountType`] variant; the wildcard-free
+ /// match below fails to compile if upstream adds one. `Standard` appears
+ /// twice — once per `StandardAccountType` — because both map to distinct
+ /// labels.
fn all_account_type_variants() -> Vec {
use key_wallet::account::{AccountType, StandardAccountType};
let variants = vec![
@@ -283,6 +408,10 @@ mod tests {
index: 0,
standard_account_type: StandardAccountType::BIP44Account,
},
+ AccountType::Standard {
+ index: 0,
+ standard_account_type: StandardAccountType::BIP32Account,
+ },
AccountType::CoinJoin { index: 0 },
AccountType::IdentityRegistration,
AccountType::IdentityTopUp {
@@ -311,9 +440,6 @@ mod tests {
key_class: 0,
},
];
- // Compile-time exhaustiveness gate: an added upstream variant
- // makes this match fail to compile and forces the sample list
- // (and `account_type_db_label`) to be updated.
for v in &variants {
match v {
AccountType::Standard { .. }
@@ -336,25 +462,6 @@ mod tests {
variants
}
- fn all_pool_type_variants() -> Vec {
- use key_wallet::managed_account::address_pool::AddressPoolType;
- let variants = vec![
- AddressPoolType::External,
- AddressPoolType::Internal,
- AddressPoolType::Absent,
- AddressPoolType::AbsentHardened,
- ];
- for v in &variants {
- match v {
- AddressPoolType::External
- | AddressPoolType::Internal
- | AddressPoolType::Absent
- | AddressPoolType::AbsentHardened => {}
- }
- }
- variants
- }
-
#[test]
fn account_type_labels_match_enum() {
let from_writer: HashSet<&'static str> = all_account_type_variants()
@@ -368,18 +475,4 @@ mod tests {
from_const, from_writer
);
}
-
- #[test]
- fn pool_type_labels_match_enum() {
- let from_writer: HashSet<&'static str> = all_pool_type_variants()
- .iter()
- .map(pool_type_db_label)
- .collect();
- let from_const: HashSet<&'static str> = POOL_TYPE_LABELS.iter().copied().collect();
- assert_eq!(
- from_writer, from_const,
- "POOL_TYPE_LABELS ({:?}) drifted from pool_type_db_label codomain ({:?})",
- from_const, from_writer
- );
- }
}
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
index af89cdc1e4d..990bbacbf72 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
@@ -13,14 +13,17 @@ use platform_wallet::wallet::platform_wallet::WalletId;
use crate::sqlite::error::WalletStorageError;
use crate::sqlite::schema::blob;
-// Imports used only by the test-gated readers below.
-#[cfg(any(test, feature = "__test-helpers"))]
use {
dashcore::OutPoint, platform_wallet::changeset::AssetLockEntry,
platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock, rusqlite::Connection,
std::collections::BTreeMap,
};
+use crate::sqlite::schema::blob::impl_persistable_blob;
+
+// PUBLIC material only: asset-lock lifecycle reaching `lifecycle_blob`.
+impl_persistable_blob!(AssetLockEntry);
+
pub fn apply(
tx: &Transaction<'_>,
wallet_id: &WalletId,
@@ -66,19 +69,11 @@ pub fn apply(
Ok(())
}
-/// Single source of truth for the `asset_locks.status` TEXT-column
-/// domain.
-///
-/// Mirrors every variant of
-/// [`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`]
-/// (writer side: [`status_str`]). The migration in
-/// `migrations/V001__initial.rs` interpolates this array into the
-/// `CHECK (status IN (...))` clause so an unknown label is rejected at
-/// insert time rather than landing as silent garbage. The
-/// `asset_lock_status_labels_match_enum` unit test below enforces
-/// set-equality between this array and the writer's output — drift (a
-/// renamed/added variant) becomes a failing test, not a runtime
-/// divergence between Rust and SQLite.
+/// Source of truth for the `asset_locks.status` TEXT domain, mirroring
+/// [`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`].
+/// `migrations/V001__initial.rs` interpolates it into a `CHECK (status IN
+/// (...))`; `asset_lock_status_labels_match_enum` keeps it in sync with
+/// [`status_str`].
pub(crate) const ASSET_LOCK_STATUS_LABELS: &[&str] = &[
"built",
"broadcast",
@@ -99,35 +94,29 @@ fn status_str(s: &AssetLockStatus) -> &'static str {
/// Per-wallet asset-lock slice as returned by the readers — outer-keyed
/// by `account_index`, inner-keyed by outpoint.
-#[cfg(any(test, feature = "__test-helpers"))]
pub type AssetLocksByAccount = BTreeMap>;
-/// Decode one raw `(outpoint_bytes, account_index, lifecycle_blob)`
+/// Decode one raw `(outpoint_bytes, account_index, lifecycle_blob, status)`
/// tuple into the typed `(account_index, OutPoint, TrackedAssetLock)`
-/// triple that [`load_state`] consumes.
+/// triple that the reader functions consume.
///
-/// Hard-fail behaviour: a malformed outpoint, blob, or out-of-range
-/// account index returns a typed [`WalletStorageError`]. Every caller
-/// propagates that error — corruption is never silently skipped.
-#[cfg(any(test, feature = "__test-helpers"))]
+/// Hard-fail behaviour: a malformed outpoint, blob, out-of-range
+/// account index, or a mismatch between the typed columns and the blob
+/// returns a typed [`WalletStorageError`]. Every caller propagates that
+/// error — corruption is never silently skipped.
fn decode_row(
op_bytes: &[u8],
account_index: i64,
blob_bytes: &[u8],
+ typed_status: &str,
) -> Result<(u32, OutPoint, TrackedAssetLock), WalletStorageError> {
let outpoint = blob::decode_outpoint(op_bytes)?;
let entry: AssetLockEntry = blob::decode(blob_bytes)?;
let account_index =
- u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow {
- field: "asset_locks.account_index",
- value: account_index as u64,
- target: crate::sqlite::util::safe_cast::SafeCastTarget::U64,
- })?;
- // Typed-column vs blob cross-check, symmetric with
- // IdentityKeyEntryMismatch. A torn write / partial migration /
- // restored corruption that passes PRAGMA integrity_check would
- // otherwise silently mis-bucket the lock into the wrong account or
- // report a different outpoint than the indexed column it was
+ crate::sqlite::util::safe_cast::i64_to_u32("asset_locks.account_index", account_index)?;
+ // Typed-column vs blob cross-check: corruption that passes PRAGMA
+ // integrity_check would otherwise mis-bucket the lock or report a
+ // different outpoint / account index than the indexed columns it was
// selected by.
if entry.out_point != outpoint || entry.account_index != account_index {
return Err(WalletStorageError::AssetLockEntryMismatch {
@@ -137,6 +126,15 @@ fn decode_row(
blob_account_index: entry.account_index,
});
}
+ // Status cross-check: the typed `status` column drives SQL-level filters
+ // (e.g. `load_unconsumed`'s `status NOT IN ('consumed')`), so a blob that
+ // disagrees with the column would cause a consumed lock to re-enter the
+ // live set or an active lock to be filtered out.
+ if status_str(&entry.status) != typed_status {
+ return Err(WalletStorageError::BlobDecode {
+ reason: "asset_locks.status column disagrees with lifecycle_blob status",
+ });
+ }
let tracked = TrackedAssetLock {
out_point: entry.out_point,
transaction: entry.transaction,
@@ -150,33 +148,89 @@ fn decode_row(
Ok((account_index, outpoint, tracked))
}
-/// Build the per-wallet asset-lock slice for `ClientStartState` from
-/// the `asset_locks` table, bucketed by account index. Every status
-/// variant the changeset writes is considered "active": consumed
-/// locks leave the table via [`AssetLockChangeSet::removed`], so a
-/// row present here is by definition still in play. Any row that
-/// fails to read or decode is a hard error — corruption is never
-/// silently dropped. Retained for this crate's integration tests until
-/// the rehydration path consumes it in `load()`.
+/// Full-history asset-lock slice bucketed by account index, **including**
+/// terminal `Consumed` rows (inspection reader for this crate's tests). Use
+/// [`load_unconsumed`] for the rehydration feed. A row that fails to decode is
+/// a hard error.
#[cfg(any(test, feature = "__test-helpers"))]
pub fn load_state(
conn: &Connection,
wallet_id: &WalletId,
) -> Result {
let mut stmt = conn.prepare(
- "SELECT outpoint, account_index, lifecycle_blob \
+ "SELECT outpoint, account_index, lifecycle_blob, status \
FROM asset_locks WHERE wallet_id = ?1",
)?;
let rows = stmt.query_map(params![wallet_id.as_slice()], |row| {
let op_bytes: Vec = row.get(0)?;
let account_index: i64 = row.get(1)?;
let blob_bytes: Vec = row.get(2)?;
- Ok((op_bytes, account_index, blob_bytes))
+ let status: String = row.get(3)?;
+ Ok((op_bytes, account_index, blob_bytes, status))
+ })?;
+ let mut out: AssetLocksByAccount = BTreeMap::new();
+ for r in rows {
+ let (op_bytes, account_index, blob_bytes, status) = r?;
+ let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?;
+ out.entry(acct).or_default().insert(outpoint, tracked);
+ }
+ Ok(out)
+}
+
+/// Status-filtered rehydration feed: every asset lock **except** terminal
+/// `Consumed` rows, bucketed by account index. Feeding `Consumed` locks back
+/// into the live set would resurrect a spent one-shot lock as actionable
+/// (A04/A08), so the exclusion is at the SQL level (`status NOT IN
+/// ('consumed')`, `status` indexed); history stays visible via [`load_state`].
+/// A row that fails to decode is a hard [`WalletStorageError`].
+pub fn load_unconsumed(
+ conn: &Connection,
+ wallet_id: &WalletId,
+) -> Result {
+ let mut stmt = conn.prepare(
+ "SELECT outpoint, account_index, lifecycle_blob, status \
+ FROM asset_locks WHERE wallet_id = ?1 AND status NOT IN ('consumed')",
+ )?;
+ let rows = stmt.query_map(params![wallet_id.as_slice()], |row| {
+ let op_bytes: Vec = row.get(0)?;
+ let account_index: i64 = row.get(1)?;
+ let blob_bytes: Vec = row.get(2)?;
+ let status: String = row.get(3)?;
+ Ok((op_bytes, account_index, blob_bytes, status))
})?;
let mut out: AssetLocksByAccount = BTreeMap::new();
for r in rows {
- let (op_bytes, account_index, blob_bytes) = r?;
- let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes)?;
+ let (op_bytes, account_index, blob_bytes, status) = r?;
+ let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?;
+ out.entry(acct).or_default().insert(outpoint, tracked);
+ }
+ Ok(out)
+}
+
+/// Every asset lock bucketed by account index, **including** terminal
+/// `Consumed` — history/inspection only; use [`load_unconsumed`] for the
+/// rehydration feed. A row that fails to decode is a hard
+/// [`WalletStorageError`].
+#[cfg(any(test, feature = "__test-helpers"))]
+pub fn list_active(
+ conn: &Connection,
+ wallet_id: &WalletId,
+) -> Result {
+ let mut stmt = conn.prepare(
+ "SELECT outpoint, account_index, lifecycle_blob, status \
+ FROM asset_locks WHERE wallet_id = ?1",
+ )?;
+ let rows = stmt.query_map(params![wallet_id.as_slice()], |row| {
+ let op_bytes: Vec = row.get(0)?;
+ let account_index: i64 = row.get(1)?;
+ let blob_bytes: Vec = row.get(2)?;
+ let status: String = row.get(3)?;
+ Ok((op_bytes, account_index, blob_bytes, status))
+ })?;
+ let mut out: AssetLocksByAccount = BTreeMap::new();
+ for r in rows {
+ let (op_bytes, account_index, blob_bytes, status) = r?;
+ let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?;
out.entry(acct).or_default().insert(outpoint, tracked);
}
Ok(out)
@@ -187,10 +241,81 @@ mod tests {
use super::*;
use std::collections::HashSet;
- /// Exhaustive sample of every [`AssetLockStatus`] variant. The
- /// trailing match arm in the loop fails to compile if upstream
- /// adds a variant — forcing the developer to extend the list,
- /// `status_str`, and [`ASSET_LOCK_STATUS_LABELS`] together.
+ /// Open an in-memory connection with the full schema applied.
+ fn migrated_conn() -> rusqlite::Connection {
+ let mut conn = rusqlite::Connection::open_in_memory().unwrap();
+ crate::sqlite::migrations::run(&mut conn).unwrap();
+ conn
+ }
+
+ /// `decode_row` (and the reader functions that call it) must reject a row
+ /// whose `status` TEXT column disagrees with the `lifecycle_blob` status,
+ /// returning a `BlobDecode` corrupt error rather than silently mis-bucketing
+ /// a lock (e.g. treating a `Consumed` lock as `Built`).
+ #[test]
+ fn load_state_rejects_status_column_mismatch() {
+ use dashcore::hashes::Hash;
+ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType;
+ use platform_wallet::changeset::AssetLockEntry;
+
+ let mut conn = migrated_conn();
+ let w = [0xAAu8; 32];
+ conn.execute(
+ "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)",
+ params![&w[..]],
+ )
+ .unwrap();
+
+ // Build a minimal `AssetLockEntry` with `status = Built`.
+ let outpoint = dashcore::OutPoint {
+ txid: dashcore::Txid::from_byte_array([0x01u8; 32]),
+ vout: 0,
+ };
+ let entry = AssetLockEntry {
+ out_point: outpoint,
+ // Dashcore Transaction with integer version and lock_time.
+ transaction: dashcore::Transaction {
+ version: 3,
+ lock_time: 0,
+ input: vec![],
+ output: vec![],
+ special_transaction_payload: None,
+ },
+ account_index: 0,
+ funding_type: AssetLockFundingType::IdentityTopUp,
+ identity_index: 0,
+ amount_duffs: 1000,
+ status: AssetLockStatus::Built,
+ proof: None,
+ };
+ let lifecycle_blob = blob::encode(&entry).unwrap();
+ let op_bytes = blob::encode_outpoint(&outpoint).unwrap();
+
+ // Insert with status column = 'consumed' but blob says 'built'.
+ {
+ let tx = conn.transaction().unwrap();
+ tx.execute(
+ "INSERT INTO asset_locks \
+ (wallet_id, outpoint, status, account_index, identity_index, \
+ amount_duffs, lifecycle_blob) \
+ VALUES (?1, ?2, 'consumed', 0, 0, 1000, ?3)",
+ params![&w[..], &op_bytes[..], lifecycle_blob],
+ )
+ .unwrap();
+ tx.commit().unwrap();
+ }
+
+ // load_state must fail: status column ('consumed') ≠ blob status ('built').
+ let err = load_state(&conn, &w)
+ .expect_err("load_state must reject a status column vs blob mismatch");
+ assert!(
+ matches!(err, WalletStorageError::BlobDecode { .. }),
+ "expected BlobDecode for status mismatch, got {err:?}"
+ );
+ }
+
+ /// Every [`AssetLockStatus`] variant; the wildcard-free match below fails
+ /// to compile if upstream adds one.
fn all_asset_lock_status_variants() -> Vec {
let variants = vec![
AssetLockStatus::Built,
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs
index 502d984116d..3cb375f30dd 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs
@@ -1,27 +1,46 @@
-//! BLOB-column codec helpers.
+//! BLOB-column codec helpers: thin `bincode::serde` wrappers so every
+//! `_blob` column uses one encoding path. Schema evolution is gated by the
+//! refinery migration version — no per-blob revision tag.
//!
-//! Thin error-mapping wrappers around `bincode::serde` so every
-//! `_blob` column in the SQLite schema uses one encoding path. Schema
-//! evolution is gated by the refinery migration version on the
-//! database as a whole — there is no per-blob revision tag.
-//!
-//! [`encode_outpoint`] / [`decode_outpoint`] encode a `dashcore::OutPoint`
-//! the same way — via bincode-serde — for the `outpoint` PRIMARY KEY
-//! columns (`core_utxos`, `asset_locks`). The bytes are a stable but not
-//! fixed-length key; both columns are used for exact-match PK lookups, so
-//! variable width is fine (no range scans or byte-order dependence).
+//! [`encode_outpoint`] / [`decode_outpoint`] encode `dashcore::OutPoint`
+//! the same way for the `outpoint` PK columns. The key is variable-width,
+//! which is fine for the exact-match PK lookups (no range scans).
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::sqlite::error::WalletStorageError;
-/// Hard cap on bincode-serde decode allocations. 16 MiB is two orders
-/// of magnitude above any legitimate per-row payload we ship — a
-/// hostile or corrupted backup with an inflated length prefix is
-/// rejected before the allocator wakes up. Applied symmetrically to
-/// encode + decode so we can't write a payload we'd then refuse.
-pub const BLOB_SIZE_LIMIT_BYTES: usize = 16 * 1024 * 1024;
+/// Sealed-trait machinery enforcing the no-key-material-in-DB invariant at
+/// the type level: only types opting in via [`impl_persistable_blob!`] can
+/// reach [`encode`].
+pub(crate) mod sealed {
+ /// `pub(crate)` supertrait of [`PersistableBlob`] — downstream cannot
+ /// name it, so the trait is sealed.
+ pub trait Sealed {}
+}
+
+/// Marker for types allowed into a `_blob` column. Sealed via
+/// [`sealed::Sealed`] so adding a (possibly key-bearing) type to the
+/// persistence path is an explicit, reviewable `impl` rather than a silent
+/// `T: Serialize` slip.
+pub trait PersistableBlob: Serialize + sealed::Sealed {}
+
+/// Seal-and-mark a type for [`blob::encode`](encode).
+macro_rules! impl_persistable_blob {
+ ($($t:ty),+ $(,)?) => {
+ $(
+ impl $crate::sqlite::schema::blob::sealed::Sealed for $t {}
+ impl $crate::sqlite::schema::blob::PersistableBlob for $t {}
+ )+
+ };
+}
+pub(crate) use impl_persistable_blob;
+
+/// Hard cap on bincode-serde allocations, applied symmetrically to encode +
+/// decode so a crafted length prefix can't OOM the host. Shares the crate-root
+/// [`SIZE_LIMIT_BYTES`](crate::SIZE_LIMIT_BYTES) with the KV value cap.
+pub const BLOB_SIZE_LIMIT_BYTES: usize = crate::SIZE_LIMIT_BYTES;
fn bounded_config() -> bincode::config::Configuration<
bincode::config::LittleEndian,
@@ -31,8 +50,10 @@ fn bounded_config() -> bincode::config::Configuration<
bincode::config::standard().with_limit::