feat(platform-wallet): manifest integrity checksum (Risk-6/R12.5 follow-up)#3992
Conversation
Snapshot a realistic multi-wallet store — one fully-populated wallet (metadata, BIP44 registration, a tx record, an unspent UTXO at the hardcoded account_index=0, an identity, a contact) plus one bare registered-but-never-synced wallet — built by the current V001-only persister and captured via a checkpointed backup_to(). The committed .db is the regression anchor for the migration-execution suites: once V002 lands, a populated V001-only store is no longer reproducible from source. Ships an #[ignore] regenerator plus an always-run guard asserting the fixture opens at schema version 1 with the seeded rows intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
Additive V002 leaves V001 byte-identical so refinery's version-1 checksum never diverges on existing stores; max_supported_version() ticks 1->2 from the derived embedded list. V002 lands three tables in one migration event: - core_address_pool: per-index pool rows with a `used` flag, PK (wallet_id, account_index, key_class, pool_type, address_index). pool_type is in the key so External/Internal pools never collide at the same index; `address` is stored so the reader returns used addresses verbatim. - meta_data_versions: monotonic per-(wallet_id, domain) seq, no FK (soft cascade trigger reaps on wallet delete), matching the meta_* pattern. - meta_store_generation: single-row token seeded via randomblob(16) so the rendered SQL stays deterministic while the value is unique per store. No MAC column — manifest authentication is deferred (dev-plan §7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…ore them The B1 commit inadvertently staged the SQLite -wal/-shm side-files a test produced when opening the committed fixture. Remove them and ignore the pattern so future test runs never re-stage transient WAL state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…mes (WS-B B2) Add embedded_migrations_sql_fingerprint() — SHA-256 over each migration's rendered SQL body — closing the documented content-blind gap in the identity-only fingerprint. Pin both fingerprints as golden constants so an in-place DDL edit (a silent table rename under the D0 freeze) breaks CI instead of slipping through. Add the retired-name grep guard (TC-B-041): no migration/writer/reader/ backup SQL may reference wallet_metadata, account_address_pools, or core_derived_addresses. The source scan matches only SQL-keyword-led table usage so the legitimate `cs.wallet_metadata` / `cs.account_address_pools` Rust fields are never false-flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…WS-B B3) Reverse the "account_address_pools intentionally NOT applied" no-op: expand each pool snapshot into per-index core_address_pool rows (idempotent upsert, monotonic `used` via MAX so a used address never reverts — the reuse-guard invariant). Delete the CORE_UTXO_ACCOUNT_INDEX=0 constant and attribute each UTXO to its owning account by matching the outpoint's script against a pool row, falling back to account 0 only when no pool row covers it (the one-way historical default, R7). Pools are applied before core in the single flush tx so attribution reads freshly-written rows. Refines V002: the pool column stores the reconstructable `script` (renamed from `address`) with a lookup index; B1/B2 goldens updated to match. V002 is new in this PR, so editing its DDL is safe — no shipped store carries it. Covers TC-B-001/002/010/015 plus the unattributed-UTXO fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…sh tx (WS-B B4) Add the Domain enum (one variant per persisted changeset field) and bump each touched domain's seq inside apply_changeset_to_tx's single transaction, so a domain's cache-invalidation marker commits atomically with its data (TC-B-011) and a partial-failure flush rolls back both (TC-B-012). The bump saturates at i64::MAX and never wraps — a wrap would look like a cache rollback and reintroduce staleness (TC-B-014). touched_domains destructures the changeset exhaustively, so a newly added field is a compile error until it is assigned a domain (R8 keystone), and populated_field_count now derives from it — one source of truth. Every domain maps to exactly its own bump, none silently excluded (TC-B-013). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…-B B5) Add the generation getter and rotate the meta_store_generation token in restore_from after the atomic swap, so a restored copy is distinguishable from its byte-identical source (a client cache keyed on the pre-restore generation misses instead of serving stale entries). A normal flush never touches the token, so it stays stable across writes (TC-B-004); a pre-V002 backup has no generation table and is (re)seeded on its later V002 migration. Covers TC-B-004 and TC-B-024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…(WS-B B6) Redirect load()'s used_core_addresses to read core_address_pool used=1 rows verbatim (reconstructing each address from its stored script), with no horizon-walk re-derivation — so a wallet whose pool advanced past the old gap-limit-30 window restores its full used-set (TC-B-023). Fall back to the core_utxos-derived set only for a pre-pool / migrated-V001 store with no pool rows. An empty wallet loads empty-but-valid, never corrupt (TC-B-025/007). Covers TC-B-020/023/025 plus the pre-pool fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…s (WS-B B7) The persister now expands account_address_pools into per-index core_address_pool rows (used state + owning-account attribution) and the reader restores the used-set from them verbatim; the field doc said storage ignored it. rehydrate consumes the verbatim used_core_addresses snapshot through the existing (kept) horizon-walk path with fallback + fail-closed SkipReason shapes unchanged — full horizon-walk deletion is Workstream E. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…re (WS-B B8) Drive the populated-V001 fixture through the post-redirect binary: migration preserves every row and backfills nothing destructively (pre-existing UTXOs keep account_index=0, the R7 one-way default) while the new V002 tables land with sane defaults (TC-B-031); the empty wallet migrates without a NOT NULL violation and reads empty-but-valid (TC-B-036); a byte-faithful pre-migration auto-backup is written at the V001 state (TC-B-032); that backup restores and re-migrates deterministically to the same end state (TC-B-033); the forward-version gate rejects at the new max of 2 (TC-B-034); and reopening a migrated store is idempotent (TC-B-035). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…t (WS-B B9) Close the workstream: FFI needs no change (no new SkipReason/CorruptKind variant crossed the boundary, rs-platform-wallet-ffi compiles clean) and both crates pass clippy --all-targets -D warnings and their full test suites. Fixes surfaced by the gates: - touched_domains uses a named-fields + `..` destructure so clippy's unexpected_cfgs no longer trips on the feature-gated `shielded` field and Cargo feature-unification can't make the pattern non-exhaustive. - Reword `seed`-substring doc comments in versions.rs / V002 that the schema secrets-scan flags (SQL body unchanged — fingerprints stable). - Allow-list the pool reader's read-only `SELECT DISTINCT script` in the prepared-statement writer check (readers use plain `prepare`). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
… (QA-001) The trailing `..` in `touched_domains` silently absorbed any newly added changeset field, disarming the R8 forgotten-domain guard tc_b_013 claims to enforce. Declare a pass-through `shielded` feature so the feature-gated `PlatformWalletChangeSet::shielded` field is visible, then restore the exhaustive destructure with a cfg-gated `shielded` binding and drop `..`. An added always-on field is now a genuine compile error. Compiles clean under both default and `--features shielded`. Corrected the stale code/test comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…QA-002, QA-005) The reader previously *replaced* the `core_utxos`-derived used-set with the `core_address_pool` set whenever any pool row existed, so a mixed store — a historical UTXO address a later partial pool snapshot never enumerates — could silently drop that address from the reuse guard (address-reuse / funds-safety hazard). `core_pool::load_used_addresses` now returns a plain `Vec` and `load()` unions it with the UTXO-derived set, deduped by script. Added a mixed-store regression test and an explicit two-wallet no-leakage test (TC-B-026). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…QA-003) TC-B-035 previously only reopened a fully-migrated fixture twice, never exercising an interrupted migration. Replaced with a test that applies part of V002's DDL inside a rolled-back transaction (modelling a crash before the migration's single COMMIT), asserts the store stays at V001 with no partial table, then re-opens and asserts byte-equal convergence with a clean direct migration — empirically demonstrating refinery's per-migration transaction guarantee. Kept a separate reopen-idempotency test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…2 tables (QA-004) No test covered multi-wallet behaviour of the new `core_address_pool` / `meta_data_versions` tables. Add TC-B-006: two wallets with fully-overlapping keys coexist without PK collision or cross-wallet read leakage, and deleting one cascades away only its rows (FK ON DELETE CASCADE for the pool, the soft-cascade trigger for versions) while the other's survive intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
… docs (QA-006) Stripped the transient "WS-B task BN" / "BN" plan-task tags from test module docs, the fixture regenerator, and the V002 header. Kept the durable TC-B-NNN spec IDs and the #3968 PR reference. V002's `migration()` SQL body is untouched, so the pinned content fingerprint is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…8-snapshot-redirect
…_registrations
Add a per-row SHA-256(wallet_id ‖ account_xpub_bytes) integrity checksum to
the account_registrations manifest, verified at load() as a per-wallet SKIP
rather than a batch abort. Catches accidental corruption, migration errors,
and the Risk-6 "manifest row bound to the wrong wallet_id" class without
false-positiving on a backup restore (checksum ignores the store generation).
- V003 additive migration: ALTER TABLE account_registrations ADD COLUMN
checksum BLOB (nullable; SQLite has no SHA-256 builtin). V001/V002 stay
byte-identical; fingerprint goldens re-pinned for V003.
- Writer: apply_registrations computes + binds the checksum in the existing
single flush transaction (INSERT + DO UPDATE SET checksum = excluded).
- open(): one-time idempotent backfill fills any NULL checksum before load()
ever verifies (own short tx).
- load(): verify_manifest_checksums per wallet; a mismatch/NULL pushes
SkipReason::CorruptPersistedRow{ ManifestIntegrityMismatch } and continues.
The bulk platform_payment oracle also drops checksum-failing rows so it
never fail-hard decodes a tampered blob (G6 belt-and-suspenders).
- Typed WalletStorageError::ManifestIntegrityMismatch and
CorruptKind::ManifestIntegrityMismatch (+Display); FFI reason code 103.
- Tests TC-C-001..011: write, valid load, tampered/wrong-wallet/NULL skips,
combined-batch isolation, backfill exactness/idempotency, restore
no-false-positive, re-persist consistency, FFI mapping, fingerprint goldens.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts: # packages/rs-platform-wallet-ffi/src/manager.rs
…hot-redirect merge Integrate against origin/feat/3968-snapshot-redirect (WS-A #3980/#3984): - FFI: renumber LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH 103 -> 104; 103 is now WS-A's LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH. Both CorruptKind variants and their mappings coexist. - load(): populate the new ClientWalletStartState.core_wallet_info field as None — the SQLite persister rehydrates via the manifest skeleton + core projection; the full snapshot path is the FFI/iOS persister's. - Update the V002 migration-execution tests for the V003 bump: migrated version 2 -> 3, pre-migration backup name 1-to-2 -> 1-to-3, and move the forward-version rejection probe from version 3 to 4. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ydration The rehydration merge added a `core_wallet_info` field to `ClientWalletStartState`; the SQLite persister replays the keyless projection onto a fresh skeleton, so it mints no full snapshot and sets the field to `None`. Without it the crate no longer compiles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three defects in the core address-pool read/write path:
- account_type PK (CRITICAL): several AccountType variants collapse to the
same (account_index=0, key_class=0) sentinel (IdentityRegistration,
ProviderVotingKeys, Standard{0}, CoinJoin{0}, ...). With those the only PK
discriminators, two such accounts upserted onto one core_address_pool row
and silently overwrote each other's script / merged used flags. Widen the
PK with an account_type TEXT column, reusing accounts::account_type_db_label
(the same discriminator account_registrations already uses).
- deterministic script lookup: account_index_for_script used LIMIT 1 with no
ORDER BY, so a script shared by several pool rows resolved to an arbitrary
account. Add a PK-ordered tie-break (account_type first).
- blob-size gate: core_pool and core_state load_used_addresses materialized
script blobs with no size gate, unlike every other blob reader. Gate the
largest stored script with a cheap MAX(length(script)) aggregate before the
DISTINCT/ORDER BY read, so a corrupt oversize column raises a typed
BlobTooLarge instead of SQLite's own TooBig (core_utxos has no script index
to stream by) and never OOMs the host.
Re-pins the content-level migration-SQL fingerprint (expected churn for the
unshipped V002 DDL; the identity fingerprint is unchanged).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…swap restore_from rotated the store-generation token on a fresh connection AFTER the atomic rename (the commit point). An Err from the post-rename steps was ambiguous — the restore had already happened — and left a window where restored content was observable carrying the source's stale token. Fold the regeneration into the staged temp before the rename: switch the staged DB to DELETE journaling (so the UPDATE lands in the main file with no -wal frames stranded outside the rename), regenerate, fsync, then persist. The single commit point now swaps in the restored bytes and the fresh token together. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…egrity-checksum Fold the four snapshot-redirect follow-ups (core_address_pool account_type PK widening, deterministic account_index_for_script ORDER BY, blob-size gate on core_pool/core_state load_used_addresses, restore_from generation rotation folded into the atomic swap) onto the V003 manifest-checksum work. Reconciliations: - persister.rs: kept the concise upstream core_wallet_info: None comment. - sqlite_schema_pinning.rs: recomputed EXPECTED_SQL_FINGERPRINT for the combined V001 + V002(widened account_type PK) + V003(checksum column) migration set -> e9c1679ba10d60b03923f20825cd41a6caaee2e186c239e138d8f6799f5659a7. EXPECTED_ID_FINGERPRINT unchanged (no migration file added/removed/renamed by the widening; V003 already accounted for on this branch). account_registrations checksum reader/writer is confined to that table and does not touch the widened core_address_pool PK. Full platform-wallet-storage, platform-wallet, platform-wallet-ffi suites green; fmt + clippy clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit 69d85dd) |
There was a problem hiding this comment.
Code Review
PR #3992 adds a well-scoped, additive SHA-256 integrity checksum over SHA-256(wallet_id ‖ account_xpub_bytes) to account_registrations, with an idempotent V003 backfill and per-wallet skip semantics on mismatch. Design and test coverage are solid (Risk-6 wrong-wallet-row, NULL-checksum skip, batch isolation, restore non-false-positive). Three in-scope suggestions worth addressing: (1) verify_manifest_checksums still calls blob::check_size before the checksum compare, so an oversized tampered blob returns BlobTooLarge and aborts the whole batch instead of falling into the per-wallet skip path the PR advertises; (2) backfill_missing_checksums does not size-gate account_xpub_bytes before materializing it in memory, unlike every other reader touching this column in this PR; (3) the Swift-side reasonDescription wire-code mirror was not extended for the new code 104.
Source: reviewers opus/opus, sonnet5/claude-sonnet-5, codex/gpt-5.5[high] for general + security-auditor + rust-quality + ffi-engineer; verifier opus/opus. Failed lanes: codex/gpt-5.5[high] general, codex/gpt-5.5[high] security-auditor, codex/gpt-5.5[high] rust-quality, codex/gpt-5.5[high] ffi-engineer.
🟡 3 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:259-275: verify_manifest_checksums fails hard on oversize blob, breaking the per-wallet skip contract for exactly the tamper class it defends against
`verify_manifest_checksums` calls `blob::check_size(row.get::<_, i64>(0)?)?` at line 265 *before* the SHA-256 comparison. If a tampered `account_xpub_bytes` also happens to exceed `BLOB_SIZE_LIMIT_BYTES`, this returns `WalletStorageError::BlobTooLarge` — which the caller in `persister.rs::load()` (line 952) routes through `Err(other) => return Err(PersistenceError::from(other))`, aborting the entire load batch. That contradicts the PR-headline invariant that a tampered / mis-bound row skips one wallet and never aborts a multi-wallet load, and it defeats it in exactly the corruption class this checksum exists to catch (in-place blob mutation, cross-wallet row copy). Because the writer's own size gate keeps this unreachable on legitimate rows, any hit here is by construction a manifest-integrity event. Either treat `BlobTooLarge` from this function as `ManifestIntegrityMismatch`, or drop the size gate here and let the checksum recompute be the sole verdict (a mismatched-length blob will fail the SHA-256 compare anyway). The pre-existing fail-hard in `load_state` predates this PR and is out of scope; this function is new and is where the skip contract lives.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:285-317: backfill_missing_checksums reads account_xpub_bytes without the blob-size gate used everywhere else in this PR
`backfill_missing_checksums` runs on every `SqlitePersister::open()` and materializes `(rowid, wallet_id, account_xpub_bytes)` for every NULL-checksum row into a single `Vec` via `.collect()`. Unlike every other reader of this same column in this PR — `verify_manifest_checksums` (line 265: `blob::check_size(row.get::<_, i64>(0)?)?`), `load_state` (line 212), and `all_platform_payment_registrations` (lines 107-113) — this new function never calls `blob::check_size` or checks `length(account_xpub_bytes)` before reading. This matters because the backfill's own threat model is a legacy or corrupted store: a row with `checksum = NULL` and an oversized `account_xpub_bytes` blob (SQLite BLOBs can be ~2GB) would be read in full into memory on every `open()`, before any checksum verification runs. Exploitation requires an already-corrupted DB on disk (matching this PR's stated Risk-6 threat model), so it is defense-in-depth rather than remotely triggerable, but the inconsistency with the size gate that appears on every other reader of the same column in this same change is worth closing. Select `length(account_xpub_bytes)` alongside the payload and reject via `blob::check_size` before decoding, matching the pattern in the sibling functions.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:344-354: Swift-side wire-code mirror not updated for new LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH (104)
This PR adds `LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH = 104` (rs-platform-wallet-ffi/src/manager.rs:193) and routes `CorruptKind::ManifestIntegrityMismatch` to it (manager.rs:249). The Swift binding's `SkippedWalletOnLoad.reasonDescription` (this file, lines 344-354) is the documented mirror of that wire contract — its own doc comment says the cases are matched by value against `rs-platform-wallet-ffi/src/manager.rs`. That switch was not extended with `case 104`, so any wallet skipped for the new manifest-integrity reason — the exact case this PR exists to surface — falls through to `default: return "unknown skip reason (104)"` instead of a meaningful description. The raw `reasonCode` is still delivered via `SkippedWalletOnLoad.reasonCode`, so no data is lost, but the SDK's own decoding helper silently degrades the PR's headline new signal. The struct-level doc comment at lines 329-334 also enumerates codes only up to 103 and should be updated in the same change.
| 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<u8> = row.get(1)?; | ||
| let stored: Option<Vec<u8>> = 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(()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: verify_manifest_checksums fails hard on oversize blob, breaking the per-wallet skip contract for exactly the tamper class it defends against
verify_manifest_checksums calls blob::check_size(row.get::<_, i64>(0)?)? at line 265 before the SHA-256 comparison. If a tampered account_xpub_bytes also happens to exceed BLOB_SIZE_LIMIT_BYTES, this returns WalletStorageError::BlobTooLarge — which the caller in persister.rs::load() (line 952) routes through Err(other) => return Err(PersistenceError::from(other)), aborting the entire load batch. That contradicts the PR-headline invariant that a tampered / mis-bound row skips one wallet and never aborts a multi-wallet load, and it defeats it in exactly the corruption class this checksum exists to catch (in-place blob mutation, cross-wallet row copy). Because the writer's own size gate keeps this unreachable on legitimate rows, any hit here is by construction a manifest-integrity event. Either treat BlobTooLarge from this function as ManifestIntegrityMismatch, or drop the size gate here and let the checksum recompute be the sole verdict (a mismatched-length blob will fail the SHA-256 compare anyway). The pre-existing fail-hard in load_state predates this PR and is out of scope; this function is new and is where the skip contract lives.
source: ['claude']
| pub fn backfill_missing_checksums(conn: &mut Connection) -> Result<usize, WalletStorageError> { | ||
| let tx = conn.transaction()?; | ||
| let pending: Vec<(i64, Vec<u8>, Vec<u8>)> = { | ||
| 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<u8> = row.get(1)?; | ||
| let payload: Vec<u8> = row.get(2)?; | ||
| Ok((rowid, wid_bytes, payload)) | ||
| })?; | ||
| mapped.collect::<Result<Vec<_>, _>>()? | ||
| }; | ||
| 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) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: backfill_missing_checksums reads account_xpub_bytes without the blob-size gate used everywhere else in this PR
backfill_missing_checksums runs on every SqlitePersister::open() and materializes (rowid, wallet_id, account_xpub_bytes) for every NULL-checksum row into a single Vec via .collect(). Unlike every other reader of this same column in this PR — verify_manifest_checksums (line 265: blob::check_size(row.get::<_, i64>(0)?)?), load_state (line 212), and all_platform_payment_registrations (lines 107-113) — this new function never calls blob::check_size or checks length(account_xpub_bytes) before reading. This matters because the backfill's own threat model is a legacy or corrupted store: a row with checksum = NULL and an oversized account_xpub_bytes blob (SQLite BLOBs can be ~2GB) would be read in full into memory on every open(), before any checksum verification runs. Exploitation requires an already-corrupted DB on disk (matching this PR's stated Risk-6 threat model), so it is defense-in-depth rather than remotely triggerable, but the inconsistency with the size gate that appears on every other reader of the same column in this same change is worth closing. Select length(account_xpub_bytes) alongside the payload and reject via blob::check_size before decoding, matching the pattern in the sibling functions.
source: ['claude']
…-manifest-integrity-checksum Re-merge against base tip 4624cec, which resequenced the V002 collision (V002__unified → V003__unified, keeping V002__address_height_pin at V002) and fixed T5 (DashPay core_address_pool PK collision). Manifest checksum moves to V004 accordingly. Migration ordering (final): V001__initial → V002__address_height_pin → V003__unified → V004__manifest_checksum. Fresh store migrates clean through V004 with no refinery version collision (verified by sqlite_v003_migration + sqlite_migrations). Migration file reconciliation: - Dropped our superseded V002__unified.rs; kept base's V003__unified.rs (same migration, version-bumped + T5 identity-pair PK on core_address_pool). - Renamed V003__manifest_checksum.rs → V004__manifest_checksum.rs; bumped its doc comment and every checksum-specific "V003"→"V004" ref (error.rs, persister.rs backfill comment, accounts.rs verify/backfill docs + test). - Deleted stale duplicate tests sqlite_v002_migration.rs / sqlite_v002_isolation.rs (superseded by base's renamed sqlite_v003_* with the T5 PK). - Bumped the global-max assertions in sqlite_v003_migration.rs to 4 (max_supported_version, fresh-store MAX). Generation/unified refs stay V003. Conflict resolutions: - manager.rs: merged LOAD_SKIP_REASON_* doc list (104 + 300 both listed). - backup.rs / versions.rs generation comments: took base (V003 generation table). - persister.rs: took base rehydration (core_wallet_info + pending_contact_crypto); our verify_manifest_checksums() skip path preserved (merged clean). - versions.rs: took base PendingContactCrypto Domain (R8 destructure stays exhaustive). - core_pool.rs + T5 tests (sqlite_core_pool_writer, fixture_gen, version_bump, store_generation): took base (identity-pair PK, DashPay collision test). - schema_pinning.rs: recomputed goldens for the V004 set (id 022d2f29…, sql fff65ac1…). Verification: production build compiles (platform-wallet-storage, platform-wallet). All storage test targets compile and pass EXCEPT the two known-broken deferred files below. Pre-existing base-branch breakage — surfaced, NOT fixed (per scope): - sqlite_migration_execution.rs, sqlite_pool_reader.rs reference the removed ClientWalletStartState.used_core_addresses field and do not compile. Same two files as before; needs porting to core_wallet_info. These deferred files also carry stale max=3 assertions that will need bumping to 4 when rewritten. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
There was a problem hiding this comment.
Code Review
Incremental review at 2020cf3 covering the 579b620→2020cf3 delta, which only renumbered the migration V003→V004 to sequence after the #3986 V002/V003 collision fix and did not touch the checksum verify/backfill functions, error routing, or Swift decoder. All three prior findings remain valid at head. Carried-forward prior findings: (1) verify_manifest_checksums fail-hards on oversize blob and breaks the advertised per-wallet skip contract; (2) backfill_missing_checksums lacks the size gate every other reader of account_xpub_bytes applies in this PR; (3) Swift SkippedWalletOnLoad.reasonDescription is missing case 104. New latest-delta findings: none introduced by the delta itself, though verification surfaced one additional in-scope gap in the same skip-contract system — all_platform_payment_registrations still returns BlobTooLarge before its belt-and-suspenders checksum-skip logic, so an oversized tampered platform_payment row aborts the load before verify_manifest_checksums can record the wallet as skipped.
Source: reviewers claude/opus general, codex/gpt-5.5 general, claude/opus security-auditor, codex/gpt-5.5 security-auditor, claude/opus rust-quality, codex/gpt-5.5 rust-quality, claude/opus ffi-engineer, codex/gpt-5.5 ffi-engineer; verifier claude/opus.
🟡 4 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:415-441: Carried-forward prior-3: Swift wire-code mirror still missing case 104 for LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH
STILL_VALID at 2020cf329a. Rust defines LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH = 104 in rs-platform-wallet-ffi/src/manager.rs:195, routes CorruptKind::ManifestIntegrityMismatch to it at :260, and pins the value as ABI-stable in load_skip_reason_wire_values_are_stable. The Swift mirror at PlatformWalletManager.swift:430-441 was extended in this PR for `case 300: "already registered"` but the 104 case was never added, so a wallet skipped for the exact new manifest-integrity reason this PR ships falls through to `default: "unknown skip reason (104)"`. The struct-level doc comment at lines 415-420 also still enumerates only 100/101/102/103/199/200/300 and needs 104 in the same edit. The raw reasonCode is still delivered on SkippedWalletOnLoad.reasonCode so no data crosses the boundary corrupt, but the SDK's own decoding helper silently degrades the PR's headline new signal.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:255-275: Carried-forward prior-1: verify_manifest_checksums fail-hards on oversize blob, breaking the per-wallet skip contract
STILL_VALID at 2020cf329a; unchanged by the V003→V004 rename delta. Line 265 calls `blob::check_size(row.get::<_, i64>(0)?)?` before the SHA-256 recompute. If a tampered account_xpub_bytes exceeds BLOB_SIZE_LIMIT_BYTES, this returns WalletStorageError::BlobTooLarge; persister.rs:952 (`Err(other) => return Err(PersistenceError::from(other))`) routes any non-ManifestIntegrityMismatch error as a hard batch abort, contradicting the explicit invariant documented three lines above at persister.rs:938 (`Manifest integrity is a per-wallet SKIP, not a batch abort`). Because apply_registrations enforces the size limit on write, any oversized blob reaching this function is by construction a manifest-integrity event — precisely the tamper class (in-place blob mutation, cross-wallet row copy) the checksum exists to catch. Either translate BlobTooLarge from this function into ManifestIntegrityMismatch, or drop the pre-compare size gate here and let the SHA-256 recompute be the sole verdict (a mismatched-length blob fails the equality compare anyway).
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:285-317: Carried-forward prior-2: backfill_missing_checksums reads account_xpub_bytes without the blob-size gate used everywhere else in this PR
STILL_VALID at 2020cf329a; unchanged by the delta. backfill_missing_checksums runs on every SqlitePersister::open() and materializes (rowid, wallet_id, account_xpub_bytes) for every NULL-checksum row into a single Vec via .collect(). Unlike every other reader of the same column in this PR — verify_manifest_checksums (line 265), load_state (line 212), all_platform_payment_registrations (lines 107-113) — this new function never selects length(account_xpub_bytes) and never calls blob::check_size before materialization. A pre-V004 row with checksum=NULL and an oversized account_xpub_bytes (SQLite BLOBs can reach ~2GB) would be read in full into memory on every open() before any checksum verification runs, and all such rows are held simultaneously in the pending Vec. Exploitation requires an already-corrupted DB on disk (the PR's stated Risk-6 threat model), so this is defense-in-depth rather than remotely triggerable, but the inconsistency with the size gate every sibling reader applies is worth closing. Select length(account_xpub_bytes) alongside the payload and reject via blob::check_size before decoding.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:104-134: Bulk platform-payment oracle still aborts load on an oversized tampered row before per-wallet skip runs
all_platform_payment_registrations is called from platform_addrs::load_all at the top of persister.rs::load(), before the per-wallet verify_manifest_checksums loop. The function already added a belt-and-suspenders checksum-skip at lines 121-129 (`match &stored_checksum { Some(c) if c.as_slice() == expected => {} _ => continue }`) — comment on lines 121-124 explicitly notes this bulk oracle 'never fail-hard decodes a tampered / mis-bound blob'. But that skip logic sits *after* the size gate at lines 107-113, which returns BlobTooLarge for an oversized blob before the checksum comparison ever runs. So a corrupted platform_payment row with an oversized blob still aborts the entire ClientStartState load before the authoritative per-wallet verifier records ManifestIntegrityMismatch in skipped, defeating the same skip-not-batch-abort contract the function's own comment claims to uphold. Either move the size gate below the checksum skip (so a checksum-mismatched row is `continue`d before the size check), or convert BlobTooLarge here into the same continue path — mirroring the fix suggested for verify_manifest_checksums.
| 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<u8> = row.get(1)?; | ||
| let stored: Option<Vec<u8>> = 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(()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Carried-forward prior-1: verify_manifest_checksums fail-hards on oversize blob, breaking the per-wallet skip contract
STILL_VALID at 2020cf3; unchanged by the V003→V004 rename delta. Line 265 calls blob::check_size(row.get::<_, i64>(0)?)? before the SHA-256 recompute. If a tampered account_xpub_bytes exceeds BLOB_SIZE_LIMIT_BYTES, this returns WalletStorageError::BlobTooLarge; persister.rs:952 (Err(other) => return Err(PersistenceError::from(other))) routes any non-ManifestIntegrityMismatch error as a hard batch abort, contradicting the explicit invariant documented three lines above at persister.rs:938 (Manifest integrity is a per-wallet SKIP, not a batch abort). Because apply_registrations enforces the size limit on write, any oversized blob reaching this function is by construction a manifest-integrity event — precisely the tamper class (in-place blob mutation, cross-wallet row copy) the checksum exists to catch. Either translate BlobTooLarge from this function into ManifestIntegrityMismatch, or drop the pre-compare size gate here and let the SHA-256 recompute be the sole verdict (a mismatched-length blob fails the equality compare anyway).
source: ['claude', 'codex']
There was a problem hiding this comment.
Correction: the automated reconciliation reply above was wrong for commit 5914e23f. This finding is still valid at that SHA and is carried forward in the exact-SHA review: verify_manifest_checksums still size-checks before converting checksum mismatch into a per-wallet skip. The thread should remain open.
…8-manifest-integrity-checksum
…8-manifest-integrity-checksum
There was a problem hiding this comment.
Code Review
Cumulative incremental review of PR #3992 at commit 5914e23f. The latest delta from 2020cf32 rewrites migration/pool-reader tests plus a small persister TODO; the production code touched by the previous findings was not changed. All four prior findings from the 2020cf32 review are still valid and are carried forward. One new latest-delta blocker was verified in sqlite_migration_execution.rs, where the rewritten test suite still pins schema version 3 even though this PR now embeds V004.
Reviewed commit: 5914e23f (5914e23fcbce4f055295fcf9d7f5be374818c1ff); prior reviewed commit: 2020cf32.
Source: reviewers claude general opus, codex general gpt-5.5, claude security-auditor opus, codex security-auditor gpt-5.5, claude rust-quality opus, codex rust-quality gpt-5.5, claude ffi-engineer opus, codex ffi-engineer gpt-5.5; verifier claude opus.
2 blocking | 3 suggestion(s)
Prior Findings Reconciliation
- STILL VALID / carried forward: prior-1
verify_manifest_checksumsfail-hards on oversized blobs before the checksum mismatch can become a per-wallet skip. - STILL VALID / carried forward: prior-2
backfill_missing_checksumsstill materializesaccount_xpub_byteswithout the blob-size gate used by sibling readers. - STILL VALID / carried forward: prior-3 Swift
SkippedWalletOnLoad.reasonDescriptionstill lacks wire code 104 for manifest-integrity mismatch. - STILL VALID / carried forward: prior-4
all_platform_payment_registrationsstill size-gates before its checksum-skip path, so an oversized tampered platform-payment row aborts load early.
Carried-Forward Prior Findings
- BLOCKING
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:415-441: Carried-forward (STILL VALID): Swift SkippedWalletOnLoad decoder omits case 104 — the exact wire code this PR ships - SUGGESTION
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:255-275: Carried-forward (STILL VALID): verify_manifest_checksums fail-hards on oversized blob, breaking the per-wallet skip contract - SUGGESTION
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:93-135: Carried-forward (STILL VALID): bulk platform-payment oracle still aborts load on oversized tampered row before per-wallet skip runs - SUGGESTION
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:285-317: Carried-forward (STILL VALID): backfill_missing_checksums reads account_xpub_bytes without the blob-size gate every sibling reader applies
New Findings In Latest Delta
- BLOCKING
packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs:90-370: New latest-delta: migration execution test suite pins schema version 3 but this PR embeds V004
Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:415-441: Carried-forward (STILL VALID): Swift SkippedWalletOnLoad decoder omits case 104 — the exact wire code this PR ships
Rust defines LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH = 104 in rs-platform-wallet-ffi/src/manager.rs:195, routes CorruptKind::ManifestIntegrityMismatch to it at :260, and pins the value as ABI-stable in the wire-values test at :613. The Swift mirror at PlatformWalletManager.swift:430-441 still switches over only 100/101/102/103/199/200/300, so a wallet skipped for this PR's headline new signal renders as `unknown skip reason (104)`. The struct-level doc comment at 415-420 also enumerates the same set without 104 and needs the same edit. Raw reasonCode still crosses the boundary intact, so this is a diagnostic-degradation not a data-corruption bug, but the SDK's own decoding helper silently loses the PR's shipping signal — verified at head 5914e23f (file untouched by the latest delta).
In `packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs:90-370: New latest-delta: migration execution test suite pins schema version 3 but this PR embeds V004
sqlite_migration_execution.rs was rewritten in the latest delta but still assumes the migration graph tops out at V003. Concretely: line 90 asserts `schema_version(conn) == 3` after `SqlitePersister::open`, lines 223 and 268 look for backup filenames `pre-migration-1-to-3-*`, line 308 asserts `max_supported == 3`, line 370 asserts `clean_snapshot[0] == 3` post-migration, and line 297/307 treat version 4 as a forged forward version. But this PR embeds `migrations/V004__manifest_checksum.rs`, and `sqlite_v003_migration.rs::max_supported_version_is_four` asserts `mig::max_supported_version() == 4`. `SqlitePersister::open` runs all pending migrations, so a V001 fixture ends at schema version 4, not 3, and the pre-migration backup name embeds the target version. Every one of these assertions will fail against the current binary, and the forward-version rejection test (tc_b_034) no longer tests a forward version — it inserts the actual current max as if it were the future. The migration/backup regression suite this file exists to be no longer describes the crate's own migration graph and no longer validates the V004 path it should be protecting.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:255-275: Carried-forward (STILL VALID): verify_manifest_checksums fail-hards on oversized blob, breaking the per-wallet skip contract
Line 265 calls `blob::check_size(row.get::<_, i64>(0)?)?` before the SHA-256 recompute. If a tampered `account_xpub_bytes` exceeds `BLOB_SIZE_LIMIT_BYTES`, this returns `WalletStorageError::BlobTooLarge`; the caller in persister.rs::load only maps `ManifestIntegrityMismatch` to a per-wallet skip and treats any other error as a whole-load abort. That contradicts the invariant the docblock three lines above states (`manifest integrity is a per-wallet SKIP, not a batch abort`). Because `apply_registrations` enforces the same size limit at write time, an oversized blob reaching this verifier is by construction the manifest-integrity tamper class (in-place blob mutation or cross-wallet row copy) the checksum exists to catch. Either translate `BlobTooLarge` from this function into `ManifestIntegrityMismatch`, or drop the pre-compare size gate here and let the SHA-256 recompute (which is constant-memory over the stream and fails immediately for a mismatched length) be the sole verdict. Verified STILL VALID at head 5914e23f (accounts.rs untouched by the latest delta).
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:93-135: Carried-forward (STILL VALID): bulk platform-payment oracle still aborts load on oversized tampered row before per-wallet skip runs
`all_platform_payment_registrations` is called from `platform_addrs::load_all` before the per-wallet `verify_manifest_checksums` loop. The belt-and-suspenders checksum-skip added at lines 121-129 (`match &stored_checksum { Some(c) if c.as_slice() == expected => {} _ => continue }`) and the comment immediately above it (`this bulk oracle scan never fail-hard decodes a tampered / mis-bound blob`) claim per-wallet-skip semantics, but the size gate at lines 107-113 sits ahead of that skip and returns `BlobTooLarge` for an oversized blob before the checksum comparison runs. A corrupted `platform_payment` row with an oversized blob therefore aborts the entire `ClientStartState` load before the authoritative per-wallet verifier records `ManifestIntegrityMismatch` in skipped, defeating the same skip-not-batch-abort contract the function's own comment asserts. Either move the size gate below the checksum-skip so a checksum-mismatched row is `continue`d before the size check, or convert `BlobTooLarge` here into the same `continue` path. Verified STILL VALID at head 5914e23f (function untouched by the latest delta).
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:285-317: Carried-forward (STILL VALID): backfill_missing_checksums reads account_xpub_bytes without the blob-size gate every sibling reader applies
`backfill_missing_checksums` runs on every `SqlitePersister::open()` and materializes `(rowid, wallet_id, account_xpub_bytes)` for every NULL-checksum row into a single `Vec` via `.collect::<Result<Vec<_>, _>>()?`. Unlike every other reader of the same column in this PR — `verify_manifest_checksums` (line 265), `load_state` (line 212), and `all_platform_payment_registrations` (lines 107-113) — this function never selects `length(account_xpub_bytes)` and never calls `blob::check_size` before materialization. A pre-V004 row with `checksum = NULL` and an oversized `account_xpub_bytes` (SQLite BLOBs reach ~2GB) is read in full on every open before any checksum verification runs, and all such rows are held simultaneously in the pending Vec. Exploitation requires an already-corrupted DB on disk (this PR's stated Risk-6 threat model), so this is defense-in-depth rather than remotely triggerable, but the inconsistency with the size discipline every sibling reader applies is worth closing. Select `length(account_xpub_bytes)` alongside the payload and reject via `blob::check_size` before decoding. Verified STILL VALID at head 5914e23f (function untouched by the latest delta).
There was a problem hiding this comment.
Code Review
Cumulative review at head 69d85ddb (69d85ddbd8502da2c01f85411b4fde7cfd3418bb). The latest delta from 5914e23f is Swift SDK integration-test infrastructure plus a DASH_KEYCHAIN_SERVICE test hook/env override merged from #3712; it does not touch the manifest-checksum Rust storage, FFI skip-code, Swift skip decoder, or migration execution test assumptions. All five prior findings from the 5914e23f review are STILL VALID and carried forward. No new latest-delta findings were verified.
Source: reviewers claude/opus general, codex/gpt-5.5 general, claude/opus security-auditor, codex/gpt-5.5 security-auditor, claude/opus rust-quality, codex/gpt-5.5 rust-quality, claude/opus ffi-engineer, codex/gpt-5.5 ffi-engineer; verifier claude/opus.
2 blocking | 3 suggestion(s)
Prior Findings Reconciliation
- STILL VALID / carried forward: prior-1
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:415-441- Swift SkippedWalletOnLoad decoder omits case 104 — the exact wire code this PR ships - STILL VALID / carried forward: prior-2
packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs:88-370- migration_execution suite pins schema V003 while this PR embeds V004 - STILL VALID / carried forward: prior-3
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:255-275- verify_manifest_checksums fail-hards on oversized blob, breaking the per-wallet skip contract - STILL VALID / carried forward: prior-4
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:93-135- bulk platform-payment oracle aborts load on oversized tampered row before per-wallet skip runs - STILL VALID / carried forward: prior-5
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:285-317- backfill_missing_checksums reads account_xpub_bytes without the blob-size gate every sibling reader applies
Carried-Forward Prior Findings
- BLOCKING
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:415-441: Carried-forward (STILL_VALID prior-1): Swift SkippedWalletOnLoad decoder omits case 104 — the exact wire code this PR ships - BLOCKING
packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs:88-370: Carried-forward (STILL_VALID prior-2): migration_execution suite pins schema V003 while this PR embeds V004 - SUGGESTION
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:255-275: Carried-forward (STILL_VALID prior-3): verify_manifest_checksums fail-hards on oversized blob, breaking the per-wallet skip contract - SUGGESTION
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:93-135: Carried-forward (STILL_VALID prior-4): bulk platform-payment oracle aborts load on oversized tampered row before per-wallet skip runs - SUGGESTION
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:285-317: Carried-forward (STILL_VALID prior-5): backfill_missing_checksums reads account_xpub_bytes without the blob-size gate every sibling reader applies
New Findings In Latest Delta
- None. The
5914e23f..69d85ddbdelta did not introduce a separate in-scope defect after verification.
Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:415-441: Carried-forward (STILL_VALID prior-1): Swift SkippedWalletOnLoad decoder omits case 104 — the exact wire code this PR ships
Rust defines `LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH = 104` in `rs-platform-wallet-ffi/src/manager.rs` and maps `CorruptKind::ManifestIntegrityMismatch` to it, but Swift's `SkippedWalletOnLoad.reasonDescription` (line 431-439) still switches only over 100/101/102/103/199/200/300, and the struct-level doc at 415-420 enumerates the same short list. A wallet skipped for the exact tamper class this PR is built to surface therefore renders as `unknown skip reason (104)`. The raw `reasonCode` still crosses the ABI intact, but the SDK's own decoder silently degrades the headline user-facing signal. Verified untouched by the 5914e23f→69d85ddb delta.
In `packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs:88-370: Carried-forward (STILL_VALID prior-2): migration_execution suite pins schema V003 while this PR embeds V004
`migrations/` now contains V001..V004 (V004__manifest_checksum.rs is embedded), so `refinery::embed_migrations!` yields `max_supported_version() == 4` and `SqlitePersister::open` migrates a V001 fixture to schema version 4. But `sqlite_migration_execution.rs` still hard-codes V003 throughout: line 90 asserts `schema_version(conn) == 3, "must be migrated to V003"`; lines 223 and 268 look for `pre-migration-1-to-3-` backups; line 308 asserts `max_supported == 3`; lines 370/392/439 assert clean/first-open migrates to V003; and `tc_b_034_forward_version_rejected_at_new_max` forges version=4 to test the forward-version gate — but 4 is now the legitimate max and the gate no longer trips. The PR description's passing-tests list omits `sqlite_migration_execution`, consistent with this target being broken. Verified untouched by the 5914e23f→69d85ddb delta.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:255-275: Carried-forward (STILL_VALID prior-3): verify_manifest_checksums fail-hards on oversized blob, breaking the per-wallet skip contract
`verify_manifest_checksums` calls `blob::check_size(row.get::<_, i64>(0)?)?` at line 265 before the SHA-256 recompute. An oversized `account_xpub_bytes` row returns `WalletStorageError::BlobTooLarge`, which `SqlitePersister::load` routes through the generic error arm as a whole-load abort — only `ManifestIntegrityMismatch` gets translated to `ClientStartState.skipped` (wire code 104 across FFI). This contradicts the invariant the docblock four lines above states ("manifest integrity is a per-wallet SKIP, not a batch abort") and denies the Swift boundary the chance to observe reason 104 for the exact tamper class the checksum defends against. Because `apply_registrations` enforces the same size limit on write, any oversized blob reaching this verifier is by construction the manifest-integrity tamper class. Fix: translate `BlobTooLarge` from this function into `ManifestIntegrityMismatch`, or drop the pre-compare size gate here — the SHA-256 compare fails on any length divergence regardless.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:93-135: Carried-forward (STILL_VALID prior-4): bulk platform-payment oracle aborts load on oversized tampered row before per-wallet skip runs
`all_platform_payment_registrations` is called from `platform_addrs::load_all` at the top of `SqlitePersister::load` before the per-wallet `verify_manifest_checksums` loop. The belt-and-suspenders checksum-skip at lines 121-129 (`_ => continue`) and its comment claim per-wallet-skip semantics — "this bulk oracle scan never fail-hard decodes a tampered / mis-bound blob" — but the size gate at lines 108-113 sits ahead of that skip and returns `BlobTooLarge` for an oversized blob before the checksum compare runs. A corrupted `platform_payment` row with oversized blob therefore aborts the entire `ClientStartState` load — and the Swift `on_load_wallet_list_fn` callback — before the authoritative per-wallet verifier records `ManifestIntegrityMismatch` (wire 104). Fix: move the size gate below the checksum skip, or convert `BlobTooLarge` here to the same `continue` path.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:285-317: Carried-forward (STILL_VALID prior-5): backfill_missing_checksums reads account_xpub_bytes without the blob-size gate every sibling reader applies
`backfill_missing_checksums` runs on every `SqlitePersister::open()` on a V004+ store. Lines 287-299 select `(rowid, wallet_id, account_xpub_bytes)` for every NULL-checksum row and collect into `Vec<(i64, Vec<u8>, Vec<u8>)>` via `.collect::<Result<Vec<_>, _>>()?` — no `length(account_xpub_bytes)` column, no `blob::check_size` gate before materialization. Every other reader added in this PR — `verify_manifest_checksums` (line 260), `load_state` (line 200/212), `all_platform_payment_registrations` (lines 97/107-113) — selects `length(...)` first and enforces `BLOB_SIZE_LIMIT_BYTES` before decoding. A pre-V004 or corrupted DB with `checksum = NULL` and a maliciously grown blob (SQLite BLOBs reach ~2 GB) is materialized in full on every open before any verify runs, with all such rows held simultaneously in the pending Vec. Exploitation presupposes on-disk write access (this PR's Risk-6 threat model), so this is defense-in-depth rather than remotely triggerable, but the inconsistency with the size discipline every sibling reader applies is worth closing. Fix: select `length(account_xpub_bytes)` alongside the payload and reject via `blob::check_size` before decoding.
| pub fn backfill_missing_checksums(conn: &mut Connection) -> Result<usize, WalletStorageError> { | ||
| let tx = conn.transaction()?; | ||
| let pending: Vec<(i64, Vec<u8>, Vec<u8>)> = { | ||
| 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<u8> = row.get(1)?; | ||
| let payload: Vec<u8> = row.get(2)?; | ||
| Ok((rowid, wid_bytes, payload)) | ||
| })?; | ||
| mapped.collect::<Result<Vec<_>, _>>()? | ||
| }; | ||
| 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) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Carried-forward (STILL_VALID prior-5): backfill_missing_checksums reads account_xpub_bytes without the blob-size gate every sibling reader applies
backfill_missing_checksums runs on every SqlitePersister::open() on a V004+ store. Lines 287-299 select (rowid, wallet_id, account_xpub_bytes) for every NULL-checksum row and collect into Vec<(i64, Vec<u8>, Vec<u8>)> via .collect::<Result<Vec<_>, _>>()? — no length(account_xpub_bytes) column, no blob::check_size gate before materialization. Every other reader added in this PR — verify_manifest_checksums (line 260), load_state (line 200/212), all_platform_payment_registrations (lines 97/107-113) — selects length(...) first and enforces BLOB_SIZE_LIMIT_BYTES before decoding. A pre-V004 or corrupted DB with checksum = NULL and a maliciously grown blob (SQLite BLOBs reach ~2 GB) is materialized in full on every open before any verify runs, with all such rows held simultaneously in the pending Vec. Exploitation presupposes on-disk write access (this PR's Risk-6 threat model), so this is defense-in-depth rather than remotely triggerable, but the inconsistency with the size discipline every sibling reader applies is worth closing. Fix: select length(account_xpub_bytes) alongside the payload and reject via blob::check_size before decoding.
source: ['claude', 'codex']
Issue being fixed or feature implemented
account_registrationsrows currently have no binding to thewallet_idthey're supposed to belong to beyond "whichever row the query happened to read." A data bug, a migration error, or disk corruption could silently associate a manifest row with the wrong wallet, causing platform-wallet to derive addresses under the wrong key while believing it's still the original wallet — the manifest trust-boundary gap tracked as Risk-6/R12.5 in the Model-A adoption risk assessment, deferred out of #3986's scope for a focused follow-up.What was done?
An unkeyed SHA-256 integrity checksum, deliberately not a keyed MAC — a local-write-access adversary can recompute any value stored in the same file, so keying with an in-file secret (e.g. the store-generation token) buys no real security. Honest framing: this closes the accidental-corruption / cross-wallet-row class, not an authentication guarantee.
ALTER TABLE account_registrations ADD COLUMN checksum BLOB. Numbered V004, not V003 — see "Migration renumbering" below. V001/V002/V003 stay byte-identical.SHA-256(wallet_id ‖ account_xpub_bytes)— exactly these two fixed identity fields, nothing else. Deliberately excludesmeta_store_generation/meta_data_versions.seq, both of which rotate on legitimate restore/migrate — including either would make every restore false-positive as tampered.account_registrationsis immutable after account creation; address-pool growth lives in the separatecore_address_pooltable.open()fills the checksum for every pre-existing row (SQLite has no SHA-256 builtin, so this can't be a pure-DDL default). By the timeload()runs, every row is covered — no legacy grace period.ClientStartState.skippedchannel —CorruptKind::ManifestIntegrityMismatch(#[non_exhaustive]-safe), FFI wire code 104 (SnapshotIdentityMismatch already claimed 103 upstream). One tampered row never aborts the whole load.all_platform_payment_registrations(used before the per-wallet loop) now also verifies-and-drops on mismatch — without this, a tamperedplatform_paymentrow would abort the load before the per-wallet skip machinery ever ran.Rebased onto #3986's final state (
core_address_pool's widened PK, deterministic pool-script lookup, blob-size gates, restore-ordering fix) — no interaction between the two: this checksum touches onlyaccount_registrations; #3986's fixes touchcore_address_pool. Verified independently.Migration renumbering (post-#3986 merge)
#3986 landed as
V002__unified.rs, colliding with #4019 (ADDR-09)'s already-mergedV002__address_height_pin.rs— two migrations sharing one version number, breakingSqlitePersister::open()for every caller via arefinery_schema_historyUNIQUE-constraint error. Fixed upstream on #3968 by renumbering the unified migration toV003(also folding in a DashPaycore_address_poolprimary-key fix, T5). This PR's own checksum migration is renumberedV003→V004to sequence after it. Final ordering:V001__initial → V002__address_height_pin → V003__unified → V004__manifest_checksum, verified to migrate a fresh store clean with zero collisions.How Has This Been Tested?
platform-wallet-storage,platform-wallet(217+ lib tests),platform-wallet-ffi— 0 failures.cargo fmt --allclean;clippy --all-features --all-targets -- -D warningsclean. Post-merge: production build compiles; 7 migration/schema/pinning test targets re-verified passing (sqlite_v003_migration,sqlite_migrations,sqlite_schema_pinning,sqlite_manifest_integrity,sqlite_store_generation,sqlite_v003_isolation,sqlite_version_bump).Breaking Changes
None. V001/V002/V003 untouched; V004 is additive with automatic backfill. No
SkipReason/CorruptKindshape changes beyond the new additive variant.Checklist:
For repository code-owners and collaborators only
Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent