diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 64cb08052c..41479b4f69 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -189,6 +189,10 @@ pub const LOAD_SKIP_REASON_DECODE_ERROR: u32 = 102; /// persisted row (wallet_id/network differ, or its account set diverges /// from the row's account manifest) — a wrong-row snapshot. pub const LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH: u32 = 103; +/// `reason_code`: a persisted account-manifest row failed its integrity +/// checksum (`SHA-256(wallet_id ‖ account_xpub_bytes)` mismatch — a row +/// bound to the wrong wallet or a blob mutated in place). +pub const LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH: u32 = 104; /// `reason_code`: an unrecognized `CorruptKind` — forward-compat /// fallback until this crate maps a newly added corrupt-row family. pub const LOAD_SKIP_REASON_CORRUPT_OTHER: u32 = 199; @@ -214,6 +218,7 @@ pub struct SkippedWalletFFI { /// [`LOAD_SKIP_REASON_MALFORMED_XPUB`] (101), /// [`LOAD_SKIP_REASON_DECODE_ERROR`] (102), /// [`LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH`] (103), + /// [`LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH`] (104), /// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199), /// [`LOAD_SKIP_REASON_OTHER`] (200), or /// [`LOAD_SKIP_REASON_ALREADY_REGISTERED`] (300). No secret material @@ -252,6 +257,7 @@ fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { CorruptKind::MalformedXpub => LOAD_SKIP_REASON_MALFORMED_XPUB, CorruptKind::SnapshotIdentityMismatch => LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH, CorruptKind::DecodeError(_) => LOAD_SKIP_REASON_DECODE_ERROR, + CorruptKind::ManifestIntegrityMismatch => LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH, // `CorruptKind` is #[non_exhaustive]; a future variant maps to a // generic corrupt-row code until this mapping is extended. _ => LOAD_SKIP_REASON_CORRUPT_OTHER, @@ -604,6 +610,7 @@ mod tests { assert_eq!(LOAD_SKIP_REASON_MALFORMED_XPUB, 101); assert_eq!(LOAD_SKIP_REASON_DECODE_ERROR, 102); assert_eq!(LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH, 103); + assert_eq!(LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH, 104); assert_eq!(LOAD_SKIP_REASON_CORRUPT_OTHER, 199); assert_eq!(LOAD_SKIP_REASON_OTHER, 200); } @@ -630,6 +637,10 @@ mod tests { skip_reason_code(&corrupt(CorruptKind::DecodeError("boom".into()))), LOAD_SKIP_REASON_DECODE_ERROR ); + assert_eq!( + skip_reason_code(&corrupt(CorruptKind::ManifestIntegrityMismatch)), + LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH + ); } #[test] diff --git a/packages/rs-platform-wallet-storage/migrations/V004__manifest_checksum.rs b/packages/rs-platform-wallet-storage/migrations/V004__manifest_checksum.rs new file mode 100644 index 0000000000..1a613a73d0 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V004__manifest_checksum.rs @@ -0,0 +1,18 @@ +//! Additive V004 migration: per-row manifest integrity checksum (#3968). +//! +//! Adds a nullable `checksum BLOB` to `account_registrations`. Each row's +//! checksum is `SHA-256(wallet_id ‖ account_xpub_bytes)`, bound by the writer +//! and by a one-time app-side backfill in `SqlitePersister::open`. SQLite has +//! no SHA-256 builtin (and no constant-literal default can carry a per-row +//! digest), so the column is added nullable at DDL time and made effectively +//! non-null at rest by that backfill; verify-at-load treats a surviving NULL +//! on a V004+ store as tamper-evidence. +//! +//! Additive-only: V001/V002/V003 stay byte-identical so refinery's +//! applied-migration checksum chain for versions 1, 2, and 3 never diverges on +//! an existing store. `max_supported_version()` lifts 3 to 4 automatically (the +//! value is derived from the embedded list's `MAX(version)`). + +pub fn migration() -> String { + "ALTER TABLE account_registrations ADD COLUMN checksum BLOB;\n".to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index f78cfd6e73..5caa092920 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -205,6 +205,16 @@ pub enum WalletStorageError { )] AccountRegistrationEntryMismatch, + /// An `account_registrations` row's stored `checksum` did not match a + /// recompute of `SHA-256(wallet_id ‖ account_xpub_bytes)` — or was NULL on + /// a V004+ store, which the `open()` backfill guarantees never survives. + /// Tamper-evidence for the Risk-6 class (a manifest row bound to the wrong + /// `wallet_id`, or a blob mutated in place). Unlike the other + /// mismatch variants this is caught at `load` and converted to a per-wallet + /// skip rather than aborting the batch. + #[error("account_registrations manifest integrity checksum mismatch")] + ManifestIntegrityMismatch, + /// An `asset_locks` row's typed-column `(outpoint, account_index)` /// disagreed with the lifecycle blob's. Rejected at decode time rather /// than mis-bucketing the lock under the wrong account. @@ -375,6 +385,7 @@ impl WalletStorageError { | Self::IdentityKeyEntryMismatch | Self::IdentityEntryIdMismatch | Self::AccountRegistrationEntryMismatch + | Self::ManifestIntegrityMismatch | Self::AssetLockEntryMismatch { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } => false, @@ -452,6 +463,7 @@ impl WalletStorageError { Self::IdentityKeyEntryMismatch => "identity_key_entry_mismatch", Self::IdentityEntryIdMismatch => "identity_entry_id_mismatch", Self::AccountRegistrationEntryMismatch => "account_registration_entry_mismatch", + Self::ManifestIntegrityMismatch => "manifest_integrity_mismatch", Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::BlobTooLarge { .. } => "blob_too_large", Self::IntegerOverflow { .. } => "integer_overflow", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 603f874779..47ee33a0b7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -9,6 +9,7 @@ use rusqlite::{Connection, OptionalExtension}; use platform_wallet::changeset::{ ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; +use platform_wallet::manager::load_outcome::{CorruptKind, SkipReason}; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::backup::{self, BackupKind}; @@ -234,6 +235,12 @@ impl SqlitePersister { let _report = crate::sqlite::migrations::run_for_open(&mut conn)?; + // SQLite cannot compute the per-row SHA-256 manifest checksum in pure + // SQL, so V004's column lands nullable and any row a forward migration + // left without one is filled here — once, before load() ever verifies + // it. Idempotent; a no-op on a store whose rows already carry one. + schema::accounts::backfill_missing_checksums(&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. @@ -928,6 +935,23 @@ impl PlatformWalletPersistence for SqlitePersister { )) })?; + // Manifest integrity is a per-wallet SKIP, not a batch abort: a + // tampered / mis-bound row fails this one wallet and records it in + // `skipped`, while every other read error below stays fail-hard. + match schema::accounts::verify_manifest_checksums(&conn, &wallet_id) { + Ok(()) => {} + Err(WalletStorageError::ManifestIntegrityMismatch) => { + state.skipped.push(( + wallet_id, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::ManifestIntegrityMismatch, + }, + )); + continue; + } + Err(other) => return Err(PersistenceError::from(other)), + } + 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) 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 dfa83b0337..dface4c130 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use key_wallet::bip32::ExtendedPubKey; use rusqlite::{params, Connection, Transaction}; +use sha2::{Digest, Sha256}; use platform_wallet::changeset::AccountRegistrationEntry; use platform_wallet::wallet::platform_wallet::WalletId; @@ -13,6 +14,23 @@ use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; +/// Per-row manifest integrity checksum: `SHA-256(wallet_id ‖ +/// account_xpub_bytes)` over the wallet id (`WalletId::as_slice`, 32 bytes) +/// concatenated with the row's stored `account_xpub_bytes` blob, byte-for-byte +/// as persisted. +/// +/// Binds `account_xpub_bytes` to its owning `wallet_id` so a row copied under a +/// different wallet, or a blob mutated in place, fails the recompute at load. +/// Deliberately excludes `meta_store_generation` and any +/// `meta_data_versions.seq`: those rotate on a legitimate restore/migrate, and +/// a restored store must never false-positive as tampered. +fn account_registration_checksum(wallet_id: &WalletId, account_xpub_bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(wallet_id.as_slice()); + hasher.update(account_xpub_bytes); + hasher.finalize().into() +} + // PUBLIC material only: the account-registration xpub manifest reaching // the `account_xpub_bytes` blob column. impl_persistable_blob!(AccountRegistrationEntry); @@ -76,7 +94,7 @@ pub(crate) fn all_platform_payment_registrations( conn: &Connection, ) -> Result>, WalletStorageError> { let mut stmt = conn.prepare( - "SELECT wallet_id, account_index, length(account_xpub_bytes), account_xpub_bytes \ + "SELECT wallet_id, account_index, length(account_xpub_bytes), account_xpub_bytes, checksum \ FROM account_registrations \ WHERE account_type = 'platform_payment' \ ORDER BY wallet_id, account_index", @@ -94,11 +112,21 @@ pub(crate) fn all_platform_payment_registrations( }); } let bytes: Vec = row.get(3)?; + let stored_checksum: Option> = row.get(4)?; let wallet_id = <[u8; 32]>::try_from(wid_bytes.as_slice()).map_err(|_| { WalletStorageError::InvalidWalletIdLength { actual: wid_bytes.len(), } })?; + // Belt-and-suspenders for the manifest integrity checksum: drop a row + // that fails the recompute so this bulk oracle scan never fail-hard + // decodes a tampered / mis-bound blob. `load()`'s per-wallet verify is + // the authoritative recorder — it flags the owning wallet as skipped. + let expected = account_registration_checksum(&wallet_id, &bytes); + match &stored_checksum { + Some(c) if c.as_slice() == expected => {} + _ => continue, + } out.entry(wallet_id) .or_default() .push(decode_platform_payment_row(idx, &bytes)?); @@ -121,11 +149,12 @@ pub fn apply_registrations( let mut stmt = tx.prepare_cached( "INSERT INTO account_registrations \ (wallet_id, account_type, account_index, key_class, \ - user_identity_id, friend_identity_id, account_xpub_bytes) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + user_identity_id, friend_identity_id, account_xpub_bytes, checksum) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ ON CONFLICT(wallet_id, account_type, account_index, key_class, \ user_identity_id, friend_identity_id) DO UPDATE SET \ - account_xpub_bytes = excluded.account_xpub_bytes", + account_xpub_bytes = excluded.account_xpub_bytes, \ + checksum = excluded.checksum", )?; for entry in entries { let account_type = account_type_db_label(&entry.account_type); @@ -133,6 +162,9 @@ pub fn apply_registrations( let key_class = account_key_class(&entry.account_type); let (user_identity_id, friend_identity_id) = account_dashpay_ids(&entry.account_type); let payload = blob::encode(entry)?; + // Binds this row's `account_xpub_bytes` to `wallet_id`; `excluded.checksum` + // in the `DO UPDATE` keeps it consistent on the rare re-persist. + let checksum = account_registration_checksum(wallet_id, &payload); stmt.execute(params![ wallet_id.as_slice(), account_type, @@ -141,6 +173,7 @@ pub fn apply_registrations( &user_identity_id[..], &friend_identity_id[..], payload, + &checksum[..], ])?; } Ok(()) @@ -206,6 +239,83 @@ pub fn load_state( Ok(out) } +/// Recompute and check the per-row manifest integrity checksum for every +/// `account_registrations` row of `wallet_id`. +/// +/// For each row, recomputes `SHA-256(wallet_id ‖ account_xpub_bytes)` and +/// compares it to the stored `checksum` column. A mismatch — or a NULL +/// checksum, which the mandatory `open()` backfill guarantees never survives +/// on a V004+ store — yields [`WalletStorageError::ManifestIntegrityMismatch`]. +/// A row copied under the wrong `wallet_id`, or a blob mutated in place, fails +/// here because `wallet_id` is part of the preimage. +/// +/// Read-only, keyless: it never decodes the blob or mints a `Wallet`, so it is +/// a cheap sibling to [`load_state`] rather than folded into it — that keeps +/// the fail-hard decode cross-check and the skip-on-tamper verify independent. +pub fn verify_manifest_checksums( + conn: &Connection, + wallet_id: &WalletId, +) -> Result<(), WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT length(account_xpub_bytes), account_xpub_bytes, checksum \ + FROM account_registrations WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get::<_, i64>(0)?)?; + let payload: Vec = row.get(1)?; + let stored: Option> = row.get(2)?; + let expected = account_registration_checksum(wallet_id, &payload); + match stored { + Some(c) if c.as_slice() == expected => {} + _ => return Err(WalletStorageError::ManifestIntegrityMismatch), + } + } + Ok(()) +} + +/// Fill the manifest `checksum` for every `account_registrations` row that +/// still carries NULL — pre-V004 rows migrated forward, which SQLite cannot +/// checksum in pure SQL (no SHA-256 builtin). Runs in its own transaction. +/// +/// Idempotent: a second pass touches nothing because the first left no NULLs, +/// and a fresh/empty store is a no-op. Returns the number of rows filled. +/// Called once from [`SqlitePersister::open`](crate::SqlitePersister) right +/// after migrations, so `load()` never sees a NULL checksum on a V004+ store. +pub fn backfill_missing_checksums(conn: &mut Connection) -> Result { + let tx = conn.transaction()?; + let pending: Vec<(i64, Vec, Vec)> = { + let mut stmt = tx.prepare( + "SELECT rowid, wallet_id, account_xpub_bytes \ + FROM account_registrations WHERE checksum IS NULL", + )?; + let mapped = stmt.query_map([], |row| { + let rowid: i64 = row.get(0)?; + let wid_bytes: Vec = row.get(1)?; + let payload: Vec = row.get(2)?; + Ok((rowid, wid_bytes, payload)) + })?; + mapped.collect::, _>>()? + }; + let mut filled = 0usize; + { + let mut upd = + tx.prepare_cached("UPDATE account_registrations SET checksum = ?1 WHERE rowid = ?2")?; + for (rowid, wid_bytes, payload) in pending { + let wallet_id = <[u8; 32]>::try_from(wid_bytes.as_slice()).map_err(|_| { + WalletStorageError::InvalidWalletIdLength { + actual: wid_bytes.len(), + } + })?; + let checksum = account_registration_checksum(&wallet_id, &payload); + upd.execute(params![&checksum[..], rowid])?; + filled += 1; + } + } + tx.commit()?; + Ok(filled) +} + /// 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 @@ -636,6 +746,129 @@ mod tests { variants } + /// Read `(account_xpub_bytes, checksum)` for the single row of `wallet_id`. + fn read_blob_and_checksum( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + ) -> (Vec, Option>) { + conn.query_row( + "SELECT account_xpub_bytes, checksum FROM account_registrations WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, Option>>(1)?)), + ) + .unwrap() + } + + /// TC-C-001 — the writer stores a non-NULL checksum equal to + /// `SHA-256(wallet_id ‖ account_xpub_bytes)` on the exact stored blob. + #[test] + fn write_stores_checksum_over_wallet_id_and_blob() { + let mut conn = migrated_conn(); + let w = [0x77u8; 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: 4, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, std::slice::from_ref(&entry)).unwrap(); + tx.commit().unwrap(); + } + let (blob, checksum) = read_blob_and_checksum(&conn, &w); + let checksum = checksum.expect("checksum must be non-NULL after a write"); + assert_eq!( + checksum.as_slice(), + account_registration_checksum(&w, &blob), + "stored checksum must equal SHA-256(wallet_id ‖ account_xpub_bytes)" + ); + // The verify path agrees on the freshly written row. + verify_manifest_checksums(&conn, &w).expect("freshly written checksum verifies"); + } + + /// TC-C-009 — re-persisting the same account keeps the checksum correct and + /// consistent with the final `account_xpub_bytes`. + #[test] + fn repersist_keeps_checksum_consistent() { + let mut conn = migrated_conn(); + let w = [0x88u8; 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: 2, + key_class: 1, + }, + account_xpub: test_xpub(), + }; + for _ in 0..2 { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, std::slice::from_ref(&entry)).unwrap(); + tx.commit().unwrap(); + } + let (blob, checksum) = read_blob_and_checksum(&conn, &w); + assert_eq!( + checksum.expect("checksum present").as_slice(), + account_registration_checksum(&w, &blob), + ); + verify_manifest_checksums(&conn, &w).expect("re-persisted checksum verifies"); + } + + /// TC-C-007 — the backfill fills every NULL checksum with the exact + /// `SHA-256(wallet_id ‖ account_xpub_bytes)`, and is idempotent (a second + /// pass fills nothing and leaves every row verifying). + #[test] + fn backfill_fills_null_checksums_exactly_and_is_idempotent() { + let mut conn = migrated_conn(); + let w = [0x99u8; 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: 1, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, std::slice::from_ref(&entry)).unwrap(); + tx.commit().unwrap(); + } + // Simulate a pre-V004 row: strip the checksum the writer just set. + conn.execute( + "UPDATE account_registrations SET checksum = NULL WHERE wallet_id = ?1", + rusqlite::params![&w[..]], + ) + .unwrap(); + assert!(read_blob_and_checksum(&conn, &w).1.is_none()); + + let filled = backfill_missing_checksums(&mut conn).unwrap(); + assert_eq!(filled, 1, "one NULL row must be filled"); + let (blob, checksum) = read_blob_and_checksum(&conn, &w); + assert_eq!( + checksum.expect("checksum filled").as_slice(), + account_registration_checksum(&w, &blob), + ); + verify_manifest_checksums(&conn, &w).expect("backfilled checksum verifies"); + + // Idempotent: nothing left to fill. + assert_eq!(backfill_missing_checksums(&mut conn).unwrap(), 0); + } + #[test] fn account_type_labels_match_enum() { let from_writer: HashSet<&'static str> = all_account_type_variants() diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 93687643b4..2d306bdd38 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -77,6 +77,12 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "accounts.rs", "SELECT account_type, account_index, key_class, user_identity_id, friend_identity_id,", ), + // Manifest-integrity read paths: verify recompute + backfill NULL scan. + ( + "accounts.rs", + "SELECT length(account_xpub_bytes), account_xpub_bytes, checksum", + ), + ("accounts.rs", "SELECT rowid, wallet_id, account_xpub_bytes"), ( "core_state.rs", "SELECT length(record_blob), record_blob FROM core_transactions", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 56c31887d7..732d906c73 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -158,6 +158,7 @@ fn samples() -> Vec { WalletStorageError::ConfigInvalid { reason: "bad knob" }, WalletStorageError::IdentityEntryIdMismatch, WalletStorageError::AccountRegistrationEntryMismatch, + WalletStorageError::ManifestIntegrityMismatch, // BincodeEncode / BincodeDecode / HashDecode / ConsensusCodec // need real upstream errors; omitted but covered by their arms. WalletStorageError::BlobDecode { @@ -254,6 +255,7 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::AccountRegistrationEntryMismatch => { (false, "account_registration_entry_mismatch") } + WalletStorageError::ManifestIntegrityMismatch => (false, "manifest_integrity_mismatch"), } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_manifest_integrity.rs b/packages/rs-platform-wallet-storage/tests/sqlite_manifest_integrity.rs new file mode 100644 index 0000000000..87d221a8c2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_manifest_integrity.rs @@ -0,0 +1,248 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Manifest integrity checksum (#3968): a tampered / mis-bound / NULL-checksum +//! `account_registrations` row becomes a per-wallet SKIP at `load()` (recorded +//! in `ClientStartState.skipped`), never a batch abort — while a clean wallet +//! in the same batch still loads. A backup restore must NOT false-positive. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::AccountType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::manager::load_outcome::{CorruptKind, SkipReason}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use rusqlite::params; + +/// A distinct, real extended public key per `seed` byte. +fn xpub_from_seed(seed: u8) -> key_wallet::bip32::ExtendedPubKey { + Wallet::from_seed_bytes( + [seed; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("wallet") + .accounts + .all_accounts() + .first() + .expect("account") + .account_xpub +} + +/// Persist one valid `platform_payment` registration under `w` (with its +/// `wallets` parent row), through the production writer so the checksum lands. +fn store_valid_manifest(persister: &SqlitePersister, w: WalletId) { + ensure_wallet_meta(persister, &w); + let manifest = vec![AccountRegistrationEntry { + account_type: AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: xpub_from_seed(w[0]), + }]; + persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: manifest, + ..Default::default() + }, + ) + .expect("store manifest"); +} + +fn is_manifest_skip(reason: &SkipReason) -> bool { + matches!( + reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::ManifestIntegrityMismatch, + } + ) +} + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") +} + +/// TC-C-002 — a valid checksum loads normally: the wallet appears in `wallets` +/// and `skipped` is empty. +#[test] +fn tc_c_002_valid_checksum_loads() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x02); + store_valid_manifest(&persister, w); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + assert!(state.wallets.contains_key(&w), "clean wallet must load"); + assert!(state.skipped.is_empty(), "no skip on a valid checksum"); + assert!( + !state.wallets[&w].account_manifest.is_empty(), + "manifest round-trips" + ); +} + +/// TC-C-003 — a blob mutated in place leaves the stored checksum stale; the +/// wallet is skipped (with `ManifestIntegrityMismatch`) and no panic occurs. +#[test] +fn tc_c_003_tampered_blob_is_skipped() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0x03); + store_valid_manifest(&persister, w); + + // Replace the blob with different BLOB-typed bytes, leaving `checksum` + // stale. `x'..'` keeps BLOB affinity (unlike `||`, which coerces to TEXT). + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE account_registrations \ + SET account_xpub_bytes = x'00112233445566778899' WHERE wallet_id = ?1", + params![w.as_slice()], + ) + .unwrap(); + } + + let state = persister + .load() + .expect("load must not error on a tampered row"); + assert!( + !state.wallets.contains_key(&w), + "tampered wallet must not load" + ); + assert_eq!(state.skipped.len(), 1); + assert_eq!(state.skipped[0].0, w); + assert!(is_manifest_skip(&state.skipped[0].1)); +} + +/// TC-C-004 (Risk-6 core) — a row copied verbatim (blob + checksum) under a +/// DIFFERENT `wallet_id` fails the recompute over the new id and is skipped, +/// while the original wallet still loads. +#[test] +fn tc_c_004_wrong_wallet_row_is_skipped() { + let (persister, _tmp, _path) = fresh_persister(); + let w1 = wid(0x41); + let w2 = wid(0x42); + store_valid_manifest(&persister, w1); + + { + let conn = persister.lock_conn_for_test(); + // w2's parent row. + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![w2.as_slice()], + ) + .unwrap(); + // Copy w1's row under w2, blob + checksum verbatim: the checksum was + // bound to w1, so recompute over w2 mismatches. + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, account_xpub_bytes, checksum) \ + SELECT ?1, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, account_xpub_bytes, checksum \ + FROM account_registrations WHERE wallet_id = ?2", + params![w2.as_slice(), w1.as_slice()], + ) + .unwrap(); + } + + let state = persister.load().expect("load"); + assert!(state.wallets.contains_key(&w1), "w1 must load"); + assert!( + !state.wallets.contains_key(&w2), + "w2 (wrong-wallet) skipped" + ); + assert_eq!(state.skipped.len(), 1); + assert_eq!(state.skipped[0].0, w2); + assert!(is_manifest_skip(&state.skipped[0].1)); +} + +/// TC-C-005 — a NULL checksum on a V004+ store is treated as corruption +/// (fail-closed) and skipped. The backfill guarantees NULLs never survive +/// `open()`, so this forces one post-open on the live connection. +#[test] +fn tc_c_005_null_checksum_is_skipped() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0x05); + store_valid_manifest(&persister, w); + + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE account_registrations SET checksum = NULL WHERE wallet_id = ?1", + params![w.as_slice()], + ) + .unwrap(); + } + + let state = persister.load().expect("load"); + assert!( + !state.wallets.contains_key(&w), + "NULL-checksum wallet skipped" + ); + assert_eq!(state.skipped.len(), 1); + assert!(is_manifest_skip(&state.skipped[0].1)); +} + +/// TC-C-006 — batch isolation: one tampered wallet + one clean wallet; the +/// clean one loads, the tampered one is skipped, the batch does not abort. +#[test] +fn tc_c_006_combined_batch_isolates_the_bad_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let clean = wid(0x61); + let bad = wid(0x62); + store_valid_manifest(&persister, clean); + store_valid_manifest(&persister, bad); + + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE account_registrations \ + SET account_xpub_bytes = x'00112233445566778899' WHERE wallet_id = ?1", + params![bad.as_slice()], + ) + .unwrap(); + } + + let state = persister.load().expect("load"); + assert!(state.wallets.contains_key(&clean), "clean wallet loads"); + assert!(!state.wallets.contains_key(&bad), "bad wallet skipped"); + assert_eq!(state.skipped.len(), 1); + assert_eq!(state.skipped[0].0, bad); + assert!(is_manifest_skip(&state.skipped[0].1)); +} + +/// TC-C-008 (lead-mandated) — a backup restore must NOT false-positive as +/// tampered. The manifest rows (blob + checksum) copy verbatim while only the +/// store generation rotates, so the checksum — which ignores the generation — +/// still verifies and the wallet loads cleanly. +#[test] +fn tc_c_008_restore_does_not_false_positive() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x08); + store_valid_manifest(&persister, w); + + let backup_path = persister.backup_to(tmp.path()).expect("backup"); + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore"); + + let p2 = reopen(&path); + let state = p2.load().expect("load after restore"); + assert!( + state.wallets.contains_key(&w), + "restored wallet must load — no false positive" + ); + assert!( + state.skipped.is_empty(), + "restore must not trip the integrity checksum (generation rotation is ignored)" + ); + drop(p2); + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index f3bfb9bc12..ef49c3559c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::migrations as mig; /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "6a073aeb0ea0a411a6fbe77732bf33c1e316024eba0920aa2105140d1172bedf"; + "022d2f294d2d8f5137045cb009048574fdd411242208e419d1690e091d4bddc2"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump deliberately only when the DDL body itself changes; an accidental /// change (a silent table rename) must fail this test, not slip through. const EXPECTED_SQL_FINGERPRINT: &str = - "c55ce79569d2bce2a1f0b7c682aa4826a46b23a379d19777068296454017234e"; + "fff65ac1e696380ea7953f431d980024cb4d79d88babd4d25a2e0a8e1f804555"; /// Table names that lost the cross-branch reconciliation and must never /// resurface as SQL identifiers on this frozen (`wallets`) baseline. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs index bc5f8734ed..35406486b9 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs @@ -51,20 +51,21 @@ impl OptionalExists for rusqlite::Result<()> { } } -/// The unified migration lifts the supported schema version to 3. +/// The embedded set tops out at V004 (manifest checksum), so +/// `max_supported_version` is 4; the V003 unified tables still land at V003. #[test] -fn max_supported_version_is_three() { +fn max_supported_version_is_four() { assert_eq!( mig::max_supported_version(), - 3, - "V003 must raise max_supported_version to 3" + 4, + "V004 must raise max_supported_version to 4" ); } -/// TC-B-030 — a fresh store migrates clean to the new target version and -/// every new table exists. +/// TC-B-030 — a fresh store migrates clean to the latest target version and +/// every unified (V003) table exists. #[test] -fn tc_b_030_fresh_store_migrates_to_version_three() { +fn tc_b_030_fresh_store_migrates_to_latest_version() { let (persister, _tmp, _path) = fresh_persister(); let conn = persister.lock_conn_for_test(); let max: i64 = conn @@ -74,7 +75,7 @@ fn tc_b_030_fresh_store_migrates_to_version_three() { |r| r.get(0), ) .unwrap(); - assert_eq!(max, 3, "fresh store must land at schema version 3"); + assert_eq!(max, 4, "fresh store must land at the latest schema version"); for table in [ "core_address_pool", "meta_data_versions", diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs index 6c284544e7..083afa1b82 100644 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -62,6 +62,12 @@ pub enum CorruptKind { /// persister. The string is a structural projection — never a raw /// row byte slice or a hex-encoded key. DecodeError(String), + /// A persisted account-manifest row failed its integrity checksum: the + /// recomputed `SHA-256(wallet_id ‖ account_xpub_bytes)` did not match the + /// stored value (a row bound to the wrong `wallet_id`, or a blob mutated in + /// place). Tamper-evidence, not authentication — a local writer can forge + /// it; the checksum only catches accidental corruption and migration bugs. + ManifestIntegrityMismatch, } impl std::fmt::Display for CorruptKind { @@ -73,6 +79,7 @@ impl std::fmt::Display for CorruptKind { f.write_str("snapshot does not match its persisted row") } Self::DecodeError(s) => write!(f, "decode error: {s}"), + Self::ManifestIntegrityMismatch => f.write_str("manifest integrity mismatch"), } } }