Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2f2a74a
feat(platform-wallet-storage): seedless load() + rehydration readers …
lklimek Jun 29, 2026
d08abc2
fix(platform-wallet-storage): widen account-registration PK with disc…
lklimek Jun 29, 2026
9c41458
fix(platform-wallet-storage): reject restore_from onto an in-process …
lklimek Jun 29, 2026
d257320
fix(platform-wallet-storage): cross-check identity_keys decoded ids a…
lklimek Jun 29, 2026
8e7fd66
fix(platform-wallet-storage): scope identities upsert overwrite to th…
lklimek Jun 29, 2026
6365be7
fix(platform-wallet-storage): restore used_core_addresses from core_u…
lklimek Jun 29, 2026
163656f
fix(platform-wallet)!: return typed errors from the #3692 stubs inste…
lklimek Jun 29, 2026
d00d59b
fix(platform-wallet-storage): rollback-safe restore ordering + keep_l…
lklimek Jun 29, 2026
f3ca134
fix(platform-wallet-storage): reject a foreign SQLite DB at open + co…
lklimek Jun 29, 2026
7ae801b
fix(platform-wallet-storage): reader cross-checks + decode hardening …
lklimek Jun 29, 2026
c9a2683
fix(platform-wallet-storage): reject embedded-NUL kv keys (#3968 review)
lklimek Jun 29, 2026
1a9a17e
docs(platform-wallet-storage): re-sync changeset doc, correct cascade…
lklimek Jun 29, 2026
f4ac7f5
fix(platform-wallet-storage): secrets hardening — atomic reprotect, s…
lklimek Jun 29, 2026
860ea7f
fix(platform-wallet-storage): surface unconfirmed vault-write durabil…
lklimek Jun 29, 2026
08b2884
fix(platform-wallet-storage): require full ChainLock consumption in m…
lklimek Jun 30, 2026
73df060
fix(platform-wallet-storage): pre-read BLOB size-gate on rehydration …
lklimek Jun 30, 2026
1454394
fix(platform-wallet-storage): DRY blob size-gate helper + close remai…
lklimek Jun 30, 2026
6ba6d4a
fix(platform-wallet-storage): SQLITE_LIMIT_LENGTH global blob floor +…
lklimek Jun 30, 2026
53b7d26
chore(platform-wallet-storage): compile-time assert SQLITE_MAX_BLOB_B…
lklimek Jun 30, 2026
58aac14
Merge v4.1-dev into feat/platform-wallet-storage-rehydration
lklimek Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 16 additions & 23 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3377,29 +3377,22 @@ fn build_wallet_start_state(
// status without rebroadcasting.
let unused_asset_locks = build_unused_asset_locks(entry)?;

let wallet_state = ClientWalletStartState {
wallet,
wallet_info,
identity_manager,
unused_asset_locks,
};

let platform_address_state = if per_account.is_empty()
&& entry.platform_sync_height == 0
&& entry.platform_sync_timestamp == 0
&& entry.platform_last_known_recent_block == 0
{
None
} else {
Some(platform_wallet::PlatformAddressSyncStartState {
per_account,
sync_height: entry.platform_sync_height,
sync_timestamp: entry.platform_sync_timestamp,
last_known_recent_block: entry.platform_last_known_recent_block,
})
};

Ok((wallet_state, platform_address_state))
// Projecting the reconstructed `wallet`/`wallet_info` into the
// reshaped keyless `ClientWalletStartState` (account manifest +
// `CoreChangeSet` + the keyless contact / identity-key feeds) is the
// seeded FFI restore path, which lands in #3692. The storage-only
// #3968 branch keeps every reader above wired — `build_unused_asset_locks`
// and `build_wallet_identity_bucket` still run, so their `?` error
// paths and the per-account projection stay exercised — and stubs
// only this final assembly. `_` consumes the two start-state slices
// whose sole consumer was the removed struct literal.
let _ = (identity_manager, unused_asset_locks);
// Must NOT panic: this runs beneath an `extern "C"` boundary where an
// unwind is undefined behaviour. Return a typed backend error until the
// seeded FFI restore path lands in #3692 (which replaces this assembly).
Err(PersistenceError::backend(
"seeded FFI restore path is not available on this build (lands in #3692)".to_string(),
))
}

/// Translate the `IdentityRestoreEntryFFI` slice carried on a wallet
Expand Down
26 changes: 26 additions & 0 deletions packages/rs-platform-wallet-storage/.cargo/audit.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[advisories]
# Each entry MUST point at a live advisory in this crate's resolved graph
# and carry a dated rationale + remediation plan. Do not blanket-ignore.
ignore = [
# RUSTSEC-2025-0141 — bincode is unmaintained (informational advisory,
# not an exploitable CVE; published 2025-12-16, no patched release).
# Acknowledged 2026-06-08.
#
# Why this is acceptable for now:
# - bincode 2.0.1 is the BLOB-codec trust boundary for every
# persisted column and backup. The known risk class for an
# unmaintained deserializer is unbounded allocation (OOM) on a
# crafted/corrupt input.
# - In-crate size bounds defang that class: MAX_VALUE_LEN /
# BLOB_SIZE_LIMIT_BYTES (kv/blob), the per-secret SecretTooLarge
# write cap, and the MAX_VAULT_SIZE_BYTES read ceiling all reject
# oversized inputs before bincode ever allocates from them.
# - load() is fail-hard: a malformed/over-large row aborts the call
# with a typed error rather than silently over-allocating.
#
# Residual risk: a future bincode defect would go unpatched upstream.
# Remediation plan: migrate the BLOB codec to a maintained equivalent
# (postcard / bitcode candidates) once the wire format is frozen at
# release; revisit this ignore at that time.
"RUSTSEC-2025-0141",
]
37 changes: 36 additions & 1 deletion packages/rs-platform-wallet-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ rusqlite = { version = "0.38", features = [
"backup",
"blob",
"hooks",
"limits",
"trace",
], optional = true }
refinery = { version = "0.9", default-features = false, features = [
Expand All @@ -61,6 +62,11 @@ chrono = { version = "0.4", default-features = false, features = [
"clock",
], optional = true }
sha2 = { version = "0.10", optional = true }
# Opt-in `JsonSchema` for `SecretString` (gated by `secret-schemars`).
# Reuses the workspace-locked 1.2.1. `default-features = false` drops the
# `derive` feature (we hand-write the impl), matching the crate's existing
# derive-free schemars usage so the lock gains no `schemars_derive` entry.
schemars = { version = "1", optional = true, default-features = false }

# Secret-storage deps (gated by the `secrets` feature). RustSec-clean
# pins (Smythe §7); `aes-gcm` is deliberately omitted. `keyring`'s
Expand All @@ -80,7 +86,14 @@ keyring-core = { version = "=1.0.0", optional = true }
# was removed from the sqlite arm — those tests grep for `fs2`/`fs4`
# literals in this crate's source/manifest and would re-trigger on the
# older crates. `fd-lock` has no such collision.
fd-lock = { version = "4.0.4", optional = true }
# LOCAL-FS ONLY: flock/LockFileEx interlock processes only on local
# filesystems; over NFS/CIFS the lock does not interlock, so a vault file
# must not be shared across hosts — steer multi-host to the OS-keyring arm.
# Exact-pinned (`=`) like the rest of the soundness-critical stack: the
# `VaultLock` unsafe drop-order argument in `secrets/file/mod.rs` is
# calibrated to fd-lock 4.0.4's guard internals; any bump must re-verify
# that the guard releases the OS lock before the backing `RwLock` frees.
fd-lock = { version = "=4.0.4", optional = true }

# CLI deps (gated by the `cli` feature)
clap = { version = "4", features = ["derive"], optional = true }
Expand Down Expand Up @@ -180,6 +193,17 @@ cli = [
# crate without the crypto graph.
secrets = [
"dep:argon2",
# Enable argon2's `zeroize` feature so the KDF wipes its sensitive
# intermediate state (`initial_hash`/`blockhash`) on drop. In argon2
# 0.5.3 this does NOT cover the bulk `Block` matrix — that residual is
# documented at `derive_key` in `secrets/file/crypto.rs`. Keep it in
# the feature list (not a `default-features = false` rewrite) so
# argon2's own default features stay intact.
"argon2/zeroize",
# bincode is the producer for the Tier-2 envelope wire format and the
# three AAD encodings (`Tier2Aad`/`EntryAad`/`VerifyAad`) — see
# `secrets/wire/`. `=2.0.1` is the workspace-wide pin.
"dep:bincode",
"dep:chacha20poly1305",
# secrets uses serde directly (vault format + crypto envelope derive
# `Serialize`/`Deserialize`); declare the dep here so
Expand All @@ -199,6 +223,15 @@ secrets = [
"dep:apple-native-keyring-store",
"dep:windows-native-keyring-store",
]
# Opt-in `SecretString` serde/schemars impls. Deliberately DEFAULT-OFF
# even though `secrets` (and, via it, the `serde` dep) are default-on:
# these gate the IMPLS, not the dep, so the impls are absent unless a
# consumer explicitly opts in. `secret-serde` requires `secrets` (the type
# only exists under it). NO `Serialize` is ever provided. `secret-schemars`
# implies `secret-serde`. (design §5.4 / GAP-001 names / GAP-002 satisfiable
# default-off.)
secret-serde = ["secrets", "dep:serde"]
secret-schemars = ["secret-serde", "dep:schemars"]
# Per-object-type key/value metadata API
# (`platform_wallet_storage::{KvStore, KvError, ObjectId}`) plus the
# SQLite-backed impl. Requires `sqlite` because the only shipped backend
Expand All @@ -212,3 +245,5 @@ kv = ["sqlite"]
# convention for "MUST NOT enable from downstream" features
# (https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2).
__test-helpers = ["sqlite"]
# e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool).
rehydration-apply = []
46 changes: 33 additions & 13 deletions packages/rs-platform-wallet-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,11 @@ flush, 5 s busy timeout, WAL journal, `NORMAL` synchronous, and an
auto-backup dir at `<db_dir>/backups/auto/`.

The trait surface is `store` / `flush` / `load` / `get_core_tx_record`.
Schema migrations are append-only Rust files under `migrations/`, applied
via [`refinery`](https://github.com/rust-db/refinery) on every `open`.
Schema migrations are versioned Rust files under `migrations/`, applied via
[`refinery`](https://github.com/rust-db/refinery) on every `open`. While the
crate is unreleased, in-place edits to the sole shipped `V001` are allowed;
the append-only guarantee (add a new versioned file, never edit a prior one)
takes effect once the schema is frozen at release.

#### Flush semantics (store / flush)

Expand Down Expand Up @@ -138,17 +141,34 @@ so one failed wallet does not hide its siblings.

#### load() reconstruction

`SqlitePersister::load()` returns the base `ClientStartState` (plain struct,
two slots — no `#[non_exhaustive]`):
`SqlitePersister::load()` returns a fully-rehydrated `ClientStartState`
(plain struct — no `#[non_exhaustive]`). Both slots are populated:

| Slot | Reader | Status |
|---|---|---|
| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallet_meta::list_ids` wallet universe) | populated |
| `wallets` | — | empty pending upstream `Wallet::from_persisted` |

The `identities` / `contacts` / `asset_locks` per-area readers exist as
hardened dormant helpers (`schema::<area>::load_state`) but are not wired
into `load()` — `ClientStartState` carries no slot for them.
| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallets::list_ids` wallet universe) | populated |
| `wallets` | per-wallet `schema::<area>` readers (see below) | populated |

Each `ClientStartState::wallets` entry is a **keyless** `ClientWalletStartState`
reconstructed from these per-area readers:

| Field | Reader |
|---|---|
| `network` / `birth_height` | `schema::wallets::fetch` |
| `account_manifest` | `schema::accounts::load_state` |
| `core_state` | `schema::core_state::load_state` |
| `identity_manager` | `schema::identities::load_state` |
| `unused_asset_locks` | `schema::asset_locks::load_unconsumed` (`Consumed`-filtered — spent locks stay on disk but are never resurrected) |
| `contacts` | `schema::contacts::load_changeset` |
| `identity_keys` | `schema::identity_keys::load_state` |

The payload carries **no** `Wallet` and no key material. On this
storage-only build the **manager-side rebuild is not yet wired**:
`PlatformWalletManager::load_from_persistor` returns a typed error rather
than reconstructing wallets. The keyless rebuild (watch-only via
`Wallet::new_watch_only` from the manifest, then on-demand signing-key
derivation through the `sign_with_mnemonic_resolver` path) lands in #3692.
`load()` itself already reconstructs the full keyless payload.

Loading is **fail-hard**: any row that fails to decode, or a stored
`wallet_id` that is not exactly 32 bytes, aborts the whole call with a typed
Expand All @@ -158,9 +178,9 @@ corruption tolerance, no per-row skip, and no partial `Ok` — a corrupt
database surfaces as an error rather than silently losing rows.

The summary `tracing::info!` carries `wallets_seen`, `addresses_loaded`,
`wallets_rehydrated`, and `wallets_pending_rehydration` (the count of
wallets that *would* be rehydrated once upstream provides
`Wallet::from_persisted`).
`wallets_rehydrated` (the count actually rehydrated this call), and
`wallets_pending_rehydration` (now always `0` — every seen wallet is
rehydrated). The only deferred field is listed in `LOAD_UNIMPLEMENTED`.

### KV metadata API

Expand Down
Loading
Loading