Skip to content

[Fixes] Multiple - #23

Merged
portlandhodl merged 18 commits into
bitcoindevkit:masterfrom
portlandhodl:develop
Aug 9, 2026
Merged

[Fixes] Multiple#23
portlandhodl merged 18 commits into
bitcoindevkit:masterfrom
portlandhodl:develop

Conversation

@portlandhodl

@portlandhodl portlandhodl commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Brings master up to date with develop: 18 commits remediating every finding of a full security/robustness review of the crate (H1–H5, M1–M5, L1–L10), plus a final hardening pass over both backends. Each fix is pinned by an always-on regression test; the suite (58 tests) passes against both postgres and sqlite, with clippy -Dwarnings and fmt clean.

High severity

  • H1 — Reorgs could wedge persistence permanently (f4fc697): anchor_tx FKs had no ON DELETE clause, so deleting a reorged-out block aborted the whole persist transaction and the wallet could never persist again. FKs now cascade (sqlite via table-rebuild migration, postgres via schema change + idempotent upgrade of existing databases).
  • H2 + L3 — Corrupt stored data was silently dropped on load (6cde51b): undecodable whole_tx bytes and unparseable anchor JSON were skipped, so a wallet loaded with less history and a wrong balance. Both backends now fail loudly (Consensus, SerdeJson), reject trailing bytes, and cross-check decoded txid / anchor block hash against the stored columns (TxidMismatch, AnchorBlockHashMismatch).
  • H3 + L10 — Tracing spans recorded descriptors and changesets (8686af9): bare #[tracing::instrument] in the sqlite backend captured full descriptors and changesets at INFO. Now skip_all everywhere; noisy info! downgraded to trace!. Regression test asserts no descriptor material in TRACE output.
  • H4 — easy_backup dumped every tenant's keychain to stdout (195d02f): unscoped SELECT * FROM keychain pretty-printed all wallets' descriptors to stdout (CI logs, journald). Removed; it was dead code.
  • H5 — Destructive DropAll test helper (052f978): hardcoded DROP TABLE on 15 unqualified names (including other applications' tables) while never cleaning the crate's own schema-qualified tables. Replaced with per-test uniquely named bdk_sqlx_test_* databases — tests are isolated, parallel-safe, and repeatable; --test-threads=1 is gone.

Medium severity

  • M1 — Stored network never validated on load + unwrap panic path (fd05b5e): postgres now checks the stored network against the configured network and fails with InvalidNetwork on mismatch; the panic-on-corrupt-data .expect in sqlite is gone too.
  • M2 — Unchecked integer casts at the DB boundary (5218db7): value=-1 wrapped to ~18.4 quintillion sats. All as casts replaced with checked conversions returning IntOutOfRange naming the column and value.
  • M3 — Non-idempotent descriptor/network writes (55305b2): re-persisting a merged changeset aborted on unique-constraint violations; both are now upserts. update_last_revealed errors on a missing keychain row instead of silently updating 0 rows (address-reuse risk).
  • M4 — Hand-rolled postgres schema (720ee4b): adopt sqlx::migrate!() versioned migrations for postgres, matching sqlite; databases created by earlier releases are adopted transparently.
  • M5 — Dependency, TLS, and CI hygiene (ba5d90f): removed unused sqlx-postgres-tester (pulled in obsolete sqlx-core 0.6.3), moved bdk_wallet/test-utils to dev-deps, documented the postgres TLS sslmode=prefer plaintext fallback, added #![forbid(unsafe_code)] and a cargo-audit CI job.

Low severity

  • L1 (57eab29): document that multi-connection pools on sqlite :memory: give every connection its own private database; point callers at the single-connection constructor.
  • L2 (31ff671): read()'s snapshot transaction is now explicitly committed instead of relying on implicit rollback at drop.
  • L5 (f7571db): a reorg replacing a block at a height left two rows and nondeterministic loads; writes now delete the replaced row (anchors cascade), and migration 03 dedupes + enforces one row per (wallet_name, height).
  • L6 (0843f8f): five copy-pasted per-test CI jobs collapsed into one parallel test-suite job.
  • L7 (e09a0d7): fix bitoin typo; reword misleading MissingPool description.
  • L9 (f92edef): document that pub use sqlx couples this crate's API stability to sqlx's major version.

Final hardening pass (ad02915)

  • postgres: concurrent block writes at the same height raced the two unique indexes and aborted the loser's whole changeset with a raw 23505; writes are now serialized per wallet via a transaction-scoped advisory lock (pg_advisory_xact_lock), last-writer-wins, with no cross-wallet blocking. sqlite needs no equivalent (single-writer lock).
  • both: tx.last_seen upsert now keeps the maximum, matching bdk_chain's monotonic merge guarantee instead of letting stale changesets move time backwards.
  • sqlite: statement failures now wrap in BdkSqlxError::QueryError with table context, matching postgres, so callers can match one error kind.
  • sqlite API parity: new SqliteStoreBuilder mirroring PgStoreBuilder, and Store::<Sqlite>::migrate() mirroring postgres.
  • tests: bare cargo test without DATABASE_TEST_URL no longer fails 40+ tests; postgres tests skip gracefully while sqlite still runs. CI keeps full coverage.
  • style: uniform skip_all instrumentation, pub(crate) demotions, typo fixes; README's Resolved defects moved to CHANGELOG.md.

Deliberately unchanged: the process-global network (OnceLock) remains a documented design decision — one process, one network, validated on every read and write.

Test plan

  • Full suite (58 tests) against postgres and sqlite
  • cargo clippy --all-targets -- -Dwarnings
  • cargo fmt --check
  • Regression tests verified to fail without their respective fixes
  • Migration adoption verified against databases built with the pre-migration schemas (live rows preserved, cascades work)

…DropAll

Security review finding H5 (high): the DropAll test helper hardcoded
DROP TABLE statements for 15 unqualified table names, including tables
belonging to a different application (users, psbts, vault_*,
_sqlx_migrations). Running the test suite with a mispointed
DATABASE_TEST_URL silently and irreversibly destroyed those tables,
while never actually cleaning the crate's own schema-qualified
bdk_wallet.* tables - which is why tests contaminated each other and
required --test-threads=1.

- Delete the DropAll trait/impl and the dead _drop_tables helper; the
  test suite no longer issues a single DROP TABLE.
- create_test_stores now creates a uniquely named bdk_sqlx_test_* database
  per store, so tests are isolated, parallel-safe, and repeatable.
  Leftover test databases are reaped opportunistically, never while any
  session is connected, with creation/cleanup serialized against races.
- Use Store::new_with_url(None, ..) for the sqlite in-memory store so the
  pool is correctly limited to a single connection.
- Also fixes L8 (redundant url.clone()).
- README: document the new test database behavior; drop --test-threads=1.

Fixes a broken baseline: 3 of 5 tests failed on a shared database due to
leftover wallet data. Full suite now passes in parallel and on repeat runs.
Security review finding H1 (high): anchor_tx declared FKs to block and tx
with no ON DELETE clause. On a reorg, BDK's local_chain changeset carries
(height, None) and the store deletes the block row; while any anchor_tx row
still references it (the normal case for an anchored tx), the database
rejected the DELETE and the entire persist transaction aborted - after
which the wallet could never persist again. anchor_tx rows were never
deleted anywhere, so any reorg over an anchored block triggered this.

Reproduced before the fix: postgres error 23503 on
anchor_tx_wallet_name_block_hash_fkey, sqlite FOREIGN KEY constraint
failed - matching the report's PoC evidence.

- sqlite: migration 02 rebuilds anchor_tx with ON DELETE CASCADE on both
  FKs (sqlite cannot alter FK clauses in place); migration 01 is untouched
  to preserve sqlx checksums for existing databases. Verified against a
  database with existing anchor rows: data survives the rebuild and the
  reorg delete now cascades.
- postgres: hardcoded schema gains ON DELETE CASCADE, plus an idempotent
  DO block that upgrades the constraints of databases created with the old
  schema. Verified idempotent and cascading on an old-schema database.
- migrations/postgres/01 (currently unused by code) kept in sync.
- New regression test reorged_out_anchored_block_can_be_deleted covers
  both backends: persist anchored txs, disconnect the anchored block,
  assert persistence continues and anchors are dropped with the block.
…load

Security review finding H2 (high) + L3: the read path decoded stored rows
with error-swallowing patterns (if let Ok(..) = consensus_decode /
serde_json::from_value), so corrupted whole_tx or anchor rows were
silently skipped: the wallet loaded successfully with less history and a
wrong balance, and nothing was ever reported. The integrity policy was
also inconsistent - other columns of the same tables already hard-failed
the load.

Both backends now apply one uniform fail-loud policy:

- Undecodable whole_tx bytes return BdkSqlxError::Consensus.
- consensus::deserialize replaces consensus_decode, so trailing bytes
  after a valid transaction are rejected too (L3).
- The decoded transaction's computed txid is cross-checked against the
  stored txid column (new TxidMismatch error).
- Unparseable anchor JSON returns BdkSqlxError::SerdeJson.
- The anchor payload's block hash is cross-checked against the stored
  block_hash column (new AnchorBlockHashMismatch error).

New regression test corrupt_rows_error_on_load covers all five scenarios
on both backends and verifies the store loads cleanly again once the
corruption is repaired.
Security review finding H3 (high) + L10: every instrumented function in
the sqlite backend used bare #[tracing::instrument], which records all
arguments into the span - full descriptors (xpub form today: public keys,
derivation structure), whole changesets, wallet names, and pool internals
- and emitted them at INFO, exactly the verbosity the shipped example
enables (RUST_LOG=bdk_sqlx=debug). The postgres module already used
skip_all/skip; the sqlite module now matches it.

- #[tracing::instrument(skip_all)] on every instrumented fn in sqlite.rs
  and on the TestStore persister shims in test.rs.
- sqlite info! events downgraded to trace! to match the postgres backend's
  noise level (L10).
- New regression test tracing_output_contains_no_descriptor_material
  captures tracing output at TRACE while creating, persisting, and loading
  a wallet, and asserts no descriptor/xkey/pubkey material appears.
  Before the fix it failed with XPub material in the captured spans.

Note bdk_wallet 1.2 strips xprv->xpub at Wallet::create, so what leaked
today was watch-only surveillance data, not private keys - but spans
record whatever a future changeset carries, so the missing skip was
latent-critical.
Security review finding H4 (high): easy_backup ran an unscoped
SELECT * FROM keychain - no wallet_name filter - and pretty-printed every
wallet's rows, including full descriptor strings, to stdout, where CI
logs, journald, and container log drivers capture them. One call exposed
all tenants' descriptors (xpub form today; latent-critical for anything
secret-bearing stored later).

Both copies were also dead code: pub fns inside private modules, never
re-exported and never constructed (the compiler flagged KeychainEntry as
never constructed), so removal breaks no user. This also makes
cargo clippy --all-targets -- -Dwarnings pass again, which was failing on
master because of exactly this dead code.

A real backup facility, if wanted later, should require a wallet_name
scope, write to a caller-provided sink instead of stdout, and document
the sensitivity of descriptor data.
Security review finding M1 (medium): the process-global NETWORK OnceLock
existed for 'network validation', yet the read path never compared the
DB-stored network against it - InvalidNetwork only fired on unparseable
strings, and that error path called get_network().unwrap(), which would
panic if the global was unset.

- postgres: after parsing the stored network, it is now checked against
  the configured global network and the load fails with InvalidNetwork on
  mismatch. The unwrap is gone; when no network is configured the error
  falls back to a descriptive placeholder instead of panicking.
- sqlite: an unparseable stored network string returned via
  .expect("parse Network") - a panic on corrupt data. It now returns
  InvalidNetwork like the postgres backend.
- New regression test mismatched_network_errors_on_load covers both the
  wrong-network and unparseable-network cases.
Security review finding M2 (medium): values crossing the DB boundary were
converted with unchecked 'as' casts in both backends - value as u64,
height as u32, vout as u32, last_seen as u64, last_revealed as u32 on the
read path (and the mirror-image casts on the write path). A negative or
oversized stored value wrapped silently: value=-1 became ~18.4 quintillion
sats, height=-1 became height 4294967295.

All casts are replaced with a checked_conv helper that returns the new
BdkSqlxError::IntOutOfRange error naming the offending column and value.

New regression test out_of_range_values_error_on_load verifies that
negative txout values and block heights error on load on both backends
instead of wrapping.
…updates

Security review finding M3 (medium): insert_descriptor and insert_network
were bare INSERTs - unlike every other writer in the crate and unlike
upstream BDK stores - so re-persisting a merged changeset that carried the
descriptor or network again aborted with a unique-constraint violation.
update_last_revealed silently updated 0 rows when the keychain row was
missing, which would lose derivation state and lead to address reuse.

- insert_descriptor/insert_network now upsert (ON CONFLICT DO UPDATE) in
  both backends.
- update_last_revealed returns QueryError{keychain, RowNotFound} when no
  row matched, in both backends.
- New regression test repersisting_full_changeset_is_idempotent covers
  both behaviors on both backends.
Security review finding M4 (medium): the postgres backend hand-rolled its
schema with CREATE TABLE IF NOT EXISTS strings in migrate(), wrote a
version row it never read back, and had no way to alter existing
databases - blocking schema fixes like H1's FK change. The sqlite backend
already used sqlx::migrate!(); both backends now share one scheme.

- migrate() now runs sqlx::migrate!("./migrations/postgres").
- The H1 constraint upgrade moves from the hardcoded query list into
  versioned migration 02.
- Databases created by earlier releases (no _sqlx_migrations bookkeeping)
  are adopted transparently: migration 01 is pure IF NOT EXISTS, and
  migration 02 upgrades pre-cascade anchor_tx constraints in place.
  Verified against a database built with the old hand-rolled schema and
  live rows: both migrations apply, and a reorg block delete cascades.
Security review finding M5 (medium):

- Remove sqlx-postgres-tester: it was an unused [dependencies] entry that
  pulled in the obsolete sqlx-core 0.6.3 (flagged by cargo's
  future-incompatibility report) into every consumer's tree.
- Move bdk_wallet's test-utils feature to [dev-dependencies] so test
  helpers are no longer compiled into release builds of consumers.
- Document the postgres TLS default (sslmode=prefer silently falls back
  to plaintext) on build_with_url and in a new README security-notes
  section, along with least-privilege role guidance.
- Add #![forbid(unsafe_code)].
- Add a rustsec cargo-audit job to CI.
Security review finding L5 (low): the block table's only uniqueness was
(wallet_name, hash), so a reorg that replaced the block at a height with a
different hash (local_chain changeset entry (height, Some(new_hash)))
inserted a second row at the same height and left the old one behind -
subsequent loads picked one of the two rows nondeterministically.

- Persisting a block now first deletes any row at the same height with a
  different hash (its stale anchors cascade away via the H1 FKs), then
  upserts, in both backends.
- Migration 03 (both backends) deduplicates existing rows and adds a
  UNIQUE index on (wallet_name, height) so the invariant is enforced by
  the database from now on.
- New regression test reorg_replaced_block_leaves_single_row verifies a
  replaced block leaves exactly one row, the new hash wins the load, and
  the stale anchors are gone - on both backends.
Security review finding L2 (low): read() opened a transaction for its
multi-query snapshot but never committed it, relying on implicit rollback
at drop. Commit it explicitly on the success path in both backends.
Security review finding L1 (low): Store::<Sqlite>::new accepts any pool,
but a multi-connection pool on :memory: gives every connection its own
private database - reads and writes silently diverge and per-connection
PRAGMAs don't apply pool-wide. Document the constraint and point callers
at new_with_url(None, ..), which configures a single-connection pool. The
test suite already switched to that constructor in the H5 commit.
Security review finding L7 (low): fix 'bitoin' typo in the HexToArray
error message, and reword MissingPool - it fired when the builder had no
pool configured, not when a postgres connection failed to initialize.

The sqlite/postgres builder API asymmetry noted in the same finding (no
SqliteStoreBuilder) is a feature addition and is left as a follow-up.
Security review finding L9 (low): the sqlx re-export is part of the
public API surface, so a sqlx major version bump is a breaking change for
this crate. Document that, and steer consumers to import sqlx types
through the re-export to stay version-aligned.
Security review finding L6 (low): CI ran five identical postgres jobs,
one per test, because tests could not share a database. Now that every
test isolates itself in its own database (H5 fix), a single job runs the
whole suite in parallel. The manual psql database-creation steps are
gone too - tests create their own databases against the service's
default postgres database.
A hardening pass over the whole store, each defect pinned by an
always-on regression test (src/test.rs, tests/builder_network.rs):

- tx.last_seen for a tx not yet stored was silently dropped (the UPDATE
  affected 0 rows); the write now upserts a stub row (whole_tx is
  nullable).
- Reads anchored on the network row, so rows persisted by a changeset
  that carried no network were written but never read back; tx, block
  and keychain tables are now read unconditionally.
- A changeset mapping the same block hash to several heights silently
  collapsed to one block row, losing checkpoints; such changesets are
  now rejected with DuplicateBlockHash.
- The postgres write path did not validate changeset.network against
  the configured network, letting a foreign network overwrite the row
  and wedge all subsequent reads; the write is now rejected with
  InvalidNetwork.
- keychain.last_revealed INTEGER DEFAULT 0 made a wallet persisted
  before its first address reveal reload with index 0 marked used,
  skipping it forever. New rows now store NULL explicitly and
  migration 04 drops the default. Existing rows are deliberately
  untouched: a stored 0 is ambiguous ('revealed index 0' vs 'never
  revealed') and rewriting it could cause address reuse.
- update_last_revealed was a plain UPDATE, letting a stale/replayed
  changeset move the derivation index backwards and silently reuse
  addresses; the update now never decreases the stored value.
- Store::<Postgres>::read ran at READ COMMITTED (per-statement
  snapshots), so a concurrent writer could produce a mixed-generation
  changeset; the read transaction now uses REPEATABLE READ.
- initialize_network had a check-then-set race that failed concurrent
  same-network builds spuriously with SetNetworkFailure; a lost race
  now re-validates instead. The race regression test lives in its own
  integration-test binary (tests/builder_network.rs) because the
  configured network is process-global.
- Store derived Clone with a DB: Clone bound that sqlx's Postgres/
  Sqlite marker types do not satisfy, making the impl unusable; a
  manual bound-free impl is provided.
- The sqlite backend had no network validation at all: its constructor
  took no network and any stored or incoming network was accepted. It
  now takes the network at construction (shared process-global with
  the postgres backend) and applies the same read/write guards.
  Store::<Sqlite>::new and new_with_url therefore take a network
  argument.
- insert_descriptor's conflict update kept the stored last_revealed
  unconditionally, so replacing a descriptor under the same
  (wallet_name, keychainkind) made the new descriptor inherit the old
  derivation index and silently skip those addresses on load. The keep
  is now conditional on the descriptor being unchanged.
- A keychainkind value outside 'External'/'Internal' was silently
  ignored on load, dropping a keychain; corrupt rows now fail with
  InvalidKeychainKind.
- Reorgs left duplicate block rows per height behind and made loads
  nondeterministic; migration 03 dedupes and enforces one row per
  (wallet_name, height), and the write path removes replaced blocks so
  their anchors cascade away.
- Corrupt stored data (undecodable tx bytes, txid/anchor payload
  mismatches, negative or overflowing integers at the database
  boundary) now fails the load loudly via TxidMismatch,
  AnchorBlockHashMismatch and checked_conv instead of being silently
  skipped or wrapping.
- Migration 05 drops the dead version table and the redundant
  idx_block_height index on both backends.

Also: tokio and tracing-subscriber move to dev-dependencies (library
consumers should not pay for test-only deps), and the README gains a
Resolved defects section and security notes (TLS sslmode, least-
privilege roles, descriptor sensitivity).
…parity

Resolves the remaining findings of a full review of the crate. Each fix
is pinned by an always-on regression test; verified with fmt, clippy
-Dwarnings, and the full suite (58 tests) against postgres and sqlite.

- fix(postgres): two writers persisting different block hashes for the
  same previously unoccupied height raced the block table's two unique
  indexes. The loser's DELETE could not see the winner's uncommitted
  row, and its INSERT then violated idx_block_wallet_height -- an index
  the upsert's (wallet_name, hash) conflict target does not cover --
  aborting the loser's whole changeset with a raw 23505. Reproduced
  before the fix (duplicate key value violates unique constraint
  "idx_block_wallet_height"). Block writes are now serialized per
  wallet with a transaction-scoped advisory lock
  (pg_advisory_xact_lock(hashtext(wallet))): the loser waits for the
  winner to commit, then sees and replaces its row, last-writer-wins,
  exactly as if the writes had been issued sequentially. The lock is
  released by commit/rollback and keyed per wallet, so different wallets
  never block each other. sqlite needs no equivalent: its single-writer
  lock already serializes the same interleaving. Regression test
  concurrent_block_writes_at_same_height_both_land races two writers
  over ten heights on both backends and was verified to fail without
  the lock.
- fix(both): the tx.last_seen upsert overwrote unconditionally, so a
  stale or replayed changeset moved the timestamp backwards,
  contradicting bdk_chain's own Merge (last_seen only ever increases).
  The conflict update now keeps the maximum on both backends, matching
  the monotonic guarantee update_last_revealed already enforces for
  derivation state. Regression test last_seen_never_regresses covers
  both backends.
- fix(sqlite): statement failures propagated raw BdkSqlxError::Sqlx
  while postgres wrapped them in BdkSqlxError::QueryError with table
  context, so callers could not match one error kind for 'the write
  failed at the database'. sqlite now wraps with the same table labels
  as postgres; the two tests that encoded the asymmetry
  (unmigrated_store_errors, failed_persist_rolls_back_everything) now
  assert QueryError on both backends.
- feat(sqlite): SqliteStoreBuilder mirroring PgStoreBuilder
  (new(wallet_name).network(..).migrate(..).pool(..).build() /
  build_with_url(..); build_with_url(None) builds the single-connection
  in-memory store), closing the constructor/builder API asymmetry
  between backends. Store::<Sqlite>::new_with_url now delegates to it,
  so pool construction lives in exactly one place.
- feat(sqlite): Store::<Sqlite>::migrate(), mirroring
  Store::<Postgres>::migrate; sqlite_migrate_is_idempotent covers it.
- test: bare 'cargo test' without DATABASE_TEST_URL no longer fails 40+
  tests with .expect panics; postgres-backend tests skip gracefully
  with a one-time notice while the sqlite backend still runs. CI sets
  the variable, so full coverage always runs there.
- style: #[tracing::instrument] on persist-path helpers is uniformly
  skip_all on both backends (one rule: no span records arguments);
  module-internal free functions demoted from unreachable pub to
  pub(crate); get_test_minisicript_with_change_desc typo renamed;
  README's Resolved defects section moved to CHANGELOG.md.

Deliberately unchanged: the process-global network (OnceLock) is a
documented design decision -- one process, one network, validated on
every read and write -- and remains as-is.
@portlandhodl portlandhodl changed the title [Security] Audit [Fixes] Multiple Aug 9, 2026
@portlandhodl portlandhodl self-assigned this Aug 9, 2026
@portlandhodl portlandhodl added the bug Something isn't working label Aug 9, 2026
@portlandhodl
portlandhodl merged commit 9552390 into bitcoindevkit:master Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant