diff --git a/Cargo.lock b/Cargo.lock index 234b65b0a13..1c1074f5172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -158,6 +169,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -637,6 +660,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "blake2b_simd" version = "1.0.4" @@ -1440,6 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array 0.14.7", + "rand_core 0.6.4", "typenum", ] @@ -1798,6 +1831,46 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + +[[package]] +name = "dbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8f54da401bb5eb2a4d873ac4b359f4a599df2ca8634bb5b8c045e5ee78757" +dependencies = [ + "dbus-secret-service", + "keyring-core", +] + [[package]] name = "delegate" version = "0.13.5" @@ -2351,6 +2424,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -3893,6 +3977,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "keyword-search-contract" version = "3.1.0-dev.7" @@ -3937,6 +4030,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3989,6 +4092,26 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "linux-keyutils-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39fbed79f71dc21eb21d3d07c0e908a3c58ff9a1fdbf5cf44230fb3deb6d994b" +dependencies = [ + "keyring-core", + "linux-keyutils", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4057,6 +4180,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.7" @@ -4576,6 +4708,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.116" @@ -4584,6 +4725,7 @@ checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -4670,6 +4812,17 @@ dependencies = [ "regex", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pasta_curves" version = "0.5.1" @@ -4941,34 +5094,46 @@ dependencies = [ name = "platform-wallet-storage" version = "3.1.0-dev.7" dependencies = [ + "apple-native-keyring-store", + "argon2", "assert_cmd", "bincode", + "chacha20poly1305", "chrono", "clap", "dash-sdk", "dashcore", + "dbus-secret-service-keyring-store", "dpp", + "fd-lock", "filetime", + "getrandom 0.2.17", "hex", "humantime", "key-wallet", + "keyring-core", + "libc", + "linux-keyutils-keyring-store", "platform-wallet", "platform-wallet-storage", "predicates", "proptest", "refinery", + "region", "rusqlite", "serde", "serde_json", "serial_test", "sha2", "static_assertions", + "subtle", "tempfile", "thiserror 1.0.69", - "tokio", "tracing", "tracing-subscriber", "tracing-test", + "windows-native-keyring-store", + "zeroize", ] [[package]] @@ -5721,6 +5886,18 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2", + "windows-sys 0.52.0", +] + [[package]] name = "rend" version = "0.4.2" @@ -8617,6 +8794,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5fd986f648459dd29aa252ed3a5ad11a60c0b1251bf81625fb03a86c69d274e" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-registry" version = "0.6.1" diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index e3a4c5da42b..99232e9da62 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -5,7 +5,7 @@ rust-version.workspace = true edition = "2021" authors = ["Dash Core Team"] license = "MIT" -description = "Storage backends for platform-wallet: SQLite persistence (today) and a future SecretStore submodule" +description = "Storage backends for platform-wallet: SQLite persistence and keyring_core secret backends (encrypted-file + OS keyring)." [lib] path = "src/lib.rs" @@ -61,6 +61,24 @@ chrono = { version = "0.4", default-features = false, features = [ ], optional = true } sha2 = { version = "0.10", optional = true } +# Secret-storage deps (gated by the `secrets` feature). RustSec-clean +# pins (Smythe §7); `aes-gcm` is deliberately omitted. `keyring`'s +# library is `keyring-core` + per-platform store crates (the `keyring` +# crate itself is sample/CLI). Verified to build under MSRV 1.92. +argon2 = { version = "=0.5.3", optional = true } +chacha20poly1305 = { version = "=0.10.1", optional = true } +zeroize = { version = "=1.8.2", features = ["derive"], optional = true } +subtle = { version = "=2.6.1", optional = true } +getrandom = { version = "0.2", optional = true } +region = { version = "=3.0.2", optional = true } +keyring-core = { version = "=1.0.0", optional = true } +# Cross-process advisory file lock for the vault RMW (CMT-001). +# `fd-lock` 4.x is pure-rustix and replaces the `fs2`/`fs4` family that +# was removed from the sqlite arm in #3743 (CODE-005/007/010/015) — 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 } + # CLI deps (gated by the `cli` feature) clap = { version = "4", features = ["derive"], optional = true } humantime = { version = "2", optional = true } @@ -69,6 +87,33 @@ tracing-subscriber = { version = "0.3", features = [ "env-filter", ], optional = true } +# Per-platform OS-keyring credential stores. `keyring-core 1.0.0` is +# the API; these crates provide the platform backends (the `keyring` +# 4.x crate is the sample CLI and is intentionally not depended on). +# Gated by `secrets` via `dep:`. Target-specific tables MUST follow all +# `[dependencies]` entries. +[target.'cfg(unix)'.dependencies] +# `O_NOFOLLOW` open flag for vault read TOCTOU defence (CMT-004). +libc = { version = "0.2", optional = true } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +linux-keyutils-keyring-store = { version = "=1.0.0", optional = true } +dbus-secret-service-keyring-store = { version = "=1.0.0", features = [ + "crypto-rust", + "vendored", +], optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +# macOS crate requires one of `keychain` / `protected` features or it +# emits a compile_error! (latent — only fires on macOS targets; Linux CI +# never tripped it). `keychain` = standard Keychain; `protected` is the +# user-presence-gated variant. We want the standard one for the v1 +# SecretStore SPI; the protected variant can be opt-in later. +apple-native-keyring-store = { version = "=1.0.0", features = ["keychain"], optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-native-keyring-store = { version = "=1.0.0", optional = true } + [dev-dependencies] proptest = "1" assert_cmd = "2" @@ -77,24 +122,30 @@ static_assertions = "1" filetime = "0.2" tracing-test = { version = "0.2", features = ["no-env-filter"] } serial_test = "3" -platform-wallet-storage = { path = ".", features = ["sqlite", "cli", "kv", "__test-helpers"] } -# `round_trip_consumer.rs` constructs a real `PlatformWalletManager` -# (consumer) against a real `SqlitePersister` (this crate's impl) so -# every consumer↔persister contract drift becomes a CI failure (CODE-008 -# / T-024). The manager needs `dash-sdk::SdkBuilder::new_mock().build()` -# (gated behind `mocks`) and `platform-wallet` requires `wallet` on the -# SDK transitively. Tokio is needed directly so `#[tokio::test]` -# resolves the macro by name. +# `default-features = false` so the off-state CI invocation +# (`--no-default-features --features sqlite,cli`) actually exercises a +# build with `secrets`/`kv` disabled — otherwise the dev-dep view would +# silently re-enable the default feature set for every integration test. +# Test surface is opted into explicitly: `secrets` and `kv` are listed +# so the plain `cargo test -p platform-wallet-storage` invocation runs +# both feature paths (the `kv`-gated `sqlite_kv_store.rs` integration +# test and the `secrets`-gated unit tests). +platform-wallet-storage = { path = ".", default-features = false, features = ["sqlite", "cli", "secrets", "kv", "__test-helpers"] } +tempfile = "3" +# `sqlite_hardening_3625.rs`, `sqlite_persist_roundtrip.rs`, and +# `sqlite_load_reconstruction.rs` import `dash_sdk::platform::address_sync::AddressFunds`. +# Mocks feature lets the consumer↔persister boundary tests stand up a +# real SDK without network. (`round_trip_consumer.rs` was extracted into +# the consumer-hardening PR; tokio is no longer needed here.) dash-sdk = { path = "../rs-sdk", default-features = false, features = [ "dashpay-contract", "dpns-contract", "wallet", "mocks", ] } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [features] -default = ["sqlite", "cli", "kv"] +default = ["sqlite", "cli", "secrets", "kv"] # SQLite-backed persister (`platform_wallet_storage::sqlite`). sqlite = [ "dep:platform-wallet", @@ -119,10 +170,29 @@ cli = [ "dep:serde_json", "dep:tracing-subscriber", ] -# Future `SecretStore` submodule. Slot is reserved; the module is not -# implemented in this build — enabling the feature today is a no-op -# beyond a `// pub mod secrets;` marker in `src/lib.rs`. -secrets = [] +# `secrets` submodule (`platform_wallet_storage::secrets`): zeroizing +# secret wrappers + EncryptedFile backend + OS-keyring construction +# helper, all built on the upstream `keyring_core::api` SPI. Default-on +# so `Cargo.lock` unconditionally pins the RustSec-clean crypto stack +# (SEC-REQ-4.7). Disable explicitly via `--no-default-features` to +# build the storage crate without the crypto graph. +secrets = [ + "dep:argon2", + "dep:chacha20poly1305", + "dep:serde_json", + "dep:tempfile", + "dep:zeroize", + "dep:subtle", + "dep:getrandom", + "dep:region", + "dep:keyring-core", + "dep:fd-lock", + "dep:libc", + "dep:linux-keyutils-keyring-store", + "dep:dbus-secret-service-keyring-store", + "dep:apple-native-keyring-store", + "dep:windows-native-keyring-store", +] # Generic key/value store API (`platform_wallet_storage::KvStore`, # `KvError`) plus the SQLite-backed impl. Requires `sqlite` because # the only shipped backend is on `SqlitePersister`. The underlying diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md index 097245743ed..7f2ba8fd993 100644 --- a/packages/rs-platform-wallet-storage/README.md +++ b/packages/rs-platform-wallet-storage/README.md @@ -1,12 +1,14 @@ # platform-wallet-storage Storage backends for the -[`platform-wallet`](../rs-platform-wallet) crate. Today this crate -ships a SQLite-backed implementation of `PlatformWalletPersistence` -under [`sqlite`](src/sqlite/) plus a maintenance CLI; the crate is -structured so a future `SecretStore` (currently sketched in -[`SECRETS.md`](./SECRETS.md)) can land as a sibling submodule under -[`secrets`](src/) without a crate split. +[`platform-wallet`](../rs-platform-wallet) crate. This crate ships a +SQLite-backed implementation of `PlatformWalletPersistence` under +[`sqlite`](src/sqlite/), a maintenance CLI, and the +[`secrets`](src/secrets/) submodule — a `keyring_core` SPI +implementation pairing the in-house `EncryptedFileStore` +(Argon2id + XChaCha20-Poly1305 on-disk vault) with the OS keyring +backends. All three are on by default; see [`SECRETS.md`](./SECRETS.md) +for the secret-storage threat model and design. ## At a glance @@ -141,14 +143,13 @@ to make Manual-mode writes durable. |---|---|---| | `sqlite` | yes | SQLite persister (`platform_wallet_storage::sqlite`) and all of its native deps (`rusqlite`, `refinery`, `dpp`, `dash-sdk`, `key-wallet`, etc.) | | `cli` | yes | Maintenance binary `platform-wallet-storage`. Implies `sqlite`. | -| `secrets` | no | Reserved for the future `SecretStore` submodule. No code lands today. | +| `secrets` | yes | `platform_wallet_storage::secrets` submodule — zeroizing secret wrappers (`SecretBytes`, `SecretString`), the `EncryptedFileStore` Argon2id + XChaCha20-Poly1305 vault backend, and the `default_credential_store()` OS-keyring constructor. Implements the upstream `keyring_core::api::{CredentialApi, CredentialStoreApi}` SPI. | | `__test-helpers` | no | Crate-private `lock_conn_for_test` / `config_for_test` accessors. The double-underscore prefix follows Cargo's "do not enable from downstream" convention; the methods are also `#[doc(hidden)]`. | -`cargo build -p platform-wallet-storage --no-default-features` builds -the crate with neither the SQLite backend nor the CLI compiled in. -The resulting library has no public surface today; the build mode -exists to support a future split where one cargo target wants only -the secrets feature. +`cargo build -p platform-wallet-storage --no-default-features` builds a +minimal core with neither the SQLite backend, the CLI, nor the secrets +submodule. `--no-default-features --features sqlite,cli` is the +"persister-only" build mode (no crypto dependencies). ## Schema diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md index b6287035c25..681283d37b0 100644 --- a/packages/rs-platform-wallet-storage/SECRETS.md +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -12,29 +12,138 @@ Keystore, OS keyring, encrypted file vault). They are re-derived as needed via the wallet's BIP-32/BIP-39 plumbing and never touch the SQLite file the persister writes. -## Future `secrets` submodule sketch +## The `secrets` submodule -This crate is structured so the `SecretStore` trait can land as a -submodule (`platform_wallet_storage::secrets`) gated behind a `secrets` -Cargo feature, sharing the crate-level error type and config -conventions. The module slot is reserved in `src/lib.rs` with a -commented-out `pub mod secrets;` line; the feature flag exists today -but flips no code. +`platform_wallet_storage::secrets` is part of the crate's default +feature set. The consumer entry point is `SecretStore`; the upstream +`keyring_core::api::{CredentialApi, CredentialStoreApi}` (shipped by +`keyring-core 1.0.0`) is the internal backend SPI. This crate +contributes backends and zeroizing wrappers, not the trait surface. + +### Consumer API: `SecretStore` + +`SecretStore` is the public, never-leaking front door. `get` yields a +zeroizing `SecretBytes` (a raw `Vec` never crosses the boundary); +`set` takes `&SecretBytes` so a caller cannot pass an unwrapped buffer. +Errors surface as the typed `FileStoreError` — losslessly for the file +arm, so `WrongPassphrase` vs `Corruption` vs `Busy` stay distinct. ```rust -trait SecretStore: Send + Sync { - fn put(&self, wallet_id: WalletId, label: &str, bytes: &[u8]) -> Result<()>; - fn get(&self, wallet_id: WalletId, label: &str) -> Result>>; - fn delete(&self, wallet_id: WalletId, label: &str) -> Result<()>; -} +use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, WalletId}; + +let store = SecretStore::file("/var/lib/wallet/secrets.pwsvault", SecretString::new("pw"))?; +let wallet = WalletId::from(wallet_id); +store.set(&wallet, "mnemonic", &SecretBytes::from_slice(b"abandon ability ..."))?; +let plaintext: Option = store.get(&wallet, "mnemonic")?; // never a bare Vec +store.delete(&wallet, "mnemonic")?; // idempotent ``` -Reference backends to plan for: +`SecretStore::file` takes the vault FILE path (operator picks the +filename); the parent directory is materialized on the first write. +Use `SecretStore::os()` for the platform OS keyring arm instead of +`SecretStore::file(..)`. + +### Internal SPI + +Below `SecretStore`, `EncryptedFileStore` and `default_credential_store` +expose the raw `keyring_core` SPI directly; their `keyring_core::Error` +projection is **lossy and string-only** (the typed distinction lives on +the `SecretStore` path). SPI consumers re-wrap the bare `Vec` from +`CredentialApi::get_secret` via `SecretBytes::new(...)` at the seam. + +### Key shape + +| upstream field | this crate's mapping | +|---|---| +| `service` | `"dash.platform-wallet-storage/" + hex(wallet_id)` (`SERVICE_PREFIX` + 64 hex chars) — one keyring "service" namespace per wallet | +| `user` | `label`, validated against `^[A-Za-z0-9._-]{1,64}$` (SEC-REQ-4.3) before reaching the SPI; allowlist excludes `/`, `:`, space, NUL, non-ASCII | + +`WalletId` is a fixed 32-byte newtype. `validated_label` runs at +`CredentialStoreApi::build` time AND at every `CredentialApi` +operation (defence in depth — credentials are long-lived). + +### Memory hygiene at the seam + +`SecretStore::get` returns `Option` — a raw `Vec` +never crosses the public boundary. Internally, the upstream SPI returns +plaintext as `Vec` from `CredentialApi::get_secret`; that result is +wrapped into `SecretBytes::new(...)` **immediately**, with no named +intermediate `Vec` binding (Smythe EDIT-1). `SecretBytes::new` takes the +`Vec` by value and `std::mem::take`s it into a `Zeroizing>` — +no copy of the bare buffer ever survives past the constructor +expression, so the bare-`Vec` exposure window is zero statements. The +wrapper is also best-effort `mlock`ed and `Debug` is redacted. + +`SecretStore::set` takes `&SecretBytes`, exposing the wrapped bytes to +the SPI's `set_secret(&[u8])` only at the last moment; no long-lived +unwrapped copy is allocated. + +### Backends -- `KeyringStore` (default) — OS-native keyring; recoverable across - reinstalls when the keyring is. -- `EncryptedFileStore` — Argon2id + XChaCha20-Poly1305 over a passphrase. -- `MemoryStore` — tests only. +- **File vault (`SecretStore::file` / `EncryptedFileStore`)** — Argon2id + (memory ≥ 19 MiB, t ≥ 2, defaults 64 MiB / t=3) + XChaCha20-Poly1305 + AEAD with a random 24-byte XNonce per entry. AAD binds ciphertext to + `format_version ‖ wallet_id ‖ label` so a blob moved between slots + (or across wallets) fails the tag. A header-stored passphrase- + verification token is unsealed before any entry is touched + (mixed-key-corruption guard). The vault is ONE `serde_json` document + covering every wallet in the store — a single passphrase, a single + KDF salt, a single cross-process advisory lock (`.lock` + sidecar). Inside, entries are nested `BTreeMap>`. The file is written atomically via + `tempfile::NamedTempFile::persist` (cross-platform + replace-over-existing) at mode 0600 on Unix; rekey rotates the WHOLE + store under a fresh passphrase + salt atomically with no `.bak` + (SEC-REQ-2.2.x). One file, one passphrase, one lock — a multi-wallet + store cannot lock its other wallets out by construction. Errors + surface as the typed `FileStoreError` through `SecretStore`. +- **OS keyring (`SecretStore::os` / `default_credential_store`)** — + returns an `Arc` over the + platform's default credential store (`linux-keyutils-keyring-store` → + `dbus-secret-service-keyring-store` on Linux/FreeBSD; + `apple-native-keyring-store` on macOS; `windows-native-keyring-store` + on Windows). Fail-closed with `keyring_core::Error::NoDefaultStore` + on headless / unknown OS (SEC-REQ-2.1.3 / AR-4) — never a silent + plaintext fallback. Through `SecretStore`, keyring failures project to + `FileStoreError::OsKeyring { kind }`, a non-secret discriminant. +- **Tests** — integration tests construct a tempdir-backed + `EncryptedFileStore` directly via + `EncryptedFileStore::open(tempfile::tempdir()?.path().join("vault.pwsvault"), SecretString::new("..."))`, + or use the public `SecretStore::file(path, passphrase)` constructor. + No special feature flag is required; both are available under the default + `secrets` feature. + +Backend selection is an explicit operator decision; there is no +automatic fallback between backends. + +### Error surface + +`SecretStore` returns the typed `FileStoreError`. For the file arm this +is **lossless**: `WrongPassphrase`, `Corruption`, `Busy`, `KdfFailure`, +`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, and +`InvalidLabel` are distinct typed variants. For the OS arm, +`keyring_core::Error` projects best-effort into +`FileStoreError::OsKeyring { kind: OsKeyringErrorKind }`, a payload-free +discriminant — keyring variants carrying raw bytes (`BadEncoding`, +`BadDataFormat`) are collapsed so their bytes never enter the error +(CWE-209/CWE-532). + +The internal SPI projection `From for +keyring_core::Error` keeps the `WrongPassphrase` / `Busy` variants +recoverable: they ride in `NoStorageAccess` with the typed +`FileStoreError` boxed as the source, so an SPI-only consumer can recover +them via `err.source().and_then(|s| s.downcast_ref::())`. +The `BadStoreFormat` group (`Corruption`, `KdfFailure`, +`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, `Decrypt`, +`OsKeyring`) has no box slot and carries only a secret-free string; those +remain fully typed on the `SecretStore` path. + +Per Smythe EDIT-2, `keyring_core::Error` is safe to `Display` +(`{ }`-format), but `{:?}`-format embeds `BadEncoding(Vec)` / +`BadDataFormat(Vec, _)` payloads — those variants are NEVER +constructed by our backends with secret bytes, and +`tests/secrets_guard.rs` enforces that no debug-format pairs with +`keyring_core::Error` inside `src/secrets/`. ## What the SQLite backend WILL refuse to store @@ -51,13 +160,34 @@ secret-free. `mnemonic`, `seed`, `xpriv`, `secret`. A new column, blob field, or comment that uses any of those words breaks the test — forcing the author to either rename, or add their phrase to the file's - allow-list with a rationale. The future `src/secrets/` directory is - exempt by design. -- NFR-4 / TC-082 (`tests/sqlite_persist_roundtrip.rs::tc082_no_box_dyn_error_in_src`): + allow-list with a rationale. The `src/secrets/` directory is exempt + by design (its own positive guard below covers it). +- **`tests/secrets_guard.rs`**: positive secret-leak guard for + `src/secrets/`. Forbids logging/formatting sinks that pair with + `expose_secret(...)` on the same logical statement (SEC-REQ-4.5.1), + AND forbids `{:?}`-debug-format paired with `keyring_core::Error` + (Smythe EDIT-2). +- **`tests/secrets_api.rs`**: shape guards — `CredentialApi::get_secret` + re-wraps through `SecretBytes::new` (EDIT-1), redacting `Debug` on + `SecretBytes`/`SecretString`, no `Box` in `src/secrets/` + (TC-082 parity). +- **`tests/secrets_off_state.rs`**: runtime guard that + `--no-default-features --features sqlite,cli` builds the persister + without pulling in the `secrets` module (D4). +- **NFR-4 / TC-082** (`tests/sqlite_persist_roundtrip.rs::tc082_no_box_dyn_error_in_src`): all public method signatures use concrete error types (`WalletStorageError`, `PersistenceError`) — never `Box` — so a future leak is caught by `grep`. +The CI advisory check runs `rustsec/audit-check` over `Cargo.lock`; +because `secrets` is in the default feature set, the pinned +`argon2` / `chacha20poly1305` / `zeroize` / `subtle` / `getrandom` +(the `OsRng` source for the salt + per-entry nonces, specified as the +semver range `getrandom = "0.2"` and lock-pinned to 0.2.17 by +lock-file convention) / `region` / `keyring-core` / per-platform store +crate versions are unconditionally in the lockfile and therefore +unconditionally in audit scope (SEC-REQ-4.7). + ## Backup retention and secrets Manual / auto backups are byte-for-byte copies of the live DB. They @@ -65,3 +195,15 @@ inherit the same "no secrets in the file" invariant. Operators may still want to encrypt backups at rest using a file-system level tool (GnuPG, age, encfs); this crate does not do that for them and never ships SQLCipher. + +## Future work — maintenance CLI + +A unified `platform-wallet-storage secrets ` CLI is planned as a follow-up to give operators a way to inspect and manage the secret backends without writing custom code. Out of scope for this PR (#3672); tracked separately. Two commands matter: + +- **`secrets probe`** — set/get/delete a `__probe__` entry under `SERVICE_PREFIX`. Works uniformly on **all** backends (kernel keyutils, Secret Service, macOS Keychain, Windows Credential Manager) because it only uses single-entry CRUD. Confirms backend liveness + write-path responsiveness — the canary command for "is the keyring actually wired up on this machine?". Cheap to implement (~30 lines). +- **`secrets list [--filter ]`** — enumerate `(wallet_id, label)` pairs in the store. Trivial on the file vault (iterate the in-memory `BTreeMap`). On the OS arm: works on Secret Service, macOS Keychain, and Windows Credential Manager via `CredentialStoreApi::search`; **fails closed** with a typed `ListNotSupported` on Linux **kernel keyutils**, which has no native enumeration (the `keyring-core 1.0.0` default `search` impl returns `NotSupportedByStore` and the `linux-keyutils-keyring-store` backend doesn't override it). Operators on headless Linux who need listing must select Secret Service explicitly. + +Other planned subcommands: `secrets put