Skip to content

feat(platform-wallet): manifest integrity checksum (Risk-6/R12.5 follow-up)#3992

Open
Claudius-Maginificent wants to merge 4 commits into
feat/3968-snapshot-redirectfrom
feat/3968-manifest-integrity-checksum
Open

feat(platform-wallet): manifest integrity checksum (Risk-6/R12.5 follow-up)#3992
Claudius-Maginificent wants to merge 4 commits into
feat/3968-snapshot-redirectfrom
feat/3968-manifest-integrity-checksum

Conversation

@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

account_registrations rows currently have no binding to the wallet_id they'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.

  • Additive V003 migration: ALTER TABLE account_registrations ADD COLUMN checksum BLOB. V001/V002 stay byte-identical.
  • Checksum: SHA-256(wallet_id ‖ account_xpub_bytes) — exactly these two fixed identity fields, nothing else. Deliberately excludes meta_store_generation/meta_data_versions.seq, both of which rotate on legitimate restore/migrate — including either would make every restore false-positive as tampered.
  • Writer: computed and bound inside the existing single flush transaction (same atomicity as the rest of the schema). Write-once in practice — account_registrations is immutable after account creation; address-pool growth lives in the separate core_address_pool table.
  • Backfill: an idempotent app-side pass in 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 time load() runs, every row is covered — no legacy grace period.
  • Verify-at-load is a per-wallet skip, not a batch abort: the existing manifest read path is fail-hard on any mismatch; a checksum failure instead surfaces through the storage crate's first use of the already-present ClientStartState.skipped channel — CorruptKind::ManifestIntegrityMismatch (#[non_exhaustive]-safe), FFI wire code 104 (SnapshotIdentityMismatch already claimed 103 upstream). One tampered row never aborts the whole load.
  • Bulk oracle hardened too: all_platform_payment_registrations (used before the per-wallet loop) now also verifies-and-drops on mismatch — without this, a tampered platform_payment row 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 only account_registrations; #3986's fixes touch core_address_pool. Verified independently.

How Has This Been Tested?

  • 11 test cases: checksum computed+stored on write; valid checksum loads normally; tampered blob → skip; wrong-wallet row (the Risk-6 core case) → skip; NULL checksum on a V003+ store → skip; combined-batch isolation (one tampered wallet doesn't sink a clean one); backfill exactness + idempotency; restore does not false-positive (proves the checksum path ignores the generation-rotation token); re-persist consistency; FFI mapping; fingerprint goldens re-pinned for the combined V001+V002(widened)+V003 schema.
  • Full suite: platform-wallet-storage, platform-wallet (217+ lib tests), platform-wallet-ffi — 0 failures. cargo fmt --all clean; clippy --all-features --all-targets -- -D warnings clean.

Breaking Changes

None. V001/V002 untouched; V003 is additive with automatic backfill. No SkipReason/CorruptKind shape changes beyond the new additive variant.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 4 commits July 3, 2026 09:24
…_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>
…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>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 534da058-534d-4e84-8e82-3a1ff939b707

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3968-manifest-integrity-checksum

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lklimek lklimek changed the title feat(platform-wallet-storage): manifest integrity checksum (Risk-6/R12.5 follow-up) feat(platform-wallet): manifest integrity checksum (Risk-6/R12.5 follow-up) Jul 3, 2026
@thepastaclaw

thepastaclaw commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 579b620)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +259 to +275
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(())
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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']

Comment on lines +285 to +317
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)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants