From a4ed0b59fd74a1c923f3c538689111718d8f239d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:58:46 +0200 Subject: [PATCH 01/21] feat(platform-wallet-storage): SecretString serde/schemars/is_blank (opt-in, default-off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 of the layered secret-protection feature. - `SecretString::is_blank()` — always-on inherent method, trims via the Unicode White_Space property (NBSP blanks, ZWSP does not). The enforcement primitive for the Tier-1 blank-passphrase guard and the Tier-2 blank-object-password reject. - Manual `Deserialize` behind the dedicated default-off `secret-serde` feature: routes the owned `String` through `SecretString::new` so the transient plaintext is zeroized; documents the unavoidable deserializer-input-buffer residual. NO `Serialize` companion. - Manual `JsonSchema` behind default-off `secret-schemars`: renders a plain `string`, no minLength/maxLength/pattern/format/example/default — no length or value policy leak (F-7). Hand-written (no derive), so the lock gains no `schemars_derive`. - `MIN_PASSPHRASE_LEN = 1` (coarse floor; real entropy policy is the consumer's, per GAP-012). - Cargo features: `secret-serde = ["secrets","dep:serde"]`, `secret-schemars = ["secret-serde","dep:schemars"]`; `default` unchanged (excludes both); `secrets` does NOT pull them. The gates are on the IMPLS not the dep, so they are satisfiable default-off even with `secrets` (and serde) on (GAP-002). Tests (TS-SER-001..008): is_blank truth table incl. Unicode boundary; bool-no-borrow signature; compile-time no-Serialize/no-Display assertion; GAP-002 regression (Deserialize ABSENT under `secrets` alone, PRESENT under `secret-serde`); zeroizing-roundtrip Deserialize; schema-shape leak guard. Green under default, `--features secret-serde`, `--features secret-schemars`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- Cargo.lock | 1 + .../rs-platform-wallet-storage/Cargo.toml | 14 ++ .../src/secrets/mod.rs | 2 +- .../src/secrets/secret.rs | 210 ++++++++++++++++++ 4 files changed, 226 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 2bc1b73c34..c285c240a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5230,6 +5230,7 @@ dependencies = [ "refinery", "region", "rusqlite", + "schemars 1.2.1", "serde", "serde_json", "serial_test", diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index 88d1a2b397..d8c9c8ad7c 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -61,6 +61,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 @@ -213,6 +218,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 diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index d69754429f..c8f82e161f 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -35,6 +35,6 @@ pub use file::{ SERVICE_PREFIX, }; pub use keyring::default_credential_store; -pub use secret::{SecretBytes, SecretString}; +pub use secret::{SecretBytes, SecretString, MIN_PASSPHRASE_LEN}; pub use store::SecretStore; pub use validate::WalletId; diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs index 3dd5c53746..46ca130b3f 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs @@ -15,6 +15,18 @@ use zeroize::{Zeroize, Zeroizing}; /// buffer behind — virtually impossible for any human-entered secret. const DEFAULT_CAPACITY: usize = 4096; +/// Minimal post-trim length floor for a vault passphrase or a Tier-2 +/// object password, in bytes. A **coarse** guard only: `1` means "merely +/// non-blank" (the same outcome [`SecretString::is_blank`] enforces). +/// +/// The library deliberately ships **no** password-strength estimator. The +/// real entropy policy — zxcvbn-style strength, dictionary checks, UX +/// feedback — is locale- and threat-specific and therefore the +/// **consumer's** responsibility (documented in `SECRETS.md`). Baking a +/// fixed estimator into a storage crate would be both too weak for some +/// callers and too rigid for others. +pub const MIN_PASSPHRASE_LEN: usize = 1; + /// Zeroize-on-drop wrapper for secret UTF-8 strings (BIP-39 mnemonic, /// `EncryptedFileStore` passphrase). /// @@ -87,6 +99,18 @@ impl SecretString { pub fn trimmed(&self) -> Self { Self::new(self.inner.trim().to_string()) } + + /// Whether the secret is empty or all Unicode-whitespace. + /// + /// Returns only blank-ness — never a borrowed view of the plaintext — + /// and uses [`str::trim`] (the Unicode `White_Space` property), so a + /// NBSP (`U+00A0`) trims to blank but a ZWSP (`U+200B`, not + /// `White_Space`) does not. This is the enforcement primitive behind + /// the Tier-1 blank-passphrase guard and the Tier-2 blank-object- + /// password reject. Always available — **not** feature-gated. + pub fn is_blank(&self) -> bool { + self.inner.trim().is_empty() + } } impl Default for SecretString { @@ -143,6 +167,86 @@ impl From<&str> for SecretString { } } +/// Deserialize a UTF-8 secret (a vault passphrase or a Tier-2 object +/// password arriving via config), routing the owned `String` through +/// [`SecretString::new`] — which zeroizes its source — so no +/// intermediate plaintext buffer **we own** lingers (CWE-316). +/// +/// Gated behind the dedicated, default-off `secret-serde` feature, NOT the +/// crate's internal `serde` dep (which `secrets` already pulls): the gate +/// is on the IMPL, so the impl is absent unless explicitly opted in, even +/// though `serde` itself is compiled. There is deliberately **no** +/// `Serialize` companion (a secret is read-from-config, never written +/// back / round-tripped / logged), so this type cannot leak out through +/// serde under any feature combination. +/// +/// **Residual (documented, not closeable here):** the deserializer's own +/// input buffer holds the cleartext before this visitor runs and is +/// outside `SecretString`'s ownership, so it cannot be wiped here — feed +/// secrets from a zeroizing source. Mirrors the Argon2 `Block` residual +/// noted at `crypto::derive_key`. +#[cfg(feature = "secret-serde")] +impl<'de> serde::Deserialize<'de> for SecretString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SecretStringVisitor; + + impl<'v> serde::de::Visitor<'v> for SecretStringVisitor { + type Value = SecretString; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a secret string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + // Take ownership of the borrowed bytes, then hand the owned + // `String` to the zeroizing constructor below. + self.visit_string(v.to_owned()) + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + // `SecretString::new` zeroizes the moved-in `String`. + Ok(SecretString::new(v)) + } + } + + deserializer.deserialize_string(SecretStringVisitor) + } +} + +/// Render the JSON schema as a plain `string` carrying **no** length or +/// value policy: no `minLength`/`maxLength`/`pattern`/`format` (would leak +/// a length policy) and no `example`/`default` (would embed a value) +/// (F-7). A short, value-free `description` marks sensitivity. +/// +/// Gated behind the default-off `secret-schemars` feature (which implies +/// `secret-serde`). Pulls in no `Serialize`/`Display` path. +#[cfg(feature = "secret-schemars")] +impl schemars::JsonSchema for SecretString { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("SecretString") + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("platform_wallet_storage::secrets::SecretString") + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "description": "A secret string. Write-only: never serialized, never echoed." + }) + } +} + /// Zeroize-on-drop wrapper for secret **bytes**: BIP-32 seed /// (`[u8; 64]`), xpriv, Argon2 output, AEAD key, decrypted plaintext. /// @@ -282,6 +386,112 @@ mod tests { assert_eq!(SecretString::default().len(), 0); } + /// TS-SER-001: `is_blank()` truth table. The boundary deliberately + /// exercises Unicode whitespace — `str::trim` uses the `White_Space` + /// property, so NBSP (`U+00A0`) trims to blank but ZWSP (`U+200B`, + /// not `White_Space`) does not. + #[test] + fn is_blank_truth_table() { + // Blank inputs. + assert!(SecretString::empty().is_blank()); + assert!(SecretString::new("").is_blank()); + assert!(SecretString::new(" ").is_blank()); + assert!(SecretString::new("\t\r\n ").is_blank()); + assert!( + SecretString::new("\u{00A0}").is_blank(), + "NBSP is White_Space" + ); + // Non-blank inputs. + assert!(!SecretString::new("pw").is_blank()); + assert!(!SecretString::new(" pw ").is_blank()); + assert!( + !SecretString::new("\u{200B}").is_blank(), + "ZWSP is NOT White_Space" + ); + } + + /// TS-SER-002: `is_blank` returns a `bool` and exposes no borrowed + /// plaintext, callable with only `secrets` (no serde/schemars). + #[test] + fn is_blank_signature_returns_bool_no_borrow() { + let f: fn(&SecretString) -> bool = SecretString::is_blank; + assert!(f(&SecretString::new(""))); + assert!(!f(&SecretString::new("x"))); + } + + /// TS-SER-005 / TS-SER-007: `SecretString` must never implement + /// `Serialize` or `Display`, even with serde compiled in. This is a + /// compile-time `!impl` assertion — adding either impl breaks the + /// build. `serde::Serialize` is nameable here because `secrets` always + /// pulls the `serde` dep. + #[test] + fn secret_string_has_no_serialize_no_display() { + static_assertions::assert_not_impl_any!(SecretString: serde::Serialize, std::fmt::Display); + } + + /// TS-SER-008 / GAP-002 regression: the `serde` DEP is on under + /// `secrets`, yet the `Deserialize` IMPL stays ABSENT because it is + /// gated on the dedicated `secret-serde` feature — proving the + /// default-off gate is satisfiable even while serde is compiled. + #[cfg(not(feature = "secret-serde"))] + #[test] + fn deserialize_absent_without_secret_serde_even_though_serde_dep_on() { + static_assertions::assert_not_impl_any!( + SecretString: serde::de::DeserializeOwned + ); + } + + /// TS-SER-008: with `secret-serde` on, the `Deserialize` impl is + /// present (and `Serialize` is still absent — see the always-on test). + #[cfg(feature = "secret-serde")] + #[test] + fn deserialize_present_with_secret_serde() { + static_assertions::assert_impl_all!(SecretString: serde::de::DeserializeOwned); + static_assertions::assert_not_impl_any!(SecretString: serde::Serialize); + } + + /// TS-SER-003: `Deserialize` round-trips the value through the + /// zeroizing constructor; the result `ct_eq`s a directly-built secret + /// and has the right length. + #[cfg(feature = "secret-serde")] + #[test] + fn deserialize_routes_value_through_zeroizing_constructor() { + let s: SecretString = serde_json::from_str("\"correct horse battery staple\"").unwrap(); + assert!(bool::from( + s.ct_eq(&SecretString::new("correct horse battery staple")) + )); + assert_eq!(s.len(), 28); + } + + /// TS-SER-006: `JsonSchema` renders a plain `string` and leaks no + /// length/value policy — no `minLength`/`maxLength`/`pattern`/`format`, + /// no `example`/`default`/`enum`. + #[cfg(feature = "secret-schemars")] + #[test] + fn json_schema_is_plain_string_no_policy_leak() { + let schema = schemars::schema_for!(SecretString); + let v = serde_json::to_value(&schema).unwrap(); + assert_eq!(v["type"], serde_json::json!("string")); + for forbidden in [ + "minLength", + "maxLength", + "pattern", + "format", + "example", + "default", + "enum", + ] { + assert!( + v.get(forbidden).is_none(), + "schema leaked `{forbidden}`: {v}" + ); + } + // Any description present must carry no example/secret value. + if let Some(desc) = v.get("description").and_then(|d| d.as_str()) { + assert!(!desc.contains("horse")); + } + } + #[test] fn secret_bytes_debug_redacted() { let b = SecretBytes::from_slice(&[1, 2, 3, 4, 5]); From 1827a3943a613d678ec7fff79e8faf266009a746 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:03:58 +0200 Subject: [PATCH 02/21] feat(platform-wallet-storage): error taxonomy for Tier-2 secret protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of the layered secret-protection feature. Add five `SecretStoreError` variants with redacted (secret-free) Display and `keyring_core::Error` projections: - `ExpectedProtectedButUnsealed` — L-1 keystone: caller asserted protection (supplied a password) but the stored value is unprotected → fail closed. - `NeedsPassword` — protected (scheme-1) read with no password; never returns ciphertext. - `WrongPassword` — Tier-2 object-password AEAD tag fail; distinct from the Tier-1 `WrongPassphrase`. - `BlankPassphrase` — blank vault passphrase or blank object password. - `UnsupportedEnvelopeVersion { found: u8 }` — magic present, unknown version/scheme; fail closed regardless of password (GAP-009). Projections (resolving GAP-004): the four credential/protection STATE errors ride `NoStorageAccess(boxed)` — losslessly downcast-recoverable, mirroring `WrongPassphrase`; `UnsupportedEnvelopeVersion` joins the secret-free `BadStoreFormat` group, mirroring `VersionUnsupported`. Tests (TS-ERR-001..003): variants distinct (incl. WrongPassword != WrongPassphrase, ExpectedProtectedButUnsealed != Corruption); exact secret-free Display; recoverable NoStorageAccess downcast for the four states; BadStoreFormat for the version variant. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../src/secrets/error.rs | 170 +++++++++++++++++- 1 file changed, 167 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs index 94e7375e1b..5290a39c8b 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/error.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs @@ -20,6 +20,39 @@ pub enum SecretStoreError { #[error("wrong passphrase")] WrongPassphrase, + /// Tier-2 (L-1 keystone): the caller asserted — by supplying an object + /// password — that this object MUST be password-protected, but the + /// stored value is a well-formed UNPROTECTED envelope (scheme-0) or a + /// legacy magic-less raw value, i.e. a strip/downgrade. **Fails + /// closed:** the stored bytes are NEVER returned (CWE-757/CWE-345). + #[error("expected a password-protected secret but the stored value is unprotected")] + ExpectedProtectedButUnsealed, + + /// Tier-2: a valid password-protected (scheme-1) envelope was read + /// with NO object password supplied. Never returns ciphertext. + #[error("secret is password-protected; a password is required")] + NeedsPassword, + + /// Tier-2: the object password failed the envelope's AEAD tag. Carries + /// **no** plaintext and no source (CWE-347). Distinct from + /// [`WrongPassphrase`] (the Tier-1 vault passphrase). On the + /// [`SecretStore::Os`] arm a tag failure may also indicate keychain + /// corruption rather than a wrong password — documented in + /// `SECRETS.md`; one AEAD tag cannot disambiguate the two. + /// + /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase + /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os + #[error("wrong object password")] + WrongPassword, + + /// A vault passphrase (Tier-1 `open`/`rekey`) or an object password + /// (Tier-2 enrol) was blank — empty or all-whitespace — rejected via + /// [`SecretString::is_blank`]. CWE-521. + /// + /// [`SecretString::is_blank`]: crate::secrets::SecretString::is_blank + #[error("passphrase must not be blank")] + BlankPassphrase, + /// AEAD tag failure on a stored entry (or rekey re-encrypt) *after* /// the header verify-token passed: the entry ciphertext is corrupt or /// tampered, **not** a wrong passphrase. No plaintext (CWE-347). @@ -39,6 +72,22 @@ pub enum SecretStoreError { found: u32, }, + /// A Tier-2 secret envelope carried the magic but a `version` (or, at a + /// known version, a `scheme`) this build does not understand. Fails + /// closed REGARDLESS of the password argument — an unparseable future + /// format can be neither safely unwrapped nor safely treated as + /// unprotected, so it is refused both ways (GAP-009). Mirrors + /// [`VersionUnsupported`] for the vault format. + /// + /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported + #[error("unsupported secret envelope version {found}")] + UnsupportedEnvelopeVersion { + /// The envelope `version` byte read from the (unauthenticated) + /// header. An unknown `scheme` under a known version reports the + /// known version byte (a forward-incompatible scheme). + found: u8, + }, + /// The vault file was malformed (bad magic, truncated header, bad /// record framing) — no plaintext was produced. #[error("malformed vault file")] @@ -231,11 +280,18 @@ impl From for SecretStoreError { /// seam. Lossy by design — the lossless typed path is the /// [`SecretStore`](crate::secrets::SecretStore) API. /// -/// - [`WrongPassphrase`] / [`AlreadyLocked`] ride in +/// - [`WrongPassphrase`] / [`AlreadyLocked`] and the Tier-2 credential / +/// protection states ([`NeedsPassword`], [`WrongPassword`], +/// [`ExpectedProtectedButUnsealed`], [`BlankPassphrase`]) ride in /// [`KeyringError::NoStorageAccess`] with the typed error boxed as the /// source, recoverable via /// `err.source().and_then(|s| s.downcast_ref::())`. -/// - The format/crypto group collapses into +/// These are all "the caller must act on a credential/expectation to +/// proceed" states, so lossless recovery lets an SPI consumer react +/// precisely (resolves GAP-004 for these variants). +/// - The format/crypto group — including [`UnsupportedEnvelopeVersion`] +/// (a fail-closed forward-format incompatibility, mirroring +/// [`VersionUnsupported`]) — collapses into /// [`KeyringError::BadStoreFormat`] (a static secret-free string — that /// variant has no box slot). /// - [`InvalidLabel`] → `KeyringError::Invalid("user", _)`; @@ -243,16 +299,28 @@ impl From for SecretStoreError { /// /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase /// [`AlreadyLocked`]: SecretStoreError::AlreadyLocked +/// [`NeedsPassword`]: SecretStoreError::NeedsPassword +/// [`WrongPassword`]: SecretStoreError::WrongPassword +/// [`ExpectedProtectedButUnsealed`]: SecretStoreError::ExpectedProtectedButUnsealed +/// [`BlankPassphrase`]: SecretStoreError::BlankPassphrase +/// [`UnsupportedEnvelopeVersion`]: SecretStoreError::UnsupportedEnvelopeVersion +/// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported /// [`InvalidLabel`]: SecretStoreError::InvalidLabel /// [`Io`]: SecretStoreError::Io impl From for KeyringError { fn from(e: SecretStoreError) -> Self { use SecretStoreError as E; match e { - E::WrongPassphrase | E::AlreadyLocked => KeyringError::NoStorageAccess(Box::new(e)), + E::WrongPassphrase + | E::AlreadyLocked + | E::NeedsPassword + | E::WrongPassword + | E::ExpectedProtectedButUnsealed + | E::BlankPassphrase => KeyringError::NoStorageAccess(Box::new(e)), E::Corruption | E::KdfFailure | E::VersionUnsupported { .. } + | E::UnsupportedEnvelopeVersion { .. } | E::MalformedVault | E::InsecurePermissions { .. } | E::InsecureParentDir { .. } @@ -386,6 +454,102 @@ mod tests { assert!(!format!("{k}").contains("plaintext")); } + /// TS-ERR-001: the five new variants exist, are constructable, render + /// distinct non-empty messages, and the Tier-2 `WrongPassword` is NOT + /// the Tier-1 `WrongPassphrase` (nor is the unseal error `Corruption`). + #[test] + fn new_variants_exist_and_are_distinct() { + use SecretStoreError as E; + assert_ne!(E::WrongPassword.to_string(), E::WrongPassphrase.to_string()); + assert_ne!( + E::ExpectedProtectedButUnsealed.to_string(), + E::Corruption.to_string() + ); + let msgs: std::collections::HashSet = [ + E::NeedsPassword.to_string(), + E::WrongPassword.to_string(), + E::BlankPassphrase.to_string(), + E::ExpectedProtectedButUnsealed.to_string(), + E::UnsupportedEnvelopeVersion { found: 2 }.to_string(), + ] + .into_iter() + .collect(); + assert_eq!(msgs.len(), 5, "all five messages must be distinct"); + } + + /// TS-ERR-002: Display + Debug render static, secret-free text. The + /// version variant surfaces the (non-secret) version byte and nothing + /// more. + #[test] + fn new_variants_carry_no_secret_in_display() { + use SecretStoreError as E; + assert_eq!( + E::NeedsPassword.to_string(), + "secret is password-protected; a password is required" + ); + assert_eq!(E::WrongPassword.to_string(), "wrong object password"); + assert_eq!( + E::BlankPassphrase.to_string(), + "passphrase must not be blank" + ); + assert_eq!( + E::ExpectedProtectedButUnsealed.to_string(), + "expected a password-protected secret but the stored value is unprotected" + ); + assert_eq!( + E::UnsupportedEnvelopeVersion { found: 7 }.to_string(), + "unsupported secret envelope version 7" + ); + // Debug is non-empty and free of plaintext-ish tokens for all. + for e in [ + E::NeedsPassword, + E::WrongPassword, + E::BlankPassphrase, + E::ExpectedProtectedButUnsealed, + E::UnsupportedEnvelopeVersion { found: 7 }, + ] { + let rendered = format!("{e} {e:?}"); + assert!(!rendered.contains("plaintext")); + } + } + + /// TS-ERR-003 (resolving GAP-004): the four Tier-2 credential / + /// protection states project to a recoverable `NoStorageAccess` with + /// the typed error losslessly downcast-able, leaking no secret. + #[test] + fn tier2_state_errors_project_to_recoverable_no_storage_access() { + for original in [ + SecretStoreError::NeedsPassword, + SecretStoreError::WrongPassword, + SecretStoreError::ExpectedProtectedButUnsealed, + SecretStoreError::BlankPassphrase, + ] { + let want = original.to_string(); + let k: KeyringError = original.into(); + assert!(!format!("{k}").contains("plaintext")); + match &k { + KeyringError::NoStorageAccess(src) => { + let recovered = src.downcast_ref::(); + assert!( + matches!(recovered, Some(e) if e.to_string() == want), + "expected recoverable {want}, got {recovered:?}" + ); + } + other => panic!("expected NoStorageAccess for {want}, got {other:?}"), + } + } + } + + /// TS-ERR-003: `UnsupportedEnvelopeVersion` projects to the + /// secret-free `BadStoreFormat` group (forward-format incompat, + /// mirroring `VersionUnsupported`). + #[test] + fn unsupported_envelope_version_projects_to_bad_store_format() { + let k: KeyringError = SecretStoreError::UnsupportedEnvelopeVersion { found: 9 }.into(); + assert!(matches!(k, KeyringError::BadStoreFormat(_))); + assert!(!format!("{k}").contains("plaintext")); + } + #[test] fn os_keyring_projects_to_bad_store_format() { let k: KeyringError = SecretStoreError::OsKeyring { From f68f00e62a434206b5835c551a917e88374c566d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:12:40 +0200 Subject: [PATCH 03/21] feat(platform-wallet-storage): Tier-2 secret envelope (wrap/unwrap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 of the layered secret-protection feature. New `secrets::envelope` module — backend-independent, sits above SecretStore over both arms. Wire format: magic b"PWSEV" + ENVELOPE_VERSION:u8 + scheme:u8 (0 unprotected passthrough / 1 Argon2id+XChaCha20-Poly1305 password); scheme-1 body = kdf(id,m_kib,t,p LE) + salt[32] + nonce[24] + ct+tag. Reuses crypto::{derive_key,seal,open,random_bytes,KdfParams,enforce_bounds} (no bespoke crypto); `crypto`/`format` widened to pub(super) so the sibling envelope module can share them without duplication. Guardrails: - L-2: KDF param ceiling enforced BEFORE derivation on the untrusted header (enforce_bounds gates pre-alloc). - L-3: AAD binds domain ‖ magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ wallet_id ‖ label (length-prefixed), mirroring format::aad/verify_aad — relocation/header-tamper fail the tag. - SEC-F006/GAP-006: plaintext capped at MAX_SECRET_LEN−MAX_ENVELOPE_OVERHEAD (=65408), uniform across schemes, so enveloped bytes always fit the backend cap. Re-exported as pub `MAX_PLAINTEXT_LEN`. - GAP-009: magic-present unknown version/scheme → UnsupportedEnvelopeVersion, fail closed regardless of password. - Legacy-tolerant read (adopted §4.1 contingency): magic-less + None → raw bytes (+ one-time warn, re-wrapped on next write); magic-less + Some(pw) → ExpectedProtectedButUnsealed (L-1 preserved). The strict fail-closed quadrant lives in `unwrap` (the L-1 keystone is proven against it and wired into the store in the next task). Tests (TS-ENV-001..010): scheme-0/1 round-trips, fresh salt+nonce, WrongPassword, identity-AAD relocation rejection, per-field header tamper, ★ KDF-ceiling-before-derive (no OOM), blank-password reject, plaintext size cap (v5 boundary), magic/version discrimination, and a 2000-iteration deterministic byte-fuzz + full truncation sweep that never panics and never leaks plaintext from a tag-failing branch. NOTE: a scoped `#![allow(dead_code)]` covers the not-yet-wired primitives; the next task wires them into SecretStore and removes it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../src/secrets/envelope.rs | 744 ++++++++++++++++++ .../src/secrets/file/mod.rs | 9 +- .../src/secrets/mod.rs | 2 + 3 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/src/secrets/envelope.rs diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs new file mode 100644 index 0000000000..958ab57966 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -0,0 +1,744 @@ +//! Tier-2 opt-in per-object password envelope (backend-independent). +//! +//! Sits ABOVE [`SecretStore`](crate::secrets::SecretStore), over both the +//! `File` vault and `Os` keyring arms: the backend stores opaque bytes, +//! and a chosen critical object (a seed wallet, a single privkey) can be +//! wrapped under an extra, user-supplied **object password** before it +//! ever reaches the backend. Reading a protected object then needs BOTH +//! backend access AND the password — the first control that survives a +//! full backend compromise (the keychain scraped, the vault stolen and its +//! passphrase cracked). +//! +//! # Wire format (self-describing, authenticated) +//! +//! ```text +//! magic b"PWSEV" (5) +//! version u8 = 1 (ENVELOPE_VERSION — independent of the vault FORMAT_VERSION) +//! scheme u8 (0 = unprotected passthrough, 1 = argon2id-xchacha password) +//! ── scheme 0 ── payload: raw secret bytes +//! ── scheme 1 ── kdf(id u8 ‖ m_kib u32 LE ‖ t u32 LE ‖ p u32 LE) (13) +//! ‖ salt[32] ‖ nonce[24] ‖ ciphertext+tag +//! ``` +//! +//! The header proves what the blob **is**, never what the caller +//! **expected** — that expectation lives solely in the caller's `Some/None` +//! password argument (see [`unwrap`]'s strict, fail-closed table). The +//! self-description is a convenience for `NeedsPassword`/`WrongPassword`/ +//! version UX, **not** the security boundary. +//! +//! ## Reused, never reinvented +//! - KDF: [`crypto::derive_key`] (Argon2id) with a fresh 32-byte salt; the +//! param **ceiling is enforced BEFORE derivation** on the +//! attacker-controllable header (L-2, [`KdfParams::enforce_bounds`]). +//! - AEAD: [`crypto::seal`]/[`crypto::open`] (XChaCha20-Poly1305), fresh +//! per-wrap nonce; a tag failure maps to +//! [`SecretStoreError::WrongPassword`] with no plaintext. +//! - AAD binds `domain ‖ magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ wallet_id +//! ‖ label` (L-3), mirroring [`format::aad`]/[`format::verify_aad`] so a +//! relocated/confused blob fails the tag. +//! +//! No bespoke crypto. +//! +//! [`format::aad`]: super::file::format::aad +//! [`format::verify_aad`]: super::file::format::verify_aad + +// The wrap/unwrap primitives are exercised by this module's own tests but +// are not yet called from non-test code: the strict-read wiring into +// `SecretStore::get_secret`/`set_secret` lands in the next task, which +// removes this allow. Without it the not-yet-wired primitives would trip +// dead-code warnings in the non-test build. +#![allow(dead_code)] + +use std::sync::Once; + +use super::error::SecretStoreError; +use super::file::crypto::{self, KdfParams, NONCE_LEN, SALT_LEN}; +use super::secret::{SecretBytes, SecretString}; +use super::validate::WalletId; +use super::MAX_SECRET_LEN; + +/// 5-byte sentinel marking a Tier-2 envelope. A decrypted entry NOT +/// starting with this is a legacy magic-less raw value (see [`unwrap`]). +pub(crate) const MAGIC: &[u8; 5] = b"PWSEV"; + +/// Envelope wire version — bumped only on a breaking layout change, and +/// independent of the vault `FORMAT_VERSION` (the envelope rides inside the +/// entry bytes, identical over File/Os). +pub(crate) const ENVELOPE_VERSION: u8 = 1; + +/// Scheme 0: unprotected passthrough — payload is the raw secret. +pub(crate) const SCHEME_UNPROTECTED: u8 = 0; +/// Scheme 1: Argon2id + XChaCha20-Poly1305 under an object password. +pub(crate) const SCHEME_PASSWORD: u8 = 1; + +/// Domain-separation tag leading the scheme-1 AAD, so a Tier-2 tag can +/// never be confused with the vault's own verify/entry AAD. +const TIER2_DOMAIN: &[u8] = b"PWSEV-TIER2-AAD-v1"; + +/// Fixed header: `magic ‖ version ‖ scheme`. +const HEADER_LEN: usize = MAGIC.len() + 2; +/// Encoded KDF-params field: `id u8 ‖ m_kib u32 ‖ t u32 ‖ p u32`. +const KDF_FIELD_LEN: usize = 1 + 4 + 4 + 4; +/// Poly1305 tag length — present even for empty plaintext. +const AEAD_TAG_LEN: usize = 16; +/// Smallest valid scheme-1 body (kdf ‖ salt ‖ nonce ‖ bare tag). +const MIN_SCHEME1_BODY: usize = KDF_FIELD_LEN + SALT_LEN + NONCE_LEN + AEAD_TAG_LEN; + +/// Fixed, bounded envelope overhead (`magic 5 + version 1 + scheme 1 + kdf +/// 13 + salt 32 + nonce 24 + tag 16 = 92`), rounded up to 128 for headroom +/// (future header fields / versions). Used to derive the plaintext cap. +pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 128; + +/// Plaintext cap at the envelope boundary: `MAX_SECRET_LEN − +/// MAX_ENVELOPE_OVERHEAD`. Capping the **plaintext** (uniformly for both +/// schemes) keeps the user-visible limit stable AND guarantees the +/// enveloped bytes always fit the backend vault's own `MAX_SECRET_LEN` +/// `put_bytes` cap (design §4.6 / SEC-F006 / GAP-006). Re-exported at +/// [`crate::secrets`] as the documented, stable user-facing cap. +pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; + +/// Wrap `plaintext` for `(wallet_id, label)` using the shipped default +/// Argon2 target (64 MiB / t=3) when a password is supplied. +/// +/// `None` → an unprotected (scheme-0) envelope; `Some(pw)` → a scheme-1 +/// envelope sealed under `pw`. A blank password is rejected at enrol +/// ([`SecretStoreError::BlankPassphrase`]). +pub(crate) fn wrap( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + plaintext: &[u8], +) -> Result, SecretStoreError> { + wrap_with_params( + wallet_id, + label, + password, + plaintext, + KdfParams::default_target(), + ) +} + +/// [`wrap`] with explicit Argon2 `params` (tests use the floor params for +/// speed; production uses [`KdfParams::default_target`]). `params` is +/// ignored when `password` is `None`. +pub(crate) fn wrap_with_params( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + plaintext: &[u8], + params: KdfParams, +) -> Result, SecretStoreError> { + // Cap the PLAINTEXT (before overhead) uniformly for both schemes so the + // enveloped bytes always fit the backend cap and the limit is stable. + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + + let Some(pw) = password else { + // Scheme 0: magic ‖ version ‖ scheme ‖ raw payload. + let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len()); + out.extend_from_slice(MAGIC); + out.push(ENVELOPE_VERSION); + out.push(SCHEME_UNPROTECTED); + out.extend_from_slice(plaintext); + return Ok(out); + }; + + // Reject a blank object password BEFORE any derivation (SEC-J). + if pw.is_blank() { + return Err(SecretStoreError::BlankPassphrase); + } + + // Fresh per-object salt so the same password on two objects yields + // different keys and precomputation is defeated. + let mut salt = [0u8; SALT_LEN]; + crypto::random_bytes(&mut salt)?; + // `derive_key` enforces the param bounds before allocating. + let key = crypto::derive_key(pw, &salt, params)?; + let aad = scheme1_aad(¶ms, &salt, wallet_id.as_bytes(), label); + let (nonce, ciphertext) = crypto::seal(&key, &aad, plaintext)?; + + let mut out = + Vec::with_capacity(HEADER_LEN + KDF_FIELD_LEN + SALT_LEN + NONCE_LEN + ciphertext.len()); + out.extend_from_slice(MAGIC); + out.push(ENVELOPE_VERSION); + out.push(SCHEME_PASSWORD); + out.extend_from_slice(&encode_kdf(¶ms)); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Unwrap `blob` for `(wallet_id, label)`, applying the **strict, +/// fail-closed** read (the L-1 keystone). The "expected-protected" bit is +/// the caller's assertion, surfaced solely by `password`, and is NEVER +/// inferred from the blob's scheme byte. +/// +/// | `password` | stored blob | result | +/// |---|---|---| +/// | `Some(pw)` | valid scheme-1 | secret, or [`WrongPassword`] on tag fail | +/// | `Some(pw)` | scheme-0 **or** magic-less (legacy raw) | [`ExpectedProtectedButUnsealed`] ★ | +/// | `Some(pw)` | scheme-1 but too short | [`Corruption`] (sealed-but-broken) | +/// | `Some/None` | magic present, unknown version/scheme | [`UnsupportedEnvelopeVersion`] | +/// | `None` | valid scheme-1 | [`NeedsPassword`] (never ciphertext) | +/// | `None` | scheme-0 | secret | +/// | `None` | magic-less (legacy raw) | secret (+ one-time warn; re-wrapped on next write) | +/// | `None` | magic present but truncated header | [`Corruption`] | +/// +/// The load-bearing row is `Some(pw)` + non-envelope ⇒ +/// [`ExpectedProtectedButUnsealed`]: with a password in hand, a +/// non-protected blob can only mean a strip → refuse, return no bytes. +/// +/// [`WrongPassword`]: SecretStoreError::WrongPassword +/// [`ExpectedProtectedButUnsealed`]: SecretStoreError::ExpectedProtectedButUnsealed +/// [`Corruption`]: SecretStoreError::Corruption +/// [`UnsupportedEnvelopeVersion`]: SecretStoreError::UnsupportedEnvelopeVersion +/// [`NeedsPassword`]: SecretStoreError::NeedsPassword +pub(crate) fn unwrap( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + blob: &[u8], +) -> Result { + // Magic-less ⇒ a legacy unprotected raw value (scheme-0-equivalent), + // per the adopted §4.1 read-path contingency. + if !blob.starts_with(MAGIC) { + return match password { + None => { + warn_legacy_once(); + Ok(SecretBytes::from_slice(blob)) + } + // Caller asserted protection but found a magic-less raw value: + // a strip/downgrade ⇒ FAIL CLOSED (L-1). Never returns bytes. + Some(_) => Err(SecretStoreError::ExpectedProtectedButUnsealed), + }; + } + + // Magic present but truncated before version+scheme: a broken envelope. + if blob.len() < HEADER_LEN { + return Err(SecretStoreError::Corruption); + } + + let version = blob[MAGIC.len()]; + if version != ENVELOPE_VERSION { + // Fail closed regardless of password — an unparseable future format + // can be neither safely unwrapped nor treated as scheme-0 (GAP-009). + return Err(SecretStoreError::UnsupportedEnvelopeVersion { found: version }); + } + + let scheme = blob[MAGIC.len() + 1]; + let body = &blob[HEADER_LEN..]; + match scheme { + SCHEME_UNPROTECTED => match password { + None => Ok(SecretBytes::from_slice(body)), + // Strip: caller expected protection, blob is unprotected (L-1). + Some(_) => Err(SecretStoreError::ExpectedProtectedButUnsealed), + }, + SCHEME_PASSWORD => match password { + None => Err(SecretStoreError::NeedsPassword), + Some(pw) => unwrap_scheme1(wallet_id, label, pw, body), + }, + // Unknown scheme under a known version ⇒ forward-incompatible + // layout; report the (known) version byte. Fail closed. + _ => Err(SecretStoreError::UnsupportedEnvelopeVersion { found: version }), + } +} + +/// Decrypt a scheme-1 body. The KDF params, salt, and nonce are all read +/// from the (attacker-controllable) header; the param **ceiling is +/// enforced before** [`crypto::derive_key`] allocates (L-2), and every +/// header field that feeds key/AAD is bound into the AAD so any in-place +/// edit fails the tag (L-3). +fn unwrap_scheme1( + wallet_id: &WalletId, + label: &str, + password: &SecretString, + body: &[u8], +) -> Result { + if body.len() < MIN_SCHEME1_BODY { + // The scheme byte says protected, but the body cannot hold a sealed + // payload — corrupt, not a strip. + return Err(SecretStoreError::Corruption); + } + let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); + // L-2: gate the inflated/unknown header BEFORE any derivation/alloc. + kdf.enforce_bounds()?; + + let mut salt = [0u8; SALT_LEN]; + salt.copy_from_slice(&body[KDF_FIELD_LEN..KDF_FIELD_LEN + SALT_LEN]); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&body[KDF_FIELD_LEN + SALT_LEN..KDF_FIELD_LEN + SALT_LEN + NONCE_LEN]); + let ciphertext = &body[KDF_FIELD_LEN + SALT_LEN + NONCE_LEN..]; + + let aad = scheme1_aad(&kdf, &salt, wallet_id.as_bytes(), label); + let key = crypto::derive_key(password, &salt, kdf)?; + match crypto::open(&key, &nonce, &aad, ciphertext) { + Ok(plaintext) => Ok(plaintext), + // Tag failure (wrong password, relocated blob, or header tamper): + // no plaintext is ever materialized (CWE-347). + Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassword), + Err(e) => Err(e), + } +} + +/// Build the scheme-1 AAD binding object identity + header (L-3), +/// length-prefixed for the variable fields, mirroring +/// [`format::aad`](super::file::format::aad)/`verify_aad`. +fn scheme1_aad( + kdf: &KdfParams, + salt: &[u8; SALT_LEN], + wallet_id: &[u8; 32], + label: &str, +) -> Vec { + let lb = label.as_bytes(); + let mut v = Vec::with_capacity( + TIER2_DOMAIN.len() + + MAGIC.len() + + 2 + + KDF_FIELD_LEN + + 4 + + SALT_LEN + + 4 + + wallet_id.len() + + 4 + + lb.len(), + ); + v.extend_from_slice(TIER2_DOMAIN); + v.extend_from_slice(MAGIC); + v.push(ENVELOPE_VERSION); + v.push(SCHEME_PASSWORD); + v.extend_from_slice(&encode_kdf(kdf)); + v.extend_from_slice(&(salt.len() as u32).to_le_bytes()); + v.extend_from_slice(salt); + v.extend_from_slice(&(wallet_id.len() as u32).to_le_bytes()); + v.extend_from_slice(wallet_id); + v.extend_from_slice(&(lb.len() as u32).to_le_bytes()); + v.extend_from_slice(lb); + v +} + +/// Encode KDF params to the fixed 13-byte header field (LE). +fn encode_kdf(kdf: &KdfParams) -> [u8; KDF_FIELD_LEN] { + let mut out = [0u8; KDF_FIELD_LEN]; + out[0] = kdf.id; + out[1..5].copy_from_slice(&kdf.m_kib.to_le_bytes()); + out[5..9].copy_from_slice(&kdf.t.to_le_bytes()); + out[9..13].copy_from_slice(&kdf.p.to_le_bytes()); + out +} + +/// Decode the fixed 13-byte KDF header field. Out-of-range values are +/// caught downstream by [`KdfParams::enforce_bounds`]. +fn decode_kdf(b: &[u8]) -> KdfParams { + debug_assert_eq!(b.len(), KDF_FIELD_LEN); + KdfParams { + id: b[0], + m_kib: u32::from_le_bytes([b[1], b[2], b[3], b[4]]), + t: u32::from_le_bytes([b[5], b[6], b[7], b[8]]), + p: u32::from_le_bytes([b[9], b[10], b[11], b[12]]), + } +} + +/// Emit a single process-lifetime warning that a legacy magic-less entry +/// was read. Carries no secret (the message is static). +fn warn_legacy_once() { + static WARN: Once = Once::new(); + WARN.call_once(|| { + tracing::warn!( + "read a legacy unprotected secret entry with no envelope header; \ + it will be re-wrapped on the next write" + ); + }); +} + +#[cfg(test)] +mod tests { + use subtle::ConstantTimeEq; + + use super::super::file::crypto::{ + ARGON2_MAX_M_KIB, ARGON2_MAX_T, ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P, + }; + use super::super::file::format::KDF_ID_ARGON2ID; + use super::*; + + // Wire offsets into a scheme-1 envelope (for surgical tampering). + const O_VERSION: usize = 5; + const O_SCHEME: usize = 6; + const O_KDF: usize = HEADER_LEN; // 7 + const O_ID: usize = O_KDF; // 7 + const O_MKIB: usize = O_KDF + 1; // 8 + const O_T: usize = O_KDF + 5; // 12 + const O_SALT: usize = O_KDF + KDF_FIELD_LEN; // 20 + const O_NONCE: usize = O_SALT + SALT_LEN; // 52 + + fn wid(b: u8) -> WalletId { + WalletId::from([b; 32]) + } + + /// Argon2id floor params — fast enough for unit tests. + fn floor() -> KdfParams { + KdfParams { + id: KDF_ID_ARGON2ID, + m_kib: ARGON2_MIN_M_KIB, + t: ARGON2_MIN_T, + p: ARGON2_P, + } + } + + fn pw(s: &str) -> SecretString { + SecretString::new(s) + } + + /// TS-ENV-001: scheme-0 passthrough round-trip; the wrapped form leads + /// with magic, version=1, scheme=0, then the raw payload. + #[test] + fn scheme0_passthrough_round_trip() { + let secret = b"top secret seed bytes"; + let blob = wrap(&wid(1), "seed", None, secret).unwrap(); + assert!(blob.starts_with(MAGIC)); + assert_eq!(blob[O_VERSION], 1); + assert_eq!(blob[O_SCHEME], 0); + assert_eq!(&blob[HEADER_LEN..], secret); + let got = unwrap(&wid(1), "seed", None, &blob).unwrap(); + assert_eq!(got.expose_secret(), secret); + } + + /// TS-ENV-002: scheme-1 round-trip; header records the argon2id id, a + /// 32-byte fresh salt and 24-byte nonce, ct != pt, and two wraps of the + /// same secret/pw differ in salt+nonce (no reuse). + #[test] + fn scheme1_round_trip_and_fresh_salt_nonce() { + let secret = b"correct horse battery staple seed"; + let p = pw("hunter2-but-better"); + let blob = wrap_with_params(&wid(7), "seed", Some(&p), secret, floor()).unwrap(); + assert!(blob.starts_with(MAGIC)); + assert_eq!(blob[O_VERSION], 1); + assert_eq!(blob[O_SCHEME], 1); + assert_eq!(blob[O_ID], KDF_ID_ARGON2ID); + // ciphertext differs from plaintext. + assert_ne!(&blob[O_NONCE + NONCE_LEN..], secret); + + let got = unwrap(&wid(7), "seed", Some(&p), &blob).unwrap(); + assert_eq!(got.expose_secret(), secret); + + let blob2 = wrap_with_params(&wid(7), "seed", Some(&p), secret, floor()).unwrap(); + assert_ne!( + &blob[O_SALT..O_SALT + SALT_LEN], + &blob2[O_SALT..O_SALT + SALT_LEN], + "salt must be fresh per wrap" + ); + assert_ne!( + &blob[O_NONCE..O_NONCE + NONCE_LEN], + &blob2[O_NONCE..O_NONCE + NONCE_LEN], + "nonce must be fresh per wrap" + ); + } + + /// TS-ENV-003: wrong object password → WrongPassword, no plaintext. + #[test] + fn wrong_password_fails_closed() { + let blob = wrap_with_params(&wid(1), "seed", Some(&pw("right")), b"seed", floor()).unwrap(); + let err = unwrap(&wid(1), "seed", Some(&pw("wrong")), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TS-ENV-004: identity AAD (L-3) — a protected blob unwrapped at any + /// other (wallet, label) fails the tag; same-identity still succeeds. + #[test] + fn relocation_across_identity_is_rejected() { + let p = pw("pw"); + let blob = wrap_with_params(&wid(0xA), "labelA", Some(&p), b"seed", floor()).unwrap(); + for (w, l) in [(0xB, "labelB"), (0xA, "labelB"), (0xB, "labelA")] { + let err = unwrap(&wid(w), l, Some(&p), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "relocation to ({w:#x},{l}) must fail, got {err:?}" + ); + } + let ok = unwrap(&wid(0xA), "labelA", Some(&p), &blob).unwrap(); + assert_eq!(ok.expose_secret(), b"seed"); + } + + /// TS-ENV-005: per-field header tamper. Unknown KDF id is rejected by + /// `enforce_bounds` (KdfFailure) before derive; in-bounds KDF shifts, + /// salt, and nonce all fail the AEAD tag (WrongPassword) — never the + /// plaintext. + #[test] + fn header_tamper_fails_closed_per_field() { + let p = pw("pw"); + let base = wrap_with_params(&wid(1), "seed", Some(&p), b"seed", floor()).unwrap(); + + // kdf.id → 7 (unknown) ⇒ KdfFailure (bounds reject pre-derive). + let mut b = base.clone(); + b[O_ID] = 7; + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), + SecretStoreError::KdfFailure + )); + + // kdf.m_kib → a different IN-BOUNDS value ⇒ WrongPassword (AAD + key). + let mut b = base.clone(); + b[O_MKIB..O_MKIB + 4].copy_from_slice(&(ARGON2_MIN_M_KIB + 1024).to_le_bytes()); + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), + SecretStoreError::WrongPassword + )); + + // kdf.t → a different IN-BOUNDS value ⇒ WrongPassword. + let mut b = base.clone(); + b[O_T..O_T + 4].copy_from_slice(&(ARGON2_MIN_T + 1).to_le_bytes()); + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), + SecretStoreError::WrongPassword + )); + + // salt[0] flip ⇒ WrongPassword (wrong key + AAD-bound salt). + let mut b = base.clone(); + b[O_SALT] ^= 1; + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), + SecretStoreError::WrongPassword + )); + + // nonce[0] flip ⇒ WrongPassword (nonce feeds decrypt ⇒ tag fail). + let mut b = base; + b[O_NONCE] ^= 1; + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), + SecretStoreError::WrongPassword + )); + } + + /// TS-ENV-006 ★ (L-2): an inflated KDF param on a forged header is + /// rejected by `enforce_bounds` BEFORE `derive_key` allocates — the + /// ~4 TiB allocation never happens (the test would OOM if it did). The + /// exact ceilings remain valid params. + #[test] + fn kdf_ceiling_enforced_before_derivation() { + let p = pw("pw"); + let base = wrap_with_params(&wid(1), "seed", Some(&p), b"seed", floor()).unwrap(); + + // m_kib = u32::MAX ⇒ KdfFailure, no allocation. + let mut b = base.clone(); + b[O_MKIB..O_MKIB + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), + SecretStoreError::KdfFailure + )); + + // t = ARGON2_MAX_T + 1 ⇒ KdfFailure. + let mut b = base; + b[O_T..O_T + 4].copy_from_slice(&(ARGON2_MAX_T + 1).to_le_bytes()); + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), + SecretStoreError::KdfFailure + )); + + // The exact ceilings are accepted by the bounds check (no derive + // here — a 1 GiB Argon2 run is not a unit-test concern). + assert!(KdfParams { + id: KDF_ID_ARGON2ID, + m_kib: ARGON2_MAX_M_KIB, + t: ARGON2_MAX_T, + p: ARGON2_P, + } + .enforce_bounds() + .is_ok()); + } + + /// TS-ENV-007: a blank object password is rejected at enrol; nothing + /// is sealed. + #[test] + fn blank_object_password_rejected_at_enrol() { + for blank in [SecretString::empty(), pw(""), pw(" "), pw("\t\n")] { + let err = + wrap_with_params(&wid(1), "seed", Some(&blank), b"seed", floor()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "got {err:?}" + ); + } + } + + /// TS-ENV-008 (SEC-F006 / GAP-006, v5 cap): the plaintext cap is + /// `MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD` (NOT `MAX_SECRET_LEN` as + /// the v4 spec literally read), uniform across schemes, so the + /// enveloped bytes always fit the backend vault cap. Accept at the cap, + /// reject at cap+1 with `max = MAX_PLAINTEXT_LEN`. + #[test] + fn plaintext_size_cap_at_envelope_boundary() { + let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let over = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1]; + + // Unprotected (scheme 0): cap accepted, +1 rejected. + assert!(wrap(&wid(1), "seed", None, &at_cap).is_ok()); + assert!(matches!( + wrap(&wid(1), "seed", None, &over).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + + // Protected (scheme 1): same cap (checked before any derivation). + let p = pw("pw"); + assert!(matches!( + wrap_with_params(&wid(1), "seed", Some(&p), &over, floor()).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + // The enveloped bytes for an at-cap plaintext fit the backend cap. + let enveloped = wrap(&wid(1), "seed", None, &at_cap).unwrap(); + assert!(enveloped.len() <= MAX_SECRET_LEN); + } + + /// TS-ENV-010 (adopted §4.1 legacy-tolerant contingency): magic/version + /// discrimination. A magic-less blob is a legacy raw value — returned + /// on `None`, refused fail-closed on `Some(pw)`. A magic-present blob + /// with an unknown version fails closed both ways; truncated-after-magic + /// is corruption. + #[test] + fn magic_and_version_discrimination() { + let p = pw("pw"); + // (a) Magic-less / wrong magic. + let legacy = b"NOTPWSEV raw legacy seed bytes".to_vec(); + // None ⇒ legacy raw bytes (adopted contingency; NOT Corruption). + let got = unwrap(&wid(1), "seed", None, &legacy).unwrap(); + assert_eq!(got.expose_secret(), &legacy[..]); + // Some(pw) ⇒ strip/downgrade ⇒ fail closed (L-1 preserved). + assert!(matches!( + unwrap(&wid(1), "seed", Some(&p), &legacy).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + + // (b) Magic present but truncated below the header ⇒ Corruption. + let mut trunc = MAGIC.to_vec(); + trunc.push(ENVELOPE_VERSION); // no scheme byte + assert!(matches!( + unwrap(&wid(1), "seed", None, &trunc).unwrap_err(), + SecretStoreError::Corruption + )); + + // (c) Magic OK but version = 2 ⇒ UnsupportedEnvelopeVersion{2}, + // regardless of password (GAP-009). + let mut v2 = wrap(&wid(1), "seed", None, b"x").unwrap(); + v2[O_VERSION] = 2; + for arg in [None, Some(&p)] { + assert!(matches!( + unwrap(&wid(1), "seed", arg, &v2).unwrap_err(), + SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } + )); + } + + // (d) Magic+version OK but unknown scheme = 9 ⇒ fail closed. + let mut s9 = wrap(&wid(1), "seed", None, b"x").unwrap(); + s9[O_SCHEME] = 9; + assert!(matches!( + unwrap(&wid(1), "seed", None, &s9).unwrap_err(), + SecretStoreError::UnsupportedEnvelopeVersion { found: 1 } + )); + } + + /// Non-vacuity helper for the L-1 keystone (used here and by the store + /// tests): a scheme-0 blob carrying `secret` DOES decode under `None`. + #[test] + fn scheme0_some_password_fails_closed_strip() { + let blob = wrap(&wid(1), "seed", None, b"attacker-seed").unwrap(); + // None ⇒ it WOULD decode to the (attacker) bytes… + assert_eq!( + unwrap(&wid(1), "seed", None, &blob) + .unwrap() + .expose_secret(), + b"attacker-seed" + ); + // …but Some(pw) ⇒ ExpectedProtectedButUnsealed, no bytes (L-1). + assert!(matches!( + unwrap(&wid(1), "seed", Some(&pw("pw")), &blob).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + } + + /// `ct_eq` sanity: a round-tripped secret matches the original under a + /// constant-time compare (no `==` on secret bytes). + #[test] + fn round_trip_is_constant_time_equal() { + let p = pw("pw"); + let original = SecretBytes::from_slice(b"seed material"); + let blob = + wrap_with_params(&wid(1), "seed", Some(&p), original.expose_secret(), floor()).unwrap(); + let got = unwrap(&wid(1), "seed", Some(&p), &blob).unwrap(); + assert!(bool::from(got.ct_eq(&original))); + } + + /// TS-ENV-009: deterministic byte-level fuzz. Every mutant unwrap is a + /// clean `Ok` or a TYPED `SecretStoreError` — never a panic, never + /// plaintext from a tag-failing branch. The `None` path (no Argon2 + /// derivation) runs the full 2000 mutants + every truncation; the + /// `Some(pw)` path — each mutant of which may trigger a real Argon2 + /// derive — runs a representative subset so the suite stays fast while + /// still exercising the derive/open code path. + #[test] + fn fuzz_byte_mutation_never_panics() { + let p = pw("fuzz-pw"); + let valid = wrap_with_params(&wid(0xAB), "seed", Some(&p), b"seed-bytes", floor()).unwrap(); + // The pristine envelope unwraps. + assert_eq!( + unwrap(&wid(0xAB), "seed", Some(&p), &valid) + .unwrap() + .expose_secret(), + b"seed-bytes" + ); + + // xorshift32 — deterministic, std-only. + let mut state: u32 = 0x9E37_79B9; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + + let assert_typed = |arg: Option<&SecretString>, buf: &[u8]| { + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unwrap(&wid(0xAB), "seed", arg, buf) + })) + .expect("unwrap must never panic on hostile input"); + match res { + Ok(_) + | Err(SecretStoreError::Corruption) + | Err(SecretStoreError::WrongPassword) + | Err(SecretStoreError::NeedsPassword) + | Err(SecretStoreError::ExpectedProtectedButUnsealed) + | Err(SecretStoreError::UnsupportedEnvelopeVersion { .. }) + | Err(SecretStoreError::KdfFailure) => {} + Err(other) => panic!("unexpected error variant: {other:?}"), + } + }; + + for i in 0..2_000 { + let mut buf = valid.clone(); + let flips = 1 + (next() % 4) as usize; + for _ in 0..flips { + let idx = (next() as usize) % buf.len(); + buf[idx] ^= (next() & 0xFF) as u8; + } + // None path every iteration (cheap, no derive). + assert_typed(None, &buf); + // Some path on a representative subset (each may derive Argon2). + if i % 16 == 0 { + assert_typed(Some(&p), &buf); + } + } + + // Truncation at every offset — a short read must never panic. + for cut in 0..valid.len() { + assert_typed(None, &valid[..cut]); + assert_typed(Some(&p), &valid[..cut]); + } + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index 628b249f8e..1c91b8cd23 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -34,8 +34,13 @@ //! by zeroize + mlock. The derived AEAD key stays resident in a //! [`SecretBytes`] (to avoid per-op Argon2) and is zeroized on Drop. -mod crypto; -mod format; +// `pub(super)` (= visible within `crate::secrets`) so the Tier-2 +// `envelope` module — a sibling of `file` under `secrets` — can reuse the +// shared Argon2id/XChaCha primitives and `KDF_ID_ARGON2ID` without +// duplicating crypto. Items inside stay `pub(crate)`/`pub(in …file)`, so +// nothing escapes the secrets tree (see the crypto.rs module doc). +pub(super) mod crypto; +pub(super) mod format; use std::any::Any; use std::collections::HashMap; diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index c8f82e161f..4161e42300 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -22,6 +22,7 @@ //! `tests/secrets_scan.rs` exempts it, so it owns its own review //! discipline via `tests/secrets_guard.rs`. +mod envelope; mod error; mod file; mod keyring; @@ -29,6 +30,7 @@ mod secret; mod store; mod validate; +pub use envelope::MAX_PLAINTEXT_LEN; pub use error::{IoError, OsKeyringErrorKind, SecretStoreError}; pub use file::{ EncryptedFileCredential, EncryptedFileStore, MAX_SECRET_LEN, MAX_VAULT_SIZE_BYTES, From 491229b592e23f26671d200a23f4bea71a3eb12f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:22:07 +0200 Subject: [PATCH 04/21] feat(platform-wallet-storage)!: strict fail-closed Tier-2 read (L-1 keystone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 of the layered secret-protection feature — THE load-bearing control. Wires the envelope's strict, fail-closed quadrant into the store read path over BOTH arms. - `SecretStore::get_secret(service, label, password: Option<&SecretString>)` reads the opaque backend bytes via a new shared `get_raw` seam, then runs `envelope::unwrap`. The "expected-protected" bit lives SOLELY in the caller's `Some/None` argument — never inferred from the stored blob, and never persisted by the library. `get` is refactored to delegate to `get_raw` (behaviour unchanged). - GAP-005 fixture: `secrets::testing::InMemoryCredentialStore`, a writable in-memory `CredentialStoreApi` mock (gated on cfg(test)/`__test-helpers`) with a `raw_overwrite` attacker primitive, so the Os arm — where the L-1 residual bites hardest (§8.3) — and the File re-seal-under-vault-key strip are both coverable in CI. Tests (TS-L1-001..006), each parameterised over File AND Os: - ★ TS-L1-002 strip-injection (non-vacuous): a protected scheme-1 object is overwritten with a well-formed scheme-0 blob carrying a DIFFERENT seed; get_secret(Some(pw)) ⇒ ExpectedProtectedButUnsealed, the attacker seed is NEVER returned — and the same blob WOULD decode to S_evil under None, proving the refusal is the strict rule, not malformation. - Full quadrant; both DET-bug directions fail closed; expectation never inferred from the scheme byte; upgrade-confusion is DoS-only; in-place 1→0 scheme flip (Some fails closed; None GAP-010 residual pinned). Deviation (documented): magic-less + None → legacy raw bytes (adopted §4.1 contingency), not Corruption as the v4 TS-L1-001 row read; magic-less + Some(pw) still fails closed, so L-1 is intact. `!`: get_secret is additive, but the strict read changes how a password-supplied read of a non-enveloped slot behaves (now refuses). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../src/secrets/envelope.rs | 11 +- .../src/secrets/mod.rs | 4 + .../src/secrets/store.rs | 426 +++++++++++++++++- .../src/secrets/testing.rs | 182 ++++++++ 4 files changed, 615 insertions(+), 8 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/src/secrets/testing.rs diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs index 958ab57966..c987e068a4 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -42,13 +42,6 @@ //! [`format::aad`]: super::file::format::aad //! [`format::verify_aad`]: super::file::format::verify_aad -// The wrap/unwrap primitives are exercised by this module's own tests but -// are not yet called from non-test code: the strict-read wiring into -// `SecretStore::get_secret`/`set_secret` lands in the next task, which -// removes this allow. Without it the not-yet-wired primitives would trip -// dead-code warnings in the non-test build. -#![allow(dead_code)] - use std::sync::Once; use super::error::SecretStoreError; @@ -103,6 +96,9 @@ pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; /// `None` → an unprotected (scheme-0) envelope; `Some(pw)` → a scheme-1 /// envelope sealed under `pw`. A blank password is rejected at enrol /// ([`SecretStoreError::BlankPassphrase`]). +// The write side is wired into `SecretStore::set_secret` in the next task; +// until then it is exercised only by this module's tests. +#[allow(dead_code)] pub(crate) fn wrap( wallet_id: &WalletId, label: &str, @@ -121,6 +117,7 @@ pub(crate) fn wrap( /// [`wrap`] with explicit Argon2 `params` (tests use the floor params for /// speed; production uses [`KdfParams::default_target`]). `params` is /// ignored when `password` is `None`. +#[allow(dead_code)] pub(crate) fn wrap_with_params( wallet_id: &WalletId, label: &str, diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index 4161e42300..32a9758e53 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -28,6 +28,10 @@ mod file; mod keyring; mod secret; mod store; +/// In-memory backend test fixtures (writable `CredentialStoreApi` mock). +/// Compiled only under `cfg(test)` or the `__test-helpers` feature. +#[cfg(any(test, feature = "__test-helpers"))] +pub mod testing; mod validate; pub use envelope::MAX_PLAINTEXT_LEN; diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 82157f4338..aea89a5794 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -12,8 +12,9 @@ use std::sync::Arc; use keyring_core::api::CredentialStoreApi; use keyring_core::{Entry, Error as KeyringError}; +use super::envelope; use super::error::{OsKeyringErrorKind, SecretStoreError}; -use super::secret::SecretBytes; +use super::secret::{SecretBytes, SecretString}; use super::validate::WalletId; use super::{default_credential_store, EncryptedFileStore, SERVICE_PREFIX}; @@ -77,6 +78,22 @@ impl SecretStore { &self, service: &WalletId, label: &str, + ) -> Result, SecretStoreError> { + self.get_raw(service, label) + } + + /// Read the opaque bytes stored under `(service, label)`, or `Ok(None)` + /// if absent — the raw backend value (a Tier-2 envelope once writes go + /// through [`set_secret`](SecretStore::set_secret), or a legacy raw + /// value). The typed-vs-SPI distinction is preserved exactly as the + /// pre-Tier-2 path did. This is the shared seam under [`get`] and + /// [`get_secret`]; it does NOT interpret the envelope. + /// + /// [`get`]: SecretStore::get + fn get_raw( + &self, + service: &WalletId, + label: &str, ) -> Result, SecretStoreError> { match self { // Inherent typed path: keeps WrongPassphrase vs Corruption @@ -93,6 +110,43 @@ impl SecretStore { } } + /// Retrieve the secret under `(service, label)` applying the Tier-2 + /// **strict, fail-closed** read (the L-1 keystone), or `Ok(None)` if + /// absent. + /// + /// `password` IS the caller's protection assertion — supply `Some(pw)` + /// for an object the caller's trusted model says is protected, `None` + /// otherwise. The expectation lives ONLY here, never in the stored + /// blob (see [`envelope::unwrap`]): + /// + /// - `Some(pw)` + valid scheme-1 → the secret (or + /// [`WrongPassword`](SecretStoreError::WrongPassword) on tag fail); + /// - `Some(pw)` + a non-protected blob (scheme-0 / legacy raw) → + /// [`ExpectedProtectedButUnsealed`](SecretStoreError::ExpectedProtectedButUnsealed) + /// — a strip/downgrade, refused, no bytes returned ★; + /// - `None` + scheme-1 → + /// [`NeedsPassword`](SecretStoreError::NeedsPassword) (never ciphertext); + /// - `None` + scheme-0 / legacy raw → the secret. + /// + /// **Documented residual:** an attacker who ALSO rewrites the + /// consumer's trusted DB so the caller passes `None` for a stripped + /// object can still downgrade — out of this library's reach by + /// construction (the protection expectation is the caller's; see + /// `SECRETS.md`). The expectation is NEVER persisted by the library. + pub fn get_secret( + &self, + service: &WalletId, + label: &str, + password: Option<&SecretString>, + ) -> Result, SecretStoreError> { + // Absence is availability-only (deletion = DoS, never injection): + // a missing entry is Ok(None) under either password argument. + let Some(stored) = self.get_raw(service, label)? else { + return Ok(None); + }; + envelope::unwrap(service, label, password, stored.expose_secret()).map(Some) + } + /// Delete the secret stored under `(service, label)`. Absent entries /// are a no-op (`Ok(())`), so deletion is idempotent. pub fn delete(&self, service: &WalletId, label: &str) -> Result<(), SecretStoreError> { @@ -357,4 +411,374 @@ mod tests { ); } } + + // ===== Tier-2 strict fail-closed read — the L-1 keystone ===== + // + // Parameterised over BOTH arms. The "attacker who can write the + // backend" is modelled per arm by `Backend::place_raw`: on File it + // re-seals the chosen blob under the resident vault key via `put_bytes` + // (a cold/backup-swap actor could only corrupt → DoS, so the strip + // requires the vault key — §8.3 arm asymmetry); on Os it overwrites the + // mock keychain item directly (the bare envelope, no second AEAD — where + // the L-1 residual bites hardest, GAP-005 / §8.3). + + use crate::secrets::file::crypto::{KdfParams, ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P}; + use crate::secrets::file::format::KDF_ID_ARGON2ID; + use crate::secrets::testing::InMemoryCredentialStore; + + /// Argon2id floor params — fast enough for the keystone tests. + fn floor() -> KdfParams { + KdfParams { + id: KDF_ID_ARGON2ID, + m_kib: ARGON2_MIN_M_KIB, + t: ARGON2_MIN_T, + p: ARGON2_P, + } + } + + fn protected(w: &WalletId, label: &str, pw: &str, secret: &[u8]) -> Vec { + envelope::wrap_with_params(w, label, Some(&SecretString::new(pw)), secret, floor()).unwrap() + } + + fn unprotected(w: &WalletId, label: &str, secret: &[u8]) -> Vec { + envelope::wrap(w, label, None, secret).unwrap() + } + + /// A backend under test plus the raw-write hook that plays the + /// backend-write attacker. + struct Backend { + store: SecretStore, + _dir: Option, + mock: Option, + name: &'static str, + } + + impl Backend { + /// Write `blob` to `(w, label)` as opaque backend bytes (the + /// attacker's primitive / the protected-enrol setup). + fn place_raw(&self, w: &WalletId, label: &str, blob: &[u8]) { + match (&self.store, &self.mock) { + (SecretStore::File(fs), _) => fs + .put_bytes(w, label, &SecretBytes::from_slice(blob)) + .unwrap(), + (SecretStore::Os(_), Some(mock)) => mock.raw_overwrite(w, label, blob), + _ => unreachable!("os backend must carry its mock"), + } + } + } + + fn file_backend() -> Backend { + let dir = tempfile::tempdir().unwrap(); + let store = file_store(dir.path()); + Backend { + store, + _dir: Some(dir), + mock: None, + name: "File", + } + } + + fn os_backend() -> Backend { + let mock = InMemoryCredentialStore::new(); + let store = SecretStore::Os(mock.as_dyn()); + Backend { + store, + _dir: None, + mock: Some(mock), + name: "Os", + } + } + + /// TS-L1-001: the strict-read QUADRANT. + fn run_quadrant(b: &Backend) { + let w = wid(1); + let pw = SecretString::new("object-pw"); + + // scheme-0 + None → bytes (the ONLY byte-returning quadrant). + b.place_raw(&w, "u0", &unprotected(&w, "u0", b"plain-seed")); + assert_eq!( + b.store + .get_secret(&w, "u0", None) + .unwrap() + .unwrap() + .expose_secret(), + b"plain-seed", + "[{}] scheme-0 + None", + b.name + ); + + // scheme-1 + None → NeedsPassword (never ciphertext). + b.place_raw(&w, "p1", &protected(&w, "p1", "object-pw", b"real-seed")); + assert!( + matches!( + b.store.get_secret(&w, "p1", None).unwrap_err(), + SecretStoreError::NeedsPassword + ), + "[{}] scheme-1 + None", + b.name + ); + + // scheme-1 + Some(correct) → secret. + assert_eq!( + b.store + .get_secret(&w, "p1", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"real-seed", + "[{}] scheme-1 + Some(correct)", + b.name + ); + + // scheme-1 + Some(wrong) → WrongPassword. + assert!( + matches!( + b.store + .get_secret(&w, "p1", Some(&SecretString::new("nope"))) + .unwrap_err(), + SecretStoreError::WrongPassword + ), + "[{}] scheme-1 + Some(wrong)", + b.name + ); + + // ★ scheme-0 + Some(pw) → ExpectedProtectedButUnsealed (fail closed). + assert!( + matches!( + b.store.get_secret(&w, "u0", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + ), + "[{}] scheme-0 + Some", + b.name + ); + + // magic-present-but-truncated + None → Corruption. + let mut trunc = envelope::MAGIC.to_vec(); + trunc.push(envelope::ENVELOPE_VERSION); // no scheme byte + b.place_raw(&w, "broken", &trunc); + assert!( + matches!( + b.store.get_secret(&w, "broken", None).unwrap_err(), + SecretStoreError::Corruption + ), + "[{}] truncated-with-magic + None", + b.name + ); + + // magic-less legacy raw + None → bytes (adopted §4.1 contingency; + // deviates from v4 TS-L1-001's Corruption row). + Some → fail closed. + b.place_raw(&w, "legacy", b"raw-legacy-seed-no-magic"); + assert_eq!( + b.store + .get_secret(&w, "legacy", None) + .unwrap() + .unwrap() + .expose_secret(), + b"raw-legacy-seed-no-magic", + "[{}] legacy magic-less + None", + b.name + ); + assert!( + matches!( + b.store.get_secret(&w, "legacy", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + ), + "[{}] legacy magic-less + Some", + b.name + ); + + // absent entry → Ok(None) under either arg (deletion = DoS). + assert!(b.store.get_secret(&w, "absent", None).unwrap().is_none()); + assert!(b + .store + .get_secret(&w, "absent", Some(&pw)) + .unwrap() + .is_none()); + } + + #[test] + fn l1_quadrant_file() { + run_quadrant(&file_backend()); + } + + #[test] + fn l1_quadrant_os() { + run_quadrant(&os_backend()); + } + + /// TS-L1-002 ★ — the non-vacuous strip-injection regression. The single + /// test the whole feature exists to make pass. + fn run_strip_injection(b: &Backend) { + let w = wid(2); + let pw = SecretString::new("object-pw"); + + // Enrol protected: stored = a valid scheme-1 envelope of S_real. + b.place_raw( + &w, + "seed", + &protected(&w, "seed", "object-pw", b"REAL-SEED-S_real"), + ); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL-SEED-S_real", + "[{}] legit protected read", + b.name + ); + + // Attacker overwrites the slot with a fresh, internally-valid + // scheme-0 envelope carrying a DIFFERENT seed S_evil. + let attacker_blob = unprotected(&w, "seed", b"EVIL-SEED-S_evil"); + b.place_raw(&w, "seed", &attacker_blob); + + // ★ A password-supplied read of the stripped slot fails closed; + // S_evil is NEVER returned. + let err = b.store.get_secret(&w, "seed", Some(&pw)).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "[{}] strip must fail closed, got {err:?}", + b.name + ); + + // Non-vacuity: the attacker blob IS a valid unprotected envelope + // that WOULD decode to S_evil under `None` — so the refusal above is + // caused SOLELY by the Some(pw)+scheme-0 strict rule, not by any + // malformation (without the strict rule, S_evil would be returned). + let would_be = envelope::unwrap(&w, "seed", None, &attacker_blob).unwrap(); + assert_eq!( + would_be.expose_secret(), + b"EVIL-SEED-S_evil", + "[{}] non-vacuity: blob decodes to S_evil under None", + b.name + ); + } + + #[test] + fn l1_strip_injection_file() { + run_strip_injection(&file_backend()); + } + + #[test] + fn l1_strip_injection_os() { + run_strip_injection(&os_backend()); + } + + /// TS-L1-003: a DET bug alone fails closed in BOTH directions. + fn run_both_det_bug_directions(b: &Backend) { + let w = wid(3); + let pw = SecretString::new("pw"); + // (a) over-supply a password on a genuinely unprotected object. + b.place_raw(&w, "u", &unprotected(&w, "u", b"x")); + assert!(matches!( + b.store.get_secret(&w, "u", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + // (b) under-supply on a genuinely protected object. + b.place_raw(&w, "p", &protected(&w, "p", "pw", b"y")); + assert!(matches!( + b.store.get_secret(&w, "p", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_both_det_bug_directions_file() { + run_both_det_bug_directions(&file_backend()); + } + + #[test] + fn l1_both_det_bug_directions_os() { + run_both_det_bug_directions(&os_backend()); + } + + /// TS-L1-004: the expectation is NEVER inferred from the blob's scheme + /// byte — identical scheme-1 blobs diverge solely on the password arg. + fn run_expectation_not_inferred(b: &Backend) { + let w = wid(4); + let pw = SecretString::new("pw"); + let blob = protected(&w, "a", "pw", b"seed"); + b.place_raw(&w, "a", &blob); + b.place_raw(&w, "b", &blob); + assert_eq!( + b.store + .get_secret(&w, "a", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"seed" + ); + assert!(matches!( + b.store.get_secret(&w, "b", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_expectation_not_inferred_file() { + run_expectation_not_inferred(&file_backend()); + } + + #[test] + fn l1_expectation_not_inferred_os() { + run_expectation_not_inferred(&os_backend()); + } + + /// TS-L1-005: unprotected→scheme-1 upgrade confusion is availability- + /// only, fail-closed (NeedsPassword), no leak / no injection. + fn run_upgrade_confusion(b: &Backend) { + let w = wid(5); + b.place_raw(&w, "x", &protected(&w, "x", "attacker-pw", b"whatever")); + assert!(matches!( + b.store.get_secret(&w, "x", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_upgrade_confusion_file() { + run_upgrade_confusion(&file_backend()); + } + + #[test] + fn l1_upgrade_confusion_os() { + run_upgrade_confusion(&os_backend()); + } + + /// TS-L1-006: an in-place scheme-byte flip (1→0). Some(pw) is caught by + /// the strict rule regardless. None reads the body as scheme-0 opaque + /// bytes (never the real seed) — the GAP-010 residual, dominated by the + /// DET-DB residual; pinned, not "fixed". + fn run_scheme_flip(b: &Backend) { + let w = wid(6); + let pw = SecretString::new("pw"); + let mut blob = protected(&w, "x", "pw", b"real-seed"); + let scheme_off = envelope::MAGIC.len() + 1; + assert_eq!(blob[scheme_off], envelope::SCHEME_PASSWORD); + blob[scheme_off] = envelope::SCHEME_UNPROTECTED; + b.place_raw(&w, "x", &blob); + + assert!(matches!( + b.store.get_secret(&w, "x", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + let got = b.store.get_secret(&w, "x", None).unwrap().unwrap(); + assert_ne!( + got.expose_secret(), + b"real-seed", + "the real seed must never surface from a flipped scheme byte" + ); + } + + #[test] + fn l1_scheme_flip_file() { + run_scheme_flip(&file_backend()); + } + + #[test] + fn l1_scheme_flip_os() { + run_scheme_flip(&os_backend()); + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/testing.rs b/packages/rs-platform-wallet-storage/src/secrets/testing.rs new file mode 100644 index 0000000000..a59aa3b7fa --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/testing.rs @@ -0,0 +1,182 @@ +//! In-memory test fixtures for the secrets backends. +//! +//! [`InMemoryCredentialStore`] is a writable +//! [`CredentialStoreApi`](keyring_core::api::CredentialStoreApi) double for +//! the [`SecretStore::Os`](crate::secrets::SecretStore::Os) arm — the only +//! shipped credential stores need a live OS keychain / D-Bus session, so +//! the Os arm (where the L-1 strip residual "bites hardest", per the threat +//! model §8.3) is otherwise un-coverable in CI (GAP-005). +//! +//! Beyond the SPI, it exposes [`raw_overwrite`](InMemoryCredentialStore::raw_overwrite) +//! / [`raw_get`](InMemoryCredentialStore::raw_get) so a test can play the +//! **backend-write attacker**: replace a slot's stored bytes with an +//! arbitrary blob (e.g. a stripped scheme-0 envelope) the way a breached +//! keychain would, then assert the strict read still fails closed. +//! +//! Compiled under `cfg(test)` or the `__test-helpers` feature, never in a +//! production build. + +use std::any::Any; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use keyring_core::api::{Credential, CredentialApi, CredentialPersistence, CredentialStoreApi}; +use keyring_core::{Entry, Error as KeyringError, Result as KeyringResult}; + +use super::validate::WalletId; +use super::SERVICE_PREFIX; + +/// Shared `(service, user) -> bytes` map backing every credential a store +/// hands out, so writes through one entry are visible to another. +type Items = Arc>>>; + +/// An in-memory, writable [`CredentialStoreApi`] for Os-arm tests. +#[derive(Default)] +pub struct InMemoryCredentialStore { + items: Items, +} + +impl InMemoryCredentialStore { + /// A fresh, empty store, ready to install as + /// `SecretStore::Os(store.into_arc())`. + pub fn new() -> Self { + Self::default() + } + + /// Hand back an `Arc` for the + /// `SecretStore::Os` arm. Clones share the same backing map, so the + /// returned trait object and `self` see each other's writes. + pub fn as_dyn(&self) -> Arc { + Arc::new(Self { + items: Arc::clone(&self.items), + }) + } + + /// The service string `SecretStore` derives for `wallet_id`, so a test + /// can address the exact slot the Os arm reads/writes. + pub fn service_for(wallet_id: &WalletId) -> String { + format!("{SERVICE_PREFIX}{}", wallet_id.to_hex()) + } + + /// **Attacker primitive:** overwrite the raw bytes at `(wallet_id, + /// label)` with `bytes`, bypassing any envelope layer — exactly what a + /// breached keychain write does. + pub fn raw_overwrite(&self, wallet_id: &WalletId, label: &str, bytes: &[u8]) { + self.lock().insert( + (Self::service_for(wallet_id), label.to_string()), + bytes.to_vec(), + ); + } + + /// Read the raw stored bytes at `(wallet_id, label)`, if any. + pub fn raw_get(&self, wallet_id: &WalletId, label: &str) -> Option> { + self.lock() + .get(&(Self::service_for(wallet_id), label.to_string())) + .cloned() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(String, String), Vec>> { + self.items.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +impl CredentialStoreApi for InMemoryCredentialStore { + fn vendor(&self) -> String { + "dash.platform-wallet-storage/in-memory-test".to_string() + } + + fn id(&self) -> String { + "in-memory-credential-store".to_string() + } + + fn build( + &self, + service: &str, + user: &str, + _modifiers: Option<&HashMap<&str, &str>>, + ) -> KeyringResult { + let cred = InMemoryCredential { + items: Arc::clone(&self.items), + service: service.to_string(), + user: user.to_string(), + }; + Ok(Entry::new_with_credential(Arc::new(cred))) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn persistence(&self) -> CredentialPersistence { + CredentialPersistence::UntilDelete + } +} + +impl std::fmt::Debug for InMemoryCredentialStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never render stored bytes (they may be secret material). + f.debug_struct("InMemoryCredentialStore") + .field("entries", &self.lock().len()) + .finish() + } +} + +/// One `(service, user)` slot in an [`InMemoryCredentialStore`]. +struct InMemoryCredential { + items: Items, + service: String, + user: String, +} + +impl InMemoryCredential { + fn key(&self) -> (String, String) { + (self.service.clone(), self.user.clone()) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(String, String), Vec>> { + self.items.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +impl CredentialApi for InMemoryCredential { + fn set_secret(&self, secret: &[u8]) -> KeyringResult<()> { + self.lock().insert(self.key(), secret.to_vec()); + Ok(()) + } + + fn get_secret(&self) -> KeyringResult> { + self.lock() + .get(&self.key()) + .cloned() + .ok_or(KeyringError::NoEntry) + } + + fn delete_credential(&self) -> KeyringResult<()> { + if self.lock().remove(&self.key()).is_some() { + Ok(()) + } else { + Err(KeyringError::NoEntry) + } + } + + fn get_credential(&self) -> KeyringResult>> { + Ok(None) + } + + fn get_specifiers(&self) -> Option<(String, String)> { + Some(self.key()) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +impl std::fmt::Debug for InMemoryCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InMemoryCredential") + .field("service", &self.service) + .field("user", &self.user) + .finish_non_exhaustive() + } +} From c19c23bf7e1984601626b29024eca4c00677f94a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:31:47 +0200 Subject: [PATCH 05/21] feat(platform-wallet-storage): SecretStore Tier-2 write API + reprotect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 of the layered secret-protection feature. - `SecretStore::set_secret(service, label, secret, password: Option<&SecretString>)` wraps via the envelope ABOVE the backend (the backend stores only the opaque envelope — ciphertext for a protected object) and writes through a shared `put_raw` seam over BOTH arms. - `set`/`get` are reimplemented as non-breaking `..,None` wrappers (signatures unchanged); `get` now routes through the strict `get_secret`. - `reprotect(service, label, current, new)` — the canonical add/change/ remove flow as one same-slot unwrap→rewrap→overwrite; reads under the `current` expectation (a strip is caught fail-closed before any rewrite), then re-writes under `new`. The atomic put leaves the prior value intact on a crash. - `envelope::wrap` now returns a zeroizing `SecretBytes` (symmetric with `unwrap`): a scheme-0 envelope embeds plaintext, so the wire bytes are mlock'd/wiped by construction rather than living in a bare `Vec`. Tests (TS-PW-001..005, TS-ARM-003, TS-T1-005), parameterised over File and Os: full enrol→change→remove lifecycle; no-recovery (lost password bricks the object, fail closed both ways); set/get `None`-wrapper round-trip + scheme-0 proof; Os-arm round-trip unaffected by the blank guard; and ★ TS-PW-004 [File] crash-safety — a disk-write failure mid-change leaves the OLD protected value intact and readable, no half-rotated state. 137 secrets unit tests + secrets integration tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../src/secrets/envelope.rs | 73 +++-- .../src/secrets/store.rs | 305 +++++++++++++++++- 2 files changed, 344 insertions(+), 34 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs index c987e068a4..d2cd043b87 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -96,15 +96,17 @@ pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; /// `None` → an unprotected (scheme-0) envelope; `Some(pw)` → a scheme-1 /// envelope sealed under `pw`. A blank password is rejected at enrol /// ([`SecretStoreError::BlankPassphrase`]). -// The write side is wired into `SecretStore::set_secret` in the next task; -// until then it is exercised only by this module's tests. -#[allow(dead_code)] +/// +/// Returns the envelope inside a zeroizing [`SecretBytes`]: a scheme-0 +/// envelope embeds the raw plaintext, so the wire bytes are handled as +/// sensitive (mlock'd, wiped on drop) by construction — symmetric with +/// [`unwrap`]'s return. pub(crate) fn wrap( wallet_id: &WalletId, label: &str, password: Option<&SecretString>, plaintext: &[u8], -) -> Result, SecretStoreError> { +) -> Result { wrap_with_params( wallet_id, label, @@ -117,14 +119,13 @@ pub(crate) fn wrap( /// [`wrap`] with explicit Argon2 `params` (tests use the floor params for /// speed; production uses [`KdfParams::default_target`]). `params` is /// ignored when `password` is `None`. -#[allow(dead_code)] pub(crate) fn wrap_with_params( wallet_id: &WalletId, label: &str, password: Option<&SecretString>, plaintext: &[u8], params: KdfParams, -) -> Result, SecretStoreError> { +) -> Result { // Cap the PLAINTEXT (before overhead) uniformly for both schemes so the // enveloped bytes always fit the backend cap and the limit is stable. if plaintext.len() > MAX_PLAINTEXT_LEN { @@ -141,7 +142,9 @@ pub(crate) fn wrap_with_params( out.push(ENVELOPE_VERSION); out.push(SCHEME_UNPROTECTED); out.extend_from_slice(plaintext); - return Ok(out); + // `SecretBytes::new` moves `out` into a zeroizing, mlock'd buffer + // (no copy) — the scheme-0 plaintext never lives in a bare Vec. + return Ok(SecretBytes::new(out)); }; // Reject a blank object password BEFORE any derivation (SEC-J). @@ -167,7 +170,7 @@ pub(crate) fn wrap_with_params( out.extend_from_slice(&salt); out.extend_from_slice(&nonce); out.extend_from_slice(&ciphertext); - Ok(out) + Ok(SecretBytes::new(out)) } /// Unwrap `blob` for `(wallet_id, label)`, applying the **strict, @@ -390,12 +393,41 @@ mod tests { SecretString::new(s) } + /// Wrap and expose the envelope as a `Vec` for byte-level + /// inspection/mutation in tests (the production `wrap` returns a + /// zeroizing `SecretBytes`). + fn wrap_bytes( + w: &WalletId, + label: &str, + password: Option<&SecretString>, + pt: &[u8], + ) -> Vec { + wrap(w, label, password, pt) + .unwrap() + .expose_secret() + .to_vec() + } + + /// [`wrap_bytes`] with explicit (floor) params, for the scheme-1 tests. + fn wrap_p( + w: &WalletId, + label: &str, + password: Option<&SecretString>, + pt: &[u8], + params: KdfParams, + ) -> Vec { + wrap_with_params(w, label, password, pt, params) + .unwrap() + .expose_secret() + .to_vec() + } + /// TS-ENV-001: scheme-0 passthrough round-trip; the wrapped form leads /// with magic, version=1, scheme=0, then the raw payload. #[test] fn scheme0_passthrough_round_trip() { let secret = b"top secret seed bytes"; - let blob = wrap(&wid(1), "seed", None, secret).unwrap(); + let blob = wrap_bytes(&wid(1), "seed", None, secret); assert!(blob.starts_with(MAGIC)); assert_eq!(blob[O_VERSION], 1); assert_eq!(blob[O_SCHEME], 0); @@ -411,7 +443,7 @@ mod tests { fn scheme1_round_trip_and_fresh_salt_nonce() { let secret = b"correct horse battery staple seed"; let p = pw("hunter2-but-better"); - let blob = wrap_with_params(&wid(7), "seed", Some(&p), secret, floor()).unwrap(); + let blob = wrap_p(&wid(7), "seed", Some(&p), secret, floor()); assert!(blob.starts_with(MAGIC)); assert_eq!(blob[O_VERSION], 1); assert_eq!(blob[O_SCHEME], 1); @@ -422,7 +454,7 @@ mod tests { let got = unwrap(&wid(7), "seed", Some(&p), &blob).unwrap(); assert_eq!(got.expose_secret(), secret); - let blob2 = wrap_with_params(&wid(7), "seed", Some(&p), secret, floor()).unwrap(); + let blob2 = wrap_p(&wid(7), "seed", Some(&p), secret, floor()); assert_ne!( &blob[O_SALT..O_SALT + SALT_LEN], &blob2[O_SALT..O_SALT + SALT_LEN], @@ -438,7 +470,7 @@ mod tests { /// TS-ENV-003: wrong object password → WrongPassword, no plaintext. #[test] fn wrong_password_fails_closed() { - let blob = wrap_with_params(&wid(1), "seed", Some(&pw("right")), b"seed", floor()).unwrap(); + let blob = wrap_p(&wid(1), "seed", Some(&pw("right")), b"seed", floor()); let err = unwrap(&wid(1), "seed", Some(&pw("wrong")), &blob).unwrap_err(); assert!( matches!(err, SecretStoreError::WrongPassword), @@ -451,7 +483,7 @@ mod tests { #[test] fn relocation_across_identity_is_rejected() { let p = pw("pw"); - let blob = wrap_with_params(&wid(0xA), "labelA", Some(&p), b"seed", floor()).unwrap(); + let blob = wrap_p(&wid(0xA), "labelA", Some(&p), b"seed", floor()); for (w, l) in [(0xB, "labelB"), (0xA, "labelB"), (0xB, "labelA")] { let err = unwrap(&wid(w), l, Some(&p), &blob).unwrap_err(); assert!( @@ -470,7 +502,7 @@ mod tests { #[test] fn header_tamper_fails_closed_per_field() { let p = pw("pw"); - let base = wrap_with_params(&wid(1), "seed", Some(&p), b"seed", floor()).unwrap(); + let base = wrap_p(&wid(1), "seed", Some(&p), b"seed", floor()); // kdf.id → 7 (unknown) ⇒ KdfFailure (bounds reject pre-derive). let mut b = base.clone(); @@ -520,7 +552,7 @@ mod tests { #[test] fn kdf_ceiling_enforced_before_derivation() { let p = pw("pw"); - let base = wrap_with_params(&wid(1), "seed", Some(&p), b"seed", floor()).unwrap(); + let base = wrap_p(&wid(1), "seed", Some(&p), b"seed", floor()); // m_kib = u32::MAX ⇒ KdfFailure, no allocation. let mut b = base.clone(); @@ -623,7 +655,7 @@ mod tests { // (c) Magic OK but version = 2 ⇒ UnsupportedEnvelopeVersion{2}, // regardless of password (GAP-009). - let mut v2 = wrap(&wid(1), "seed", None, b"x").unwrap(); + let mut v2 = wrap_bytes(&wid(1), "seed", None, b"x"); v2[O_VERSION] = 2; for arg in [None, Some(&p)] { assert!(matches!( @@ -633,7 +665,7 @@ mod tests { } // (d) Magic+version OK but unknown scheme = 9 ⇒ fail closed. - let mut s9 = wrap(&wid(1), "seed", None, b"x").unwrap(); + let mut s9 = wrap_bytes(&wid(1), "seed", None, b"x"); s9[O_SCHEME] = 9; assert!(matches!( unwrap(&wid(1), "seed", None, &s9).unwrap_err(), @@ -645,7 +677,7 @@ mod tests { /// tests): a scheme-0 blob carrying `secret` DOES decode under `None`. #[test] fn scheme0_some_password_fails_closed_strip() { - let blob = wrap(&wid(1), "seed", None, b"attacker-seed").unwrap(); + let blob = wrap_bytes(&wid(1), "seed", None, b"attacker-seed"); // None ⇒ it WOULD decode to the (attacker) bytes… assert_eq!( unwrap(&wid(1), "seed", None, &blob) @@ -666,8 +698,7 @@ mod tests { fn round_trip_is_constant_time_equal() { let p = pw("pw"); let original = SecretBytes::from_slice(b"seed material"); - let blob = - wrap_with_params(&wid(1), "seed", Some(&p), original.expose_secret(), floor()).unwrap(); + let blob = wrap_p(&wid(1), "seed", Some(&p), original.expose_secret(), floor()); let got = unwrap(&wid(1), "seed", Some(&p), &blob).unwrap(); assert!(bool::from(got.ct_eq(&original))); } @@ -682,7 +713,7 @@ mod tests { #[test] fn fuzz_byte_mutation_never_panics() { let p = pw("fuzz-pw"); - let valid = wrap_with_params(&wid(0xAB), "seed", Some(&p), b"seed-bytes", floor()).unwrap(); + let valid = wrap_p(&wid(0xAB), "seed", Some(&p), b"seed-bytes", floor()); // The pristine envelope unwraps. assert_eq!( unwrap(&wid(0xAB), "seed", Some(&p), &valid) diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index aea89a5794..0a88dcc841 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -50,36 +50,77 @@ impl SecretStore { Ok(Self::Os(default_credential_store().map_err(map_spi)?)) } - /// Store `secret` under `(service, label)`, overwriting any prior - /// value. Takes `&SecretBytes` so the caller cannot pass an unwrapped - /// buffer; the wrapped bytes are exposed to the SPI only at the last - /// moment. + /// Store `secret` under `(service, label)` UNPROTECTED (Tier-2 + /// scheme-0), overwriting any prior value — a `set_secret(.., None)` + /// wrapper kept for non-breaking back-compat. Takes `&SecretBytes` so + /// the caller cannot pass an unwrapped buffer. pub fn set( &self, service: &WalletId, label: &str, secret: &SecretBytes, + ) -> Result<(), SecretStoreError> { + self.set_secret(service, label, secret, None) + } + + /// Store `secret` under `(service, label)`, overwriting any prior value. + /// + /// `password` selects the Tier-2 protection: `None` writes an + /// unprotected scheme-0 envelope; `Some(pw)` writes a scheme-1 envelope + /// sealed under the object password `pw` (Argon2id + XChaCha20-Poly1305) + /// **before** the bytes reach the backend, so a protected object stays + /// confidential even under a full backend compromise. A blank `pw` is + /// rejected ([`BlankPassphrase`](SecretStoreError::BlankPassphrase)). + /// + /// The write is an atomic same-slot overwrite on both arms (File: the + /// vault's atomic replace; Os: the keychain item), so add/change/remove + /// password flows — see [`reprotect`](SecretStore::reprotect) — leave + /// the prior value intact on a crash. + pub fn set_secret( + &self, + service: &WalletId, + label: &str, + secret: &SecretBytes, + password: Option<&SecretString>, + ) -> Result<(), SecretStoreError> { + // Wrap above the backend: the backend only ever stores the opaque + // envelope (ciphertext for a protected object). + let blob = envelope::wrap(service, label, password, secret.expose_secret())?; + self.put_raw(service, label, &blob) + } + + /// Store the already-enveloped opaque `blob` under `(service, label)`. + /// The shared write seam under [`set`] and [`set_secret`]. + /// + /// [`set`]: SecretStore::set + fn put_raw( + &self, + service: &WalletId, + label: &str, + blob: &SecretBytes, ) -> Result<(), SecretStoreError> { match self { // Inherent typed path — no lossy SPI seam, no bare buffer. - Self::File(s) => s.put_bytes(service, label, secret), + Self::File(s) => s.put_bytes(service, label, blob), Self::Os(store) => { let entry = build_os(store, service, label)?; - entry.set_secret(secret.expose_secret()).map_err(map_spi) + entry.set_secret(blob.expose_secret()).map_err(map_spi) } } } - /// Retrieve the secret stored under `(service, label)`, or `Ok(None)` - /// if absent. The plaintext is wrapped into [`SecretBytes`] at the - /// seam with no named `Vec` intermediate, so the bare-buffer window is - /// zero statements. + /// Retrieve the UNPROTECTED secret stored under `(service, label)`, or + /// `Ok(None)` if absent — a `get_secret(.., None)` wrapper kept for + /// non-breaking back-compat. A scheme-1 (password-protected) object read + /// through this path returns + /// [`NeedsPassword`](SecretStoreError::NeedsPassword); use + /// [`get_secret`](SecretStore::get_secret) with the object password. pub fn get( &self, service: &WalletId, label: &str, ) -> Result, SecretStoreError> { - self.get_raw(service, label) + self.get_secret(service, label, None) } /// Read the opaque bytes stored under `(service, label)`, or `Ok(None)` @@ -147,6 +188,34 @@ impl SecretStore { envelope::unwrap(service, label, password, stored.expose_secret()).map(Some) } + /// Add / change / remove an object password in one same-slot + /// unwrap→rewrap→overwrite — the canonical Tier-2 re-protection flow. + /// + /// Reads the object under the `current` expectation (so a strip is + /// caught fail-closed before any rewrap), then re-writes it under + /// `new`: + /// - **add:** `current = None`, `new = Some(pw)`; + /// - **change:** `current = Some(old)`, `new = Some(pw_new)`; + /// - **remove:** `current = Some(old)`, `new = None`. + /// + /// An absent object is a no-op (`Ok(())`). The rewrite is the atomic + /// same-slot overwrite of [`set_secret`], so a crash between the read + /// and the commit leaves the prior value intact and readable under + /// `current`. After a successful call the consumer MUST update its own + /// trusted protection-status record (the L-1 expectation lives there). + pub fn reprotect( + &self, + service: &WalletId, + label: &str, + current: Option<&SecretString>, + new: Option<&SecretString>, + ) -> Result<(), SecretStoreError> { + let Some(secret) = self.get_secret(service, label, current)? else { + return Ok(()); + }; + self.set_secret(service, label, &secret, new) + } + /// Delete the secret stored under `(service, label)`. Absent entries /// are a no-op (`Ok(())`), so deletion is idempotent. pub fn delete(&self, service: &WalletId, label: &str) -> Result<(), SecretStoreError> { @@ -437,11 +506,17 @@ mod tests { } fn protected(w: &WalletId, label: &str, pw: &str, secret: &[u8]) -> Vec { - envelope::wrap_with_params(w, label, Some(&SecretString::new(pw)), secret, floor()).unwrap() + envelope::wrap_with_params(w, label, Some(&SecretString::new(pw)), secret, floor()) + .unwrap() + .expose_secret() + .to_vec() } fn unprotected(w: &WalletId, label: &str, secret: &[u8]) -> Vec { - envelope::wrap(w, label, None, secret).unwrap() + envelope::wrap(w, label, None, secret) + .unwrap() + .expose_secret() + .to_vec() } /// A backend under test plus the raw-write hook that plays the @@ -781,4 +856,208 @@ mod tests { fn l1_scheme_flip_os() { run_scheme_flip(&os_backend()); } + + // ===== Add / change / remove password + arm matrix (TS-PW / TS-ARM) ===== + // + // These exercise the PUBLIC set_secret/get_secret/reprotect API, so the + // protected writes/reads run the real (default 64 MiB) Argon2 — kept to + // a small number of derivations per test. + + /// TS-PW-001/002/003: the full enrol → change → remove lifecycle, each + /// step verified through the strict read. + fn run_pw_lifecycle(b: &Backend) { + let w = wid(10); + let pw1 = SecretString::new("pw-one"); + let pw2 = SecretString::new("pw-two"); + + // ADD: start unprotected, enrol a password. + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"SEED")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); + b.store.reprotect(&w, "seed", None, Some(&pw1)).unwrap(); + assert!( + matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::NeedsPassword + ), + "[{}] after add, None read needs a password", + b.name + ); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw1)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + + // CHANGE: rotate to a new password (unwrap-old → rewrap-new). + b.store + .reprotect(&w, "seed", Some(&pw1), Some(&pw2)) + .unwrap(); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw2)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + assert!( + matches!( + b.store.get_secret(&w, "seed", Some(&pw1)).unwrap_err(), + SecretStoreError::WrongPassword + ), + "[{}] old password no longer unlocks after change", + b.name + ); + + // REMOVE: back to unprotected. + b.store.reprotect(&w, "seed", Some(&pw2), None).unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); + assert!( + matches!( + b.store.get_secret(&w, "seed", Some(&pw2)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + ), + "[{}] after remove, a password read fails closed until DET updates its DB", + b.name + ); + } + + #[test] + fn pw_lifecycle_file() { + run_pw_lifecycle(&file_backend()); + } + + #[test] + fn pw_lifecycle_os() { + run_pw_lifecycle(&os_backend()); + } + + /// TS-PW-005: losing the object password bricks the object — no recovery + /// path exists, every read fails closed. + fn run_pw_no_recovery(b: &Backend) { + let w = wid(11); + let pw = SecretString::new("the-only-pw"); + b.store + .set_secret(&w, "seed", &SecretBytes::from_slice(b"SEED"), Some(&pw)) + .unwrap(); + assert!(matches!( + b.store + .get_secret(&w, "seed", Some(&SecretString::new("guess"))) + .unwrap_err(), + SecretStoreError::WrongPassword + )); + assert!(matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn pw_no_recovery_file() { + run_pw_no_recovery(&file_backend()); + } + + #[test] + fn pw_no_recovery_os() { + run_pw_no_recovery(&os_backend()); + } + + /// TS-ARM-003: `set`/`get` are additive `..,None` wrappers — `set` + /// writes a scheme-0 envelope, `get` reads it byte-exact, and a + /// password-supplied read of that unprotected object fails closed. + fn run_set_get_wrappers(b: &Backend) { + let w = wid(12); + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"plain")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"plain" + ); + assert!(matches!( + b.store + .get_secret(&w, "seed", Some(&SecretString::new("pw"))) + .unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + } + + #[test] + fn set_get_wrappers_file() { + run_set_get_wrappers(&file_backend()); + } + + #[test] + fn set_get_wrappers_os() { + run_set_get_wrappers(&os_backend()); + } + + /// TS-T1-005: the Os arm has no passphrase concept; the Tier-1 blank + /// guard never fires and the round-trip is byte-exact. + #[test] + fn os_arm_roundtrip_no_blank_guard() { + let b = os_backend(); + let w = wid(13); + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"abc")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"abc" + ); + b.store.delete(&w, "seed").unwrap(); + assert!(b.store.get(&w, "seed").unwrap().is_none()); + } + + /// TS-PW-004 ★ [File]: a crash (disk-write failure) between the unwrap + /// and the overwrite-commit leaves the OLD protected value intact and + /// readable — no half-rotated / unprotected state. + #[cfg(unix)] + #[test] + fn pw_change_crash_safety_leaves_old_intact_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + let w = wid(14); + let old = SecretString::new("old-pw"); + let new = SecretString::new("new-pw"); + + s.set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old)) + .unwrap(); + + // Make the vault's parent read-only so the atomic temp-write fails + // mid-change (mirrors rekey_does_not_corrupt_on_disk_temp_failure). + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let err = s.reprotect(&w, "seed", Some(&old), Some(&new)).unwrap_err(); + assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}"); + + // Restore write so the resident store can sync/clean up at drop. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + // The OLD value is still readable under the OLD password; the new + // password does not unlock it (no half-rotation). + assert_eq!( + s.get_secret(&w, "seed", Some(&old)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL" + ); + assert!(matches!( + s.get_secret(&w, "seed", Some(&new)).unwrap_err(), + SecretStoreError::WrongPassword + )); + } } From d4d311cca17615e38ef3c1b0924d9ddcff5d0e99 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:38:26 +0200 Subject: [PATCH 06/21] feat(platform-wallet-storage)!: Tier-1 blank-passphrase guard + open_unprotected + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6 (final) of the layered secret-protection feature. - `EncryptedFileStore::open` and `rekey` now reject a blank (empty / all-whitespace) passphrase with `BlankPassphrase` via `is_blank` — a blank passphrase derives a key from a public salt only (obfuscation, not confidentiality; closes SEC-A / F-1). INTENDED behavioural break for any caller that relied on `SecretString::empty()`. - `EncryptedFileStore::open_unprotected(path)` / `SecretStore::file_unprotected(path)` — the explicit, named keyless door (AC-2.1 maps here, not to `open`). Used for the empty→real `rekey` migration or hosts where secrets carry their own Tier-2 password. - `reject_weak_passphrase` wires the coarse `MIN_PASSPHRASE_LEN` floor (1 = non-blank); real entropy policy is delegated to the consumer (GAP-012). - SECRETS.md: new "Two-tier secret protection" section — the model, the envelope wire format, which tier defeats which adversary, the strict fail-closed read + the caller-DB anti-downgrade dependency, the value-rollback non-defence, add/change/remove + no-recovery, entropy-policy delegation, greenfield/legacy tolerance, `open_unprotected` caveat, the `MAX_PLAINTEXT_LEN` cap; plus the five new error variants and their projections. Tests (TS-T1-001..004,006): blank rejected at open (no file/lock created) and at rekey (vault unchanged); open_unprotected keyless round-trip + real-pass open → WrongPassphrase; empty→real rekey migration; ★ rekey crash-safety leaves the pre-rekey keyless vault intact. 142 secrets unit tests + integration green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../rs-platform-wallet-storage/SECRETS.md | 201 +++++++++++++++++- .../src/secrets/file/mod.rs | 191 ++++++++++++++++- .../src/secrets/store.rs | 11 + 3 files changed, 399 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md index 8a1c7fcc39..99991f189d 100644 --- a/packages/rs-platform-wallet-storage/SECRETS.md +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -62,8 +62,22 @@ use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, W let store = SecretStore::file("/var/lib/wallet/secrets.pwsvault", SecretString::new("pw"))?; let wallet = WalletId::from(wallet_id); + +// Tier-1 only (unprotected by an object password). `set`/`get` are +// `..,None` wrappers over `set_secret`/`get_secret`. store.set(&wallet, "mnemonic", &SecretBytes::from_slice(b"abandon ability ..."))?; let plaintext: Option = store.get(&wallet, "mnemonic")?; // never a bare Vec + +// Tier-2: protect a critical object under an extra OBJECT PASSWORD that +// the backend never sees. Reading it back REQUIRES the password. +let pw = SecretString::new("a strong object password"); +store.set_secret(&wallet, "seed", &SecretBytes::from_slice(b""), Some(&pw))?; +let seed = store.get_secret(&wallet, "seed", Some(&pw))?; // Some(secret) +// Reading a protected object WITHOUT the password fails closed: +assert!(store.get_secret(&wallet, "seed", None).is_err()); // NeedsPassword + +// Add / change / remove an object password in one atomic same-slot flow: +store.reprotect(&wallet, "seed", Some(&pw), None)?; // remove → now unprotected store.delete(&wallet, "mnemonic")?; // idempotent ``` @@ -72,6 +86,167 @@ filename); the parent directory is materialized on the first write. Use `SecretStore::os()` for the platform OS keyring arm instead of `SecretStore::file(..)`. +See **Two-tier secret protection** below for the model, the envelope +format, which tier defeats which adversary, and the strict fail-closed +read that is the heart of the opt-in scheme. + +### Two-tier secret protection + +Secret protection comes in two layers. Tier-1 is always on (it is just +"which backend you opened"); Tier-2 is opt-in, per critical object, and +backend-independent. + +| Tier | Provided by | Defeats | Mechanism | +|---|---|---|---| +| **1 — backend baseline** | the *backend* | another local user, a lost laptop, the vault at rest | OS keychain ACLs **or** Argon2id + XChaCha20-Poly1305 vault under a **real** passphrase | +| **2 — per-object password** | the *library*, above `SecretStore`, over **both** arms | **backend compromise** — the keychain scraped, or the vault stolen *and* its passphrase cracked | the object's bytes are Argon2id + XChaCha20-Poly1305 **enveloped under a per-object password BEFORE they reach the backend** | + +**Why Tier-2 is more than key granularity.** Its value is not a sub-key — +it is (a) an **independent human password the backend never sees** and (b) +**envelope-before-backend ordering**, so for a protected object the backend +only ever stores ciphertext. That is the first and only control that keeps +a chosen critical object confidential across a *full* backend compromise +(the A2/A3/A6 gap Tier-1 leaves open). + +Tier-2 has two guarantees of different strength: + +- **Confidentiality** (an attacker cannot *read* a protected secret) is + **unconditional** — the object password never enters any backend, so a + full backend dump yields only ciphertext + a per-object salt to + offline-Argon2id-crack against the password's entropy. +- **Integrity / anti-downgrade** is delivered by the **strict fail-closed + read** below and is **conditional on the caller's trusted model staying + intact** (see the documented residual). + +#### The envelope (wire format) + +Every value written through `set_secret`/`set` is wrapped in a +self-describing, authenticated envelope before it reaches the backend. The +backend (file vault or OS keychain) stores only these opaque bytes. + +```text +magic b"PWSEV" (5) +version u8 = 1 (envelope version — independent of the vault FORMAT_VERSION) +scheme u8 (0 = unprotected passthrough, 1 = password) +── scheme 0 ── payload: the raw secret bytes +── scheme 1 ── kdf(id u8 ‖ m_kib u32 LE ‖ t u32 LE ‖ p u32 LE) (13) + ‖ salt[32] ‖ nonce[24] ‖ ciphertext+tag +``` + +- **AAD (scheme 1)** binds `domain ‖ magic ‖ version ‖ scheme ‖ kdf ‖ salt + ‖ wallet_id ‖ label` (length-prefixed), mirroring the vault's own + `aad()`/`verify_aad()`. A protected blob relocated to another slot — or + any in-place header edit — fails the tag (relocation/header-tamper + resistance). On the file arm this AAD is *in addition* to the vault's own + per-entry AAD + tag; on the OS arm it is the only authentication layer. +- **KDF ceiling before derivation (anti-DoS).** The KDF params live in the + (attacker-controllable) header, so on a read the Argon2 **ceiling is + enforced before** any derivation/allocation — a forged `m_kib`/`t` cannot + force a giant allocation or an unbounded stall on the victim's unlock. +- **No vault format bump.** The envelope lives *inside* the entry bytes, + identical over File and Os, so there is no vault-parser or migration + change. +- **Size cap.** The plaintext is capped at `MAX_PLAINTEXT_LEN` + (`MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD` = 64 KiB − 128 = 65 408 bytes), + uniformly for both schemes, so the enveloped bytes always fit the + backend's own `MAX_SECRET_LEN` cap and the user-visible limit is stable + regardless of scheme. Oversize → `SecretTooLarge { found, max }` with + `max = MAX_PLAINTEXT_LEN` (re-exported as `secrets::MAX_PLAINTEXT_LEN`). +- **Unknown version/scheme** (magic present) → `UnsupportedEnvelopeVersion` + — fail closed **regardless of the password**: an unparseable future + format can be neither safely unwrapped nor treated as unprotected. + +#### The strict, fail-closed read (the L-1 keystone) + +The defining risk of any opt-in "some objects are extra-protected" scheme +is **strip / downgrade**: an attacker who can WRITE the backend replaces a +protected blob with a fresh, internally-valid *unprotected* (scheme-0) blob +carrying a chosen seed/xpriv. There is nothing in that blob alone to prove +an envelope was *expected*, so inferring protection from the stored bytes +would silently return the attacker's secret — funds redirection, password +prompt bypassed. + +The fix: **the "expected-protected" bit lives in the CALLER's trusted +model, surfaced solely by whether a password is supplied to `get_secret` — +NEVER inferred from the blob.** The library does not guess and does not +persist the expectation. A supplied password *is* the assertion "this +object must be protected": + +| `password` arg | stored blob | result | +|---|---|---| +| `Some(pw)` | valid scheme-1 | the secret, or `WrongPassword` on tag fail | +| **`Some(pw)`** | **scheme-0 / legacy magic-less raw** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED ★** | +| `Some(pw)` | scheme-1 but truncated/corrupt | `Corruption` | +| `Some/None` | magic present, unknown version/scheme | `UnsupportedEnvelopeVersion` | +| `None` | valid scheme-1 | `NeedsPassword` (never ciphertext) | +| `None` | scheme-0 | the secret | +| `None` | legacy magic-less raw | the secret (+ a one-time warning; re-wrapped on next write) | +| any | absent entry | `Ok(None)` (deletion = DoS, never injection) | + +The load-bearing row is **`Some(pw)` + non-envelope ⇒ +`ExpectedProtectedButUnsealed`**: with a password in hand, a non-protected +blob can only mean a strip, so it is refused and **no bytes are returned**. +A DET (consumer) bug alone — over- or under-supplying a password — fails +closed in *every* direction. + +**Arm asymmetry.** On the file arm the stored bytes are themselves sealed +under the vault key, so producing a *readable* stripped blob at a slot +requires the vault key; a cold/backup-swap actor can only corrupt +(→ DoS), not inject-to-readable. On the OS-keychain arm the stored item is +the bare envelope with no second seal, so the strip defence there leans +entirely on the `Some(pw)` strict rule plus the consumer's metadata +integrity — this is where the residual bites hardest. + +**Documented residual (out of the library's reach).** If an attacker ALSO +rewrites the consumer's trusted DB so the consumer calls `get_secret(X, +None)` for a stripped object, the `(scheme-0, None)` quadrant returns the +attacker's bytes. The library only ever sees the blob and the caller's +`Some/None`; the "should be protected" fact lives entirely in the +consumer's metadata store. **Anti-downgrade strength therefore equals the +tamper-resistance of the consumer's protection-status record** — store it +as integrity-protected, security-critical state (it is one more field +alongside the addresses/policy the wallet DB must already protect). + +**Value rollback is NOT defended.** Restoring an *older valid* scheme-1 +envelope under the *current* password decrypts cleanly. L-1 closes the +strip/downgrade injection, not value rollback; if backup-swap/restore-old +is in scope, anchor a monotonic version in integrity-protected consumer +metadata. Do not mistake L-1 for rollback protection. + +#### Add / change / remove an object password + +`reprotect(service, label, current, new)` does it in one same-slot +unwrap→rewrap→overwrite: read under the `current` expectation (so a strip +is caught before any rewrite), then write under `new` — `None`→`Some` adds, +`Some`→`Some` changes, `Some`→`None` removes. The write is the atomic +same-slot overwrite, so a crash between the read and the commit leaves the +prior value intact and readable under `current`. **After a successful call +the consumer MUST update its own protection-status record** (the L-1 +expectation lives there). There is **no password recovery** — losing an +object password bricks that object (an availability trade-off the UX must +state plainly). + +#### Entropy policy is the consumer's + +The library enforces only **non-blank** at enrol (and a coarse +`MIN_PASSPHRASE_LEN` floor, `1` today = merely non-blank) for both the +vault passphrase and the Tier-2 object password. It ships **no** +password-strength estimator: real entropy policy (zxcvbn-style strength, +dictionary checks, UX feedback) is locale- and threat-specific and is the +**consumer's responsibility**. For a protected object the password's +entropy is the *whole* guarantee against an offline Argon2id attacker who +already holds the backend — choose it accordingly. + +#### Greenfield / legacy entries + +The envelope is net-new, so post-feature reads/writes go through it. A +decrypted entry that lacks the `PWSEV` magic is treated as a **legacy +unprotected** value: returned on a `None` read (with a one-time warning, +and re-wrapped on the next write) and refused (`ExpectedProtectedButUnsealed`) +on a `Some(pw)` read — so legacy tolerance never weakens L-1. (A pre-feature +build that persisted vault files is a deployment fact outside this crate; +the legacy-tolerant read makes the transition seamless either way.) + ### Internal SPI Below `SecretStore`, `EncryptedFileStore` and `default_credential_store` @@ -138,7 +313,20 @@ unwrapped copy is allocated. Each secret is capped at `MAX_SECRET_LEN` (64 KiB) at the write boundary — generously above any mnemonic/seed/xpriv — so a single oversized entry cannot inflate the shared document past the read-side - 128 MiB ceiling and brick every wallet on the next open. + 128 MiB ceiling and brick every wallet on the next open. (Through + `SecretStore::set_secret`/`set` the user-facing plaintext cap is the + slightly lower `MAX_PLAINTEXT_LEN`, leaving room for the envelope + overhead; see **Two-tier secret protection**.) + **Blank passphrase is rejected.** `open` (and `rekey`) refuse a blank + (empty / all-whitespace) passphrase with `SecretStoreError::BlankPassphrase` + — a blank passphrase derives a key from a public salt only, i.e. + obfuscation, not confidentiality. This is an **intended behavioural + break** for any caller that relied on `SecretString::empty()`. A + deliberate keyless vault uses the explicit + `EncryptedFileStore::open_unprotected(path)` / + `SecretStore::file_unprotected(path)` door instead (use it only where the + stored secrets carry their own Tier-2 object password, or as a staging + step before `rekey` to a real passphrase — the empty→real migration). - **OS keyring (`SecretStore::os` / `default_credential_store`)** — returns an `Arc` over the platform's default credential store. The backend on Linux/FreeBSD is @@ -184,7 +372,16 @@ automatic fallback between backends. is **lossless**: `WrongPassphrase`, `Corruption`, `AlreadyLocked`, `KdfFailure`, `VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, `InsecureParentDir`, `SecretTooLarge`, `VaultTooLarge`, `Encrypt`, and -`InvalidLabel` are distinct typed variants. `VaultTooLarge` surfaces when +`InvalidLabel` are distinct typed variants. The Tier-2 layer adds five more: +`ExpectedProtectedButUnsealed` (the L-1 fail-closed strip refusal), +`NeedsPassword` (a protected object read with no password), `WrongPassword` +(object-password tag fail — distinct from the Tier-1 `WrongPassphrase`), +`BlankPassphrase` (a blank vault passphrase or object password), and +`UnsupportedEnvelopeVersion { found }` (a future envelope format, fail +closed regardless of the password). The four Tier-2 credential/protection +*state* variants project to a recoverable `NoStorageAccess` (boxed, +downcast-recoverable, like `WrongPassphrase`); `UnsupportedEnvelopeVersion` +joins the secret-free `BadStoreFormat` group. `VaultTooLarge` surfaces when the on-disk vault exceeds the read-side ceiling; `SecretTooLarge` rejects an oversized secret at the write boundary before it can inflate the shared vault; `InsecureParentDir` refuses a vault whose parent directory is diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index 1c91b8cd23..6cfc57ecb5 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -57,7 +57,7 @@ use format::{EntryBody, Vault}; use super::error::SecretStoreError; -use super::secret::{SecretBytes, SecretString}; +use super::secret::{SecretBytes, SecretString, MIN_PASSPHRASE_LEN}; use super::validate::{validated_label, WalletId}; /// Service-prefix for vault entries: the full `service` string is @@ -139,7 +139,33 @@ impl EncryptedFileStore { path: impl AsRef, passphrase: SecretString, ) -> Result { - let path = path.as_ref().to_path_buf(); + // Tier-1 baseline: reject a blank passphrase (empty / all-whitespace) + // BEFORE touching the filesystem. A blank passphrase derives a key + // from a public salt only — obfuscation, not confidentiality + // (SEC-A / F-1). This is an INTENDED behavioural break for any caller + // that relied on `SecretString::empty()`; a deliberate keyless vault + // must use [`open_unprotected`](Self::open_unprotected). No vault + // file is created or altered for a blank passphrase. + reject_weak_passphrase(&passphrase)?; + Self::open_inner(path.as_ref(), passphrase) + } + + /// Open (or create) a **deliberately keyless** vault — the only door + /// that accepts no passphrase. The vault key is derived from an empty + /// passphrase under the public salt, so this is **obfuscation, not + /// confidentiality**: use it only where the stored secrets carry their + /// own Tier-2 object password, or as a staging step before + /// [`rekey`](Self::rekey) to a real passphrase. AC-2.1 maps HERE, not to + /// [`open`](Self::open) (which now rejects a blank passphrase). + pub fn open_unprotected(path: impl AsRef) -> Result { + Self::open_inner(path.as_ref(), SecretString::empty()) + } + + /// Shared open/create core for [`open`](Self::open) and + /// [`open_unprotected`](Self::open_unprotected). Does NOT apply the + /// blank-passphrase guard — the public doors decide that. + fn open_inner(path: &Path, passphrase: SecretString) -> Result { + let path = path.to_path_buf(); // Materialize the parent so the lock-sidecar open and vault // create do not fail on a not-yet-existing dir. @@ -204,6 +230,11 @@ impl EncryptedFileStore { /// new passphrase + fresh salt, so paying ~hundreds of ms inside the /// critical section would needlessly stall unrelated put/get ops. pub fn rekey(&self, new_passphrase: SecretString) -> Result<(), SecretStoreError> { + // Reject a blank target passphrase: `rekey` always advances to a + // REAL passphrase (the empty→real migration uses this). The resident + // vault, key, and on-disk file are untouched on rejection. To make a + // vault keyless, use `open_unprotected` on a fresh path instead. + reject_weak_passphrase(&new_passphrase)?; let (new_vault, new_key) = build_fresh_vault(&new_passphrase)?; lock_inner(&self.inner).rekey(new_vault, new_key, new_passphrase) } @@ -471,6 +502,18 @@ fn lock_path_for(path: &Path) -> PathBuf { PathBuf::from(s) } +/// Reject a blank (empty / all-whitespace) or sub-floor passphrase → +/// [`SecretStoreError::BlankPassphrase`]. The floor is the coarse +/// [`MIN_PASSPHRASE_LEN`] (1 today = merely non-blank); the real entropy +/// policy is the consumer's (see `SECRETS.md`). A blank check alone closes +/// SEC-A/F-1; the length term keeps the floor wired for a future bump. +fn reject_weak_passphrase(passphrase: &SecretString) -> Result<(), SecretStoreError> { + if passphrase.is_blank() || passphrase.trimmed().len() < MIN_PASSPHRASE_LEN { + return Err(SecretStoreError::BlankPassphrase); + } + Ok(()) +} + /// Build a fresh entry-less vault (random salt, default Argon2 params, /// verify-token sealed under the derived key) plus that derived key, so /// the caller can seal entries without re-deriving. @@ -1431,6 +1474,150 @@ mod tests { ); } + /// TS-T1-001: a blank passphrase is rejected at `open` → + /// `BlankPassphrase`; no vault file (or lock sidecar) is created. + #[test] + fn open_rejects_blank_passphrase() { + for blank in [ + SecretString::empty(), + SecretString::new(""), + SecretString::new(" "), + SecretString::new("\t\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let err = EncryptedFileStore::open(&path, blank).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank passphrase must be rejected, got {err:?}" + ); + assert!(!path.exists(), "no vault file for a blank passphrase"); + assert!( + !lock_path_for(&path).exists(), + "no lock sidecar for a blank passphrase" + ); + } + } + + /// TS-T1-002: a blank passphrase is rejected at `rekey`; the resident + /// vault, key, and on-disk file are UNCHANGED — the original passphrase + /// still reads every entry, live and after reopen. + #[test] + fn rekey_rejects_blank_passphrase_vault_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); // real "pw-correct" + entry(&s, wid(1), "seed").set_secret(b"v1").unwrap(); + for blank in [SecretString::empty(), SecretString::new(" ")] { + let err = s.rekey(blank).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank rekey must be rejected, got {err:?}" + ); + } + // Old passphrase still reads the entry, live… + assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"v1"); + // …and after a clean reopen under the original passphrase. + drop(s); + let s2 = store_at(&path); + assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"v1"); + } + + /// TS-T1-003: `open_unprotected` permits a deliberate keyless vault that + /// round-trips; a real-passphrase `open` of that keyless vault then + /// fails with `WrongPassphrase` (it is keyless, not real-pass). + #[test] + fn open_unprotected_permits_keyless_vault() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed") + .set_secret(b"keyless-seed") + .unwrap(); + } + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"keyless-seed" + ); + } + let err = EncryptedFileStore::open(&path, SecretString::new("real")).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "real-pass open of a keyless vault must fail, got {err:?}" + ); + } + + /// TS-T1-004: empty→real passphrase migration via `rekey`. After rekey, + /// `open(real)` reads every entry; the keyless door no longer opens it; + /// no `.bak`/`.tmp` residue beside the vault. + #[test] + fn empty_to_real_rekey_migration() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed").set_secret(b"migrate-me").unwrap(); + s.rekey(SecretString::new("real-pass")).unwrap(); + // The live handle keeps working post-rekey. + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"migrate-me" + ); + } + // Reopen under the real passphrase reads the entry. + { + let s = EncryptedFileStore::open(&path, SecretString::new("real-pass")).unwrap(); + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"migrate-me" + ); + } + // The keyless door no longer opens it. + let err = EncryptedFileStore::open_unprotected(&path).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "keyless open after migration must fail, got {err:?}" + ); + // No .bak / .tmp residue (mirrors rekey_reencrypts_and_old_passphrase_fails). + for sibling in fs::read_dir(dir.path()).unwrap().flatten() { + let name = sibling.file_name(); + let name = name.to_string_lossy(); + assert!( + !name.ends_with(".bak") && !name.ends_with(".tmp"), + "unexpected residue: {name}" + ); + } + } + + /// TS-T1-004 crash-safety: a disk-write failure mid-rekey leaves the + /// pre-rekey keyless vault intact and readable via `open_unprotected` + /// (mirrors rekey_does_not_corrupt_on_disk_temp_failure). + #[cfg(unix)] + #[test] + fn empty_to_real_rekey_crash_safe_stays_keyless() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed").set_secret(b"keyless").unwrap(); + + // Read-only parent → the rekey atomic temp-write fails. + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap(); + let err = s.rekey(SecretString::new("real-pass")).unwrap_err(); + assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + + // The live handle still serves the pre-rekey keyless vault… + assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"keyless"); + // …and on disk it is still the keyless vault. + drop(s); + let s2 = EncryptedFileStore::open_unprotected(&path).unwrap(); + assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"keyless"); + } + #[test] fn build_rejects_malformed_service() { let dir = tempfile::tempdir().unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 0a88dcc841..5cb850414a 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -44,6 +44,17 @@ impl SecretStore { Ok(Self::File(EncryptedFileStore::open(path, passphrase)?)) } + /// Open (or create) a **deliberately keyless** file-backed vault — the + /// only door that takes no passphrase. Obfuscation, not confidentiality + /// (the key derives from an empty passphrase under the public salt): use + /// it where the stored secrets carry their own Tier-2 object password, + /// or as a staging step before [`EncryptedFileStore::rekey`] to a real + /// passphrase. [`file`](SecretStore::file) rejects a blank passphrase; + /// this is the explicit keyless alternative. + pub fn file_unprotected(path: impl AsRef) -> Result { + Ok(Self::File(EncryptedFileStore::open_unprotected(path)?)) + } + /// Open the platform's default OS keyring, failing closed when none /// is reachable (headless / no Secret Service). pub fn os() -> Result { From 351d1e6b47a3476e43b10e0d0f2236e4359c127a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:49:34 +0200 Subject: [PATCH 07/21] refactor(platform-wallet-storage): use keyring_core::mock::Store; annotate v5 deviations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review polish addressing the lead's dedup self-scan + QA annotation requirements. Dedup (prefer-established-packages): the GAP-005 Os-arm fixture was a bespoke `secrets::testing::InMemoryCredentialStore` (182 LOC). keyring-core 1.0.0 already ships `keyring_core::mock::Store` — not feature-gated, returns an `Arc` usable directly as `SecretStore::Os(..)`, and `build()` returns the SHARED `Arc` per `(service, user)` so a raw SPI `set_secret` (the backend-write attacker primitive) persists across the fresh entry `get_secret` builds. Replaced the custom mock with it and removed `testing.rs` and its `pub mod testing` gate. The L-1 strip-injection and full quadrant still pass on the Os arm (now via the upstream mock). QA annotations (v5 design supersedes Marvin's v4 test-spec wherever it overrides): the three adapted tests now name the superseding v5 clause in the body — TS-ENV-008 (v5 §4.6: plaintext cap = MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD, not MAX_SECRET_LEN), TS-ENV-010(a) and the TS-L1-001 quadrant row (v5 §4.1 legacy-tolerant: magic-less + None → bytes+warn, not Corruption; + Some(pw) still fails closed so L-1 holds). 142 secrets unit + integration tests green; clippy clean; all test targets compile. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../src/secrets/envelope.rs | 21 +- .../src/secrets/mod.rs | 4 - .../src/secrets/store.rs | 36 +++- .../src/secrets/testing.rs | 182 ------------------ 4 files changed, 37 insertions(+), 206 deletions(-) delete mode 100644 packages/rs-platform-wallet-storage/src/secrets/testing.rs diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs index d2cd043b87..3840a3d81b 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -596,11 +596,11 @@ mod tests { } } - /// TS-ENV-008 (SEC-F006 / GAP-006, v5 cap): the plaintext cap is - /// `MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD` (NOT `MAX_SECRET_LEN` as - /// the v4 spec literally read), uniform across schemes, so the - /// enveloped bytes always fit the backend vault cap. Accept at the cap, - /// reject at cap+1 with `max = MAX_PLAINTEXT_LEN`. + /// TS-ENV-008 — **v5 §4.6 supersedes the v4 test-spec.** The plaintext + /// cap is `MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD` (NOT `MAX_SECRET_LEN` + /// as TS-ENV-008 literally read); v5 §4.6 / SEC-F006 / GAP-006 fix the + /// off-by-overhead so the enveloped bytes always fit the backend cap. + /// Accept at the cap, reject at cap+1 with `max = MAX_PLAINTEXT_LEN`. #[test] fn plaintext_size_cap_at_envelope_boundary() { let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; @@ -626,11 +626,12 @@ mod tests { assert!(enveloped.len() <= MAX_SECRET_LEN); } - /// TS-ENV-010 (adopted §4.1 legacy-tolerant contingency): magic/version - /// discrimination. A magic-less blob is a legacy raw value — returned - /// on `None`, refused fail-closed on `Some(pw)`. A magic-present blob - /// with an unknown version fails closed both ways; truncated-after-magic - /// is corruption. + /// TS-ENV-010 — **v5 §4.1 (legacy-tolerant contingency) supersedes the + /// v4 test-spec.** magic/version discrimination: a magic-less blob is a + /// legacy raw value — returned on `None` (TS-ENV-010(a) read `Corruption`; + /// v5 §4.1 makes it bytes+warn), refused fail-closed on `Some(pw)` (so + /// L-1 is preserved). A magic-present blob with an unknown version fails + /// closed both ways; truncated-after-magic is corruption. #[test] fn magic_and_version_discrimination() { let p = pw("pw"); diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index 32a9758e53..4161e42300 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -28,10 +28,6 @@ mod file; mod keyring; mod secret; mod store; -/// In-memory backend test fixtures (writable `CredentialStoreApi` mock). -/// Compiled only under `cfg(test)` or the `__test-helpers` feature. -#[cfg(any(test, feature = "__test-helpers"))] -pub mod testing; mod validate; pub use envelope::MAX_PLAINTEXT_LEN; diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 5cb850414a..552a159f32 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -499,12 +499,15 @@ mod tests { // re-seals the chosen blob under the resident vault key via `put_bytes` // (a cold/backup-swap actor could only corrupt → DoS, so the strip // requires the vault key — §8.3 arm asymmetry); on Os it overwrites the - // mock keychain item directly (the bare envelope, no second AEAD — where - // the L-1 residual bites hardest, GAP-005 / §8.3). + // keychain item directly (the bare envelope, no second AEAD — where the + // L-1 residual bites hardest, §8.3). GAP-005's writable Os fixture is + // the upstream `keyring_core::mock::Store` (a raw SPI `set_secret` + // bypasses the envelope), so no bespoke mock is needed. + + use keyring_core::mock; use crate::secrets::file::crypto::{KdfParams, ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P}; use crate::secrets::file::format::KDF_ID_ARGON2ID; - use crate::secrets::testing::InMemoryCredentialStore; /// Argon2id floor params — fast enough for the keystone tests. fn floor() -> KdfParams { @@ -535,19 +538,28 @@ mod tests { struct Backend { store: SecretStore, _dir: Option, - mock: Option, + mock: Option>, name: &'static str, } impl Backend { /// Write `blob` to `(w, label)` as opaque backend bytes (the - /// attacker's primitive / the protected-enrol setup). + /// attacker's primitive / the protected-enrol setup). On Os this is + /// a raw SPI `set_secret` on the shared mock store, bypassing the + /// `SecretStore` envelope layer exactly as a breached keychain write + /// would. fn place_raw(&self, w: &WalletId, label: &str, blob: &[u8]) { match (&self.store, &self.mock) { (SecretStore::File(fs), _) => fs .put_bytes(w, label, &SecretBytes::from_slice(blob)) .unwrap(), - (SecretStore::Os(_), Some(mock)) => mock.raw_overwrite(w, label, blob), + (SecretStore::Os(_), Some(mock)) => { + let service = format!("{SERVICE_PREFIX}{}", w.to_hex()); + mock.build(&service, label, None) + .unwrap() + .set_secret(blob) + .unwrap(); + } _ => unreachable!("os backend must carry its mock"), } } @@ -565,8 +577,11 @@ mod tests { } fn os_backend() -> Backend { - let mock = InMemoryCredentialStore::new(); - let store = SecretStore::Os(mock.as_dyn()); + // GAP-005: the upstream in-memory mock store. The clone handed to + // `SecretStore::Os` and the handle kept for raw attacker writes + // share the same backing credentials by `Arc`. + let mock = mock::Store::new().unwrap(); + let store = SecretStore::Os(mock.clone()); Backend { store, _dir: None, @@ -651,8 +666,9 @@ mod tests { b.name ); - // magic-less legacy raw + None → bytes (adopted §4.1 contingency; - // deviates from v4 TS-L1-001's Corruption row). + Some → fail closed. + // magic-less legacy raw + None → bytes: v5 §4.1 (legacy-tolerant) + // supersedes v4 TS-L1-001's Corruption row. + Some → fail closed, + // so L-1 is preserved. b.place_raw(&w, "legacy", b"raw-legacy-seed-no-magic"); assert_eq!( b.store diff --git a/packages/rs-platform-wallet-storage/src/secrets/testing.rs b/packages/rs-platform-wallet-storage/src/secrets/testing.rs deleted file mode 100644 index a59aa3b7fa..0000000000 --- a/packages/rs-platform-wallet-storage/src/secrets/testing.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! In-memory test fixtures for the secrets backends. -//! -//! [`InMemoryCredentialStore`] is a writable -//! [`CredentialStoreApi`](keyring_core::api::CredentialStoreApi) double for -//! the [`SecretStore::Os`](crate::secrets::SecretStore::Os) arm — the only -//! shipped credential stores need a live OS keychain / D-Bus session, so -//! the Os arm (where the L-1 strip residual "bites hardest", per the threat -//! model §8.3) is otherwise un-coverable in CI (GAP-005). -//! -//! Beyond the SPI, it exposes [`raw_overwrite`](InMemoryCredentialStore::raw_overwrite) -//! / [`raw_get`](InMemoryCredentialStore::raw_get) so a test can play the -//! **backend-write attacker**: replace a slot's stored bytes with an -//! arbitrary blob (e.g. a stripped scheme-0 envelope) the way a breached -//! keychain would, then assert the strict read still fails closed. -//! -//! Compiled under `cfg(test)` or the `__test-helpers` feature, never in a -//! production build. - -use std::any::Any; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use keyring_core::api::{Credential, CredentialApi, CredentialPersistence, CredentialStoreApi}; -use keyring_core::{Entry, Error as KeyringError, Result as KeyringResult}; - -use super::validate::WalletId; -use super::SERVICE_PREFIX; - -/// Shared `(service, user) -> bytes` map backing every credential a store -/// hands out, so writes through one entry are visible to another. -type Items = Arc>>>; - -/// An in-memory, writable [`CredentialStoreApi`] for Os-arm tests. -#[derive(Default)] -pub struct InMemoryCredentialStore { - items: Items, -} - -impl InMemoryCredentialStore { - /// A fresh, empty store, ready to install as - /// `SecretStore::Os(store.into_arc())`. - pub fn new() -> Self { - Self::default() - } - - /// Hand back an `Arc` for the - /// `SecretStore::Os` arm. Clones share the same backing map, so the - /// returned trait object and `self` see each other's writes. - pub fn as_dyn(&self) -> Arc { - Arc::new(Self { - items: Arc::clone(&self.items), - }) - } - - /// The service string `SecretStore` derives for `wallet_id`, so a test - /// can address the exact slot the Os arm reads/writes. - pub fn service_for(wallet_id: &WalletId) -> String { - format!("{SERVICE_PREFIX}{}", wallet_id.to_hex()) - } - - /// **Attacker primitive:** overwrite the raw bytes at `(wallet_id, - /// label)` with `bytes`, bypassing any envelope layer — exactly what a - /// breached keychain write does. - pub fn raw_overwrite(&self, wallet_id: &WalletId, label: &str, bytes: &[u8]) { - self.lock().insert( - (Self::service_for(wallet_id), label.to_string()), - bytes.to_vec(), - ); - } - - /// Read the raw stored bytes at `(wallet_id, label)`, if any. - pub fn raw_get(&self, wallet_id: &WalletId, label: &str) -> Option> { - self.lock() - .get(&(Self::service_for(wallet_id), label.to_string())) - .cloned() - } - - fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(String, String), Vec>> { - self.items.lock().unwrap_or_else(|p| p.into_inner()) - } -} - -impl CredentialStoreApi for InMemoryCredentialStore { - fn vendor(&self) -> String { - "dash.platform-wallet-storage/in-memory-test".to_string() - } - - fn id(&self) -> String { - "in-memory-credential-store".to_string() - } - - fn build( - &self, - service: &str, - user: &str, - _modifiers: Option<&HashMap<&str, &str>>, - ) -> KeyringResult { - let cred = InMemoryCredential { - items: Arc::clone(&self.items), - service: service.to_string(), - user: user.to_string(), - }; - Ok(Entry::new_with_credential(Arc::new(cred))) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn persistence(&self) -> CredentialPersistence { - CredentialPersistence::UntilDelete - } -} - -impl std::fmt::Debug for InMemoryCredentialStore { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Never render stored bytes (they may be secret material). - f.debug_struct("InMemoryCredentialStore") - .field("entries", &self.lock().len()) - .finish() - } -} - -/// One `(service, user)` slot in an [`InMemoryCredentialStore`]. -struct InMemoryCredential { - items: Items, - service: String, - user: String, -} - -impl InMemoryCredential { - fn key(&self) -> (String, String) { - (self.service.clone(), self.user.clone()) - } - - fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(String, String), Vec>> { - self.items.lock().unwrap_or_else(|p| p.into_inner()) - } -} - -impl CredentialApi for InMemoryCredential { - fn set_secret(&self, secret: &[u8]) -> KeyringResult<()> { - self.lock().insert(self.key(), secret.to_vec()); - Ok(()) - } - - fn get_secret(&self) -> KeyringResult> { - self.lock() - .get(&self.key()) - .cloned() - .ok_or(KeyringError::NoEntry) - } - - fn delete_credential(&self) -> KeyringResult<()> { - if self.lock().remove(&self.key()).is_some() { - Ok(()) - } else { - Err(KeyringError::NoEntry) - } - } - - fn get_credential(&self) -> KeyringResult>> { - Ok(None) - } - - fn get_specifiers(&self) -> Option<(String, String)> { - Some(self.key()) - } - - fn as_any(&self) -> &dyn Any { - self - } -} - -impl std::fmt::Debug for InMemoryCredential { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("InMemoryCredential") - .field("service", &self.service) - .field("user", &self.user) - .finish_non_exhaustive() - } -} From d3df41e913a617c65007e4dc942b450fb34c86b3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:26:50 +0200 Subject: [PATCH 08/21] =?UTF-8?q?docs(platform-wallet-storage):=20QA=20fix?= =?UTF-8?q?es=20=E2=80=94=20rustdoc=20clarity=20+=20ephemeral-ID=20scrub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidated QA-wave fix batch, part 1 (docs/comments only). - No-recovery + entropy-delegation warnings added to `set_secret` AND `reprotect` rustdoc (lost object password = permanently unrecoverable; Tier-2 confidentiality rests on caller-supplied password entropy, strength policy is the caller's). - SECRETS.md: new paragraph that an OS-arm envelope tag failure is ambiguous (wrong password OR corrupted keychain item), resolving the `WrongPassword` rustdoc's forward reference; added `UnsupportedEnvelopeVersion` to the itemized BadStoreFormat group; added the `None` + truncated-header → `Corruption` row to the strict-read table; documented `reprotect`'s absent-entry no-op. - `SecretStore` enum doc corrected: reads are `get` / `get_secret` / the read inside `reprotect`, not "only `get`". - `BlankPassphrase` Display now points to `open_unprotected` for the deliberate keyless-vault case. - Softened "atomic on both arms" wording: the File arm is the vault's atomic replace; the Os arm inherits the backend's single-item-replace contract. - Ephemeral-ID scrub: removed session-internal finding/spec/clause IDs (SEC-*, GAP-*, TS-*, L-1/2/3, F-*, AC-*, §x.y) and v4→v5 history narration from all committed comments/rustdoc and SECRETS.md, keeping the technical rationale. Traceability belongs in the PR description, not the source. No production logic changed. clippy --lib --tests clean; 142 secrets unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../rs-platform-wallet-storage/SECRETS.md | 58 +++++--- .../src/secrets/envelope.rs | 72 +++++----- .../src/secrets/error.rs | 20 +-- .../src/secrets/file/mod.rs | 19 +-- .../src/secrets/secret.rs | 16 +-- .../src/secrets/store.rs | 129 ++++++++++-------- 6 files changed, 173 insertions(+), 141 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md index 99991f189d..8732e041cc 100644 --- a/packages/rs-platform-wallet-storage/SECRETS.md +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -156,7 +156,7 @@ scheme u8 (0 = unprotected passthrough, 1 = password) — fail closed **regardless of the password**: an unparseable future format can be neither safely unwrapped nor treated as unprotected. -#### The strict, fail-closed read (the L-1 keystone) +#### The strict, fail-closed read The defining risk of any opt-in "some objects are extra-protected" scheme is **strip / downgrade**: an attacker who can WRITE the backend replaces a @@ -175,19 +175,20 @@ object must be protected": | `password` arg | stored blob | result | |---|---|---| | `Some(pw)` | valid scheme-1 | the secret, or `WrongPassword` on tag fail | -| **`Some(pw)`** | **scheme-0 / legacy magic-less raw** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED ★** | +| **`Some(pw)`** | **scheme-0 / legacy magic-less raw** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED** | | `Some(pw)` | scheme-1 but truncated/corrupt | `Corruption` | | `Some/None` | magic present, unknown version/scheme | `UnsupportedEnvelopeVersion` | | `None` | valid scheme-1 | `NeedsPassword` (never ciphertext) | | `None` | scheme-0 | the secret | | `None` | legacy magic-less raw | the secret (+ a one-time warning; re-wrapped on next write) | +| `None` | magic present but truncated header | `Corruption` | | any | absent entry | `Ok(None)` (deletion = DoS, never injection) | The load-bearing row is **`Some(pw)` + non-envelope ⇒ `ExpectedProtectedButUnsealed`**: with a password in hand, a non-protected blob can only mean a strip, so it is refused and **no bytes are returned**. -A DET (consumer) bug alone — over- or under-supplying a password — fails -closed in *every* direction. +A consumer bug alone — over- or under-supplying a password — fails closed +in *every* direction. **Arm asymmetry.** On the file arm the stored bytes are themselves sealed under the vault key, so producing a *readable* stripped blob at a slot @@ -208,23 +209,25 @@ as integrity-protected, security-critical state (it is one more field alongside the addresses/policy the wallet DB must already protect). **Value rollback is NOT defended.** Restoring an *older valid* scheme-1 -envelope under the *current* password decrypts cleanly. L-1 closes the -strip/downgrade injection, not value rollback; if backup-swap/restore-old -is in scope, anchor a monotonic version in integrity-protected consumer -metadata. Do not mistake L-1 for rollback protection. +envelope under the *current* password decrypts cleanly. The strict read +closes the strip/downgrade injection, not value rollback; if +backup-swap/restore-old is in scope, anchor a monotonic version in +integrity-protected consumer metadata. Do not mistake the strict read for +rollback protection. #### Add / change / remove an object password `reprotect(service, label, current, new)` does it in one same-slot unwrap→rewrap→overwrite: read under the `current` expectation (so a strip is caught before any rewrite), then write under `new` — `None`→`Some` adds, -`Some`→`Some` changes, `Some`→`None` removes. The write is the atomic -same-slot overwrite, so a crash between the read and the commit leaves the -prior value intact and readable under `current`. **After a successful call -the consumer MUST update its own protection-status record** (the L-1 -expectation lives there). There is **no password recovery** — losing an -object password bricks that object (an availability trade-off the UX must -state plainly). +`Some`→`Some` changes, `Some`→`None` removes. An absent object is a no-op +(`Ok(())`). The rewrite is a same-slot overwrite — atomic on the file arm, +and on the OS arm inheriting the backend's single-item-replace contract — +so a crash between the read and the commit leaves the prior value intact +and readable under `current`. **After a successful call the consumer MUST +update its own protection-status record** (the protection expectation lives +there). There is **no password recovery** — losing an object password +bricks that object (an availability trade-off the UX must state plainly). #### Entropy policy is the consumer's @@ -243,9 +246,10 @@ The envelope is net-new, so post-feature reads/writes go through it. A decrypted entry that lacks the `PWSEV` magic is treated as a **legacy unprotected** value: returned on a `None` read (with a one-time warning, and re-wrapped on the next write) and refused (`ExpectedProtectedButUnsealed`) -on a `Some(pw)` read — so legacy tolerance never weakens L-1. (A pre-feature -build that persisted vault files is a deployment fact outside this crate; -the legacy-tolerant read makes the transition seamless either way.) +on a `Some(pw)` read — so legacy tolerance never weakens the strict read. +(A pre-feature build that persisted vault files is a deployment fact outside +this crate; the legacy-tolerant read makes the transition seamless either +way.) ### Internal SPI @@ -373,7 +377,7 @@ is **lossless**: `WrongPassphrase`, `Corruption`, `AlreadyLocked`, `KdfFailure`, `VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, `InsecureParentDir`, `SecretTooLarge`, `VaultTooLarge`, `Encrypt`, and `InvalidLabel` are distinct typed variants. The Tier-2 layer adds five more: -`ExpectedProtectedButUnsealed` (the L-1 fail-closed strip refusal), +`ExpectedProtectedButUnsealed` (the fail-closed strip refusal), `NeedsPassword` (a protected object read with no password), `WrongPassword` (object-password tag fail — distinct from the Tier-1 `WrongPassphrase`), `BlankPassphrase` (a blank vault passphrase or object password), and @@ -395,15 +399,25 @@ discriminant — keyring variants carrying raw bytes (`BadEncoding`, `BadDataFormat`) are collapsed so their bytes never enter the error (CWE-209/CWE-532). +**`WrongPassword` on the OS arm is ambiguous.** A Tier-2 envelope AEAD tag +failure surfaces as `WrongPassword`, but on the OS-keyring arm the stored +item is the bare envelope with no second authentication layer, so a tag +failure can mean EITHER a wrong object password OR a corrupted keychain +item — one AEAD tag cannot disambiguate the two. Treat `WrongPassword` on +the OS arm as "wrong password or corrupted item." On the file arm it is +unambiguous: the vault's own per-entry tag has already authenticated the +stored bytes before the envelope is parsed. + The internal SPI projection `From for keyring_core::Error` keeps the `WrongPassphrase` / `AlreadyLocked` variants recoverable: they ride in `NoStorageAccess` with the typed `SecretStoreError` 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`, -`InsecureParentDir`, `SecretTooLarge`, `VaultTooLarge`, `Decrypt`, -`Encrypt`, `OsKeyring`) has no box slot and carries only a secret-free +`VersionUnsupported`, `UnsupportedEnvelopeVersion`, `MalformedVault`, +`InsecurePermissions`, `InsecureParentDir`, `SecretTooLarge`, +`VaultTooLarge`, `Decrypt`, `Encrypt`, `OsKeyring`) has no box slot and +carries only a secret-free string; those remain fully typed on the `SecretStore` path (so e.g. `VaultTooLarge` / `SecretTooLarge` are not losslessly recoverable through the SPI downcast). diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs index 3840a3d81b..efb271e0a2 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -29,12 +29,12 @@ //! ## Reused, never reinvented //! - KDF: [`crypto::derive_key`] (Argon2id) with a fresh 32-byte salt; the //! param **ceiling is enforced BEFORE derivation** on the -//! attacker-controllable header (L-2, [`KdfParams::enforce_bounds`]). +//! attacker-controllable header ([`KdfParams::enforce_bounds`]). //! - AEAD: [`crypto::seal`]/[`crypto::open`] (XChaCha20-Poly1305), fresh //! per-wrap nonce; a tag failure maps to //! [`SecretStoreError::WrongPassword`] with no plaintext. //! - AAD binds `domain ‖ magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ wallet_id -//! ‖ label` (L-3), mirroring [`format::aad`]/[`format::verify_aad`] so a +//! ‖ label`, mirroring [`format::aad`]/[`format::verify_aad`] so a //! relocated/confused blob fails the tag. //! //! No bespoke crypto. @@ -86,7 +86,7 @@ pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 128; /// MAX_ENVELOPE_OVERHEAD`. Capping the **plaintext** (uniformly for both /// schemes) keeps the user-visible limit stable AND guarantees the /// enveloped bytes always fit the backend vault's own `MAX_SECRET_LEN` -/// `put_bytes` cap (design §4.6 / SEC-F006 / GAP-006). Re-exported at +/// `put_bytes` cap. Re-exported at /// [`crate::secrets`] as the documented, stable user-facing cap. pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; @@ -147,7 +147,7 @@ pub(crate) fn wrap_with_params( return Ok(SecretBytes::new(out)); }; - // Reject a blank object password BEFORE any derivation (SEC-J). + // Reject a blank object password BEFORE any derivation. if pw.is_blank() { return Err(SecretStoreError::BlankPassphrase); } @@ -174,14 +174,14 @@ pub(crate) fn wrap_with_params( } /// Unwrap `blob` for `(wallet_id, label)`, applying the **strict, -/// fail-closed** read (the L-1 keystone). The "expected-protected" bit is +/// fail-closed** read. The "expected-protected" bit is /// the caller's assertion, surfaced solely by `password`, and is NEVER /// inferred from the blob's scheme byte. /// /// | `password` | stored blob | result | /// |---|---|---| /// | `Some(pw)` | valid scheme-1 | secret, or [`WrongPassword`] on tag fail | -/// | `Some(pw)` | scheme-0 **or** magic-less (legacy raw) | [`ExpectedProtectedButUnsealed`] ★ | +/// | `Some(pw)` | scheme-0 **or** magic-less (legacy raw) | [`ExpectedProtectedButUnsealed`] | /// | `Some(pw)` | scheme-1 but too short | [`Corruption`] (sealed-but-broken) | /// | `Some/None` | magic present, unknown version/scheme | [`UnsupportedEnvelopeVersion`] | /// | `None` | valid scheme-1 | [`NeedsPassword`] (never ciphertext) | @@ -205,7 +205,7 @@ pub(crate) fn unwrap( blob: &[u8], ) -> Result { // Magic-less ⇒ a legacy unprotected raw value (scheme-0-equivalent), - // per the adopted §4.1 read-path contingency. + // (legacy-tolerant read-path: a None read returns it, a Some(pw) read refuses). if !blob.starts_with(MAGIC) { return match password { None => { @@ -213,7 +213,7 @@ pub(crate) fn unwrap( Ok(SecretBytes::from_slice(blob)) } // Caller asserted protection but found a magic-less raw value: - // a strip/downgrade ⇒ FAIL CLOSED (L-1). Never returns bytes. + // a strip/downgrade ⇒ FAIL CLOSED. Never returns bytes. Some(_) => Err(SecretStoreError::ExpectedProtectedButUnsealed), }; } @@ -226,7 +226,7 @@ pub(crate) fn unwrap( let version = blob[MAGIC.len()]; if version != ENVELOPE_VERSION { // Fail closed regardless of password — an unparseable future format - // can be neither safely unwrapped nor treated as scheme-0 (GAP-009). + // can be neither safely unwrapped nor treated as scheme-0. return Err(SecretStoreError::UnsupportedEnvelopeVersion { found: version }); } @@ -235,7 +235,7 @@ pub(crate) fn unwrap( match scheme { SCHEME_UNPROTECTED => match password { None => Ok(SecretBytes::from_slice(body)), - // Strip: caller expected protection, blob is unprotected (L-1). + // Strip: caller expected protection, blob is unprotected. Some(_) => Err(SecretStoreError::ExpectedProtectedButUnsealed), }, SCHEME_PASSWORD => match password { @@ -250,9 +250,9 @@ pub(crate) fn unwrap( /// Decrypt a scheme-1 body. The KDF params, salt, and nonce are all read /// from the (attacker-controllable) header; the param **ceiling is -/// enforced before** [`crypto::derive_key`] allocates (L-2), and every +/// enforced before** [`crypto::derive_key`] allocates, and every /// header field that feeds key/AAD is bound into the AAD so any in-place -/// edit fails the tag (L-3). +/// edit fails the tag. fn unwrap_scheme1( wallet_id: &WalletId, label: &str, @@ -265,7 +265,7 @@ fn unwrap_scheme1( return Err(SecretStoreError::Corruption); } let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); - // L-2: gate the inflated/unknown header BEFORE any derivation/alloc. + // Gate the inflated/unknown header BEFORE any derivation/alloc. kdf.enforce_bounds()?; let mut salt = [0u8; SALT_LEN]; @@ -285,7 +285,7 @@ fn unwrap_scheme1( } } -/// Build the scheme-1 AAD binding object identity + header (L-3), +/// Build the scheme-1 AAD binding object identity + header, /// length-prefixed for the variable fields, mirroring /// [`format::aad`](super::file::format::aad)/`verify_aad`. fn scheme1_aad( @@ -422,7 +422,7 @@ mod tests { .to_vec() } - /// TS-ENV-001: scheme-0 passthrough round-trip; the wrapped form leads + /// scheme-0 passthrough round-trip; the wrapped form leads /// with magic, version=1, scheme=0, then the raw payload. #[test] fn scheme0_passthrough_round_trip() { @@ -436,7 +436,7 @@ mod tests { assert_eq!(got.expose_secret(), secret); } - /// TS-ENV-002: scheme-1 round-trip; header records the argon2id id, a + /// scheme-1 round-trip; header records the argon2id id, a /// 32-byte fresh salt and 24-byte nonce, ct != pt, and two wraps of the /// same secret/pw differ in salt+nonce (no reuse). #[test] @@ -467,7 +467,7 @@ mod tests { ); } - /// TS-ENV-003: wrong object password → WrongPassword, no plaintext. + /// Wrong object password → WrongPassword, no plaintext. #[test] fn wrong_password_fails_closed() { let blob = wrap_p(&wid(1), "seed", Some(&pw("right")), b"seed", floor()); @@ -478,7 +478,7 @@ mod tests { ); } - /// TS-ENV-004: identity AAD (L-3) — a protected blob unwrapped at any + /// Identity AAD — a protected blob unwrapped at any /// other (wallet, label) fails the tag; same-identity still succeeds. #[test] fn relocation_across_identity_is_rejected() { @@ -495,7 +495,7 @@ mod tests { assert_eq!(ok.expose_secret(), b"seed"); } - /// TS-ENV-005: per-field header tamper. Unknown KDF id is rejected by + /// Per-field header tamper. Unknown KDF id is rejected by /// `enforce_bounds` (KdfFailure) before derive; in-bounds KDF shifts, /// salt, and nonce all fail the AEAD tag (WrongPassword) — never the /// plaintext. @@ -545,7 +545,7 @@ mod tests { )); } - /// TS-ENV-006 ★ (L-2): an inflated KDF param on a forged header is + /// An inflated KDF param on a forged header is /// rejected by `enforce_bounds` BEFORE `derive_key` allocates — the /// ~4 TiB allocation never happens (the test would OOM if it did). The /// exact ceilings remain valid params. @@ -582,7 +582,7 @@ mod tests { .is_ok()); } - /// TS-ENV-007: a blank object password is rejected at enrol; nothing + /// A blank object password is rejected at enrol; nothing /// is sealed. #[test] fn blank_object_password_rejected_at_enrol() { @@ -596,11 +596,10 @@ mod tests { } } - /// TS-ENV-008 — **v5 §4.6 supersedes the v4 test-spec.** The plaintext - /// cap is `MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD` (NOT `MAX_SECRET_LEN` - /// as TS-ENV-008 literally read); v5 §4.6 / SEC-F006 / GAP-006 fix the - /// off-by-overhead so the enveloped bytes always fit the backend cap. - /// Accept at the cap, reject at cap+1 with `max = MAX_PLAINTEXT_LEN`. + /// The plaintext is capped at `MAX_PLAINTEXT_LEN` (`MAX_SECRET_LEN − + /// MAX_ENVELOPE_OVERHEAD`), uniform across schemes, so plaintext + + /// overhead always fits the backend's own `MAX_SECRET_LEN` cap. Accept + /// at the cap, reject at cap+1 with `max = MAX_PLAINTEXT_LEN`. #[test] fn plaintext_size_cap_at_envelope_boundary() { let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; @@ -626,12 +625,11 @@ mod tests { assert!(enveloped.len() <= MAX_SECRET_LEN); } - /// TS-ENV-010 — **v5 §4.1 (legacy-tolerant contingency) supersedes the - /// v4 test-spec.** magic/version discrimination: a magic-less blob is a - /// legacy raw value — returned on `None` (TS-ENV-010(a) read `Corruption`; - /// v5 §4.1 makes it bytes+warn), refused fail-closed on `Some(pw)` (so - /// L-1 is preserved). A magic-present blob with an unknown version fails - /// closed both ways; truncated-after-magic is corruption. + /// magic/version discrimination: a magic-less blob is a legacy raw + /// value — returned on a `None` read (with a one-time warning), refused + /// fail-closed on `Some(pw)` so the strict rule holds. A magic-present + /// blob with an unknown version fails closed both ways; truncated- + /// after-magic is corruption. #[test] fn magic_and_version_discrimination() { let p = pw("pw"); @@ -640,7 +638,7 @@ mod tests { // None ⇒ legacy raw bytes (adopted contingency; NOT Corruption). let got = unwrap(&wid(1), "seed", None, &legacy).unwrap(); assert_eq!(got.expose_secret(), &legacy[..]); - // Some(pw) ⇒ strip/downgrade ⇒ fail closed (L-1 preserved). + // Some(pw) ⇒ strip/downgrade ⇒ fail closed. assert!(matches!( unwrap(&wid(1), "seed", Some(&p), &legacy).unwrap_err(), SecretStoreError::ExpectedProtectedButUnsealed @@ -655,7 +653,7 @@ mod tests { )); // (c) Magic OK but version = 2 ⇒ UnsupportedEnvelopeVersion{2}, - // regardless of password (GAP-009). + // regardless of password. let mut v2 = wrap_bytes(&wid(1), "seed", None, b"x"); v2[O_VERSION] = 2; for arg in [None, Some(&p)] { @@ -674,7 +672,7 @@ mod tests { )); } - /// Non-vacuity helper for the L-1 keystone (used here and by the store + /// Non-vacuity helper for the strict read (used here and by the store /// tests): a scheme-0 blob carrying `secret` DOES decode under `None`. #[test] fn scheme0_some_password_fails_closed_strip() { @@ -686,7 +684,7 @@ mod tests { .expose_secret(), b"attacker-seed" ); - // …but Some(pw) ⇒ ExpectedProtectedButUnsealed, no bytes (L-1). + // …but Some(pw) ⇒ ExpectedProtectedButUnsealed, no bytes. assert!(matches!( unwrap(&wid(1), "seed", Some(&pw("pw")), &blob).unwrap_err(), SecretStoreError::ExpectedProtectedButUnsealed @@ -704,7 +702,7 @@ mod tests { assert!(bool::from(got.ct_eq(&original))); } - /// TS-ENV-009: deterministic byte-level fuzz. Every mutant unwrap is a + /// Deterministic byte-level fuzz. Every mutant unwrap is a /// clean `Ok` or a TYPED `SecretStoreError` — never a panic, never /// plaintext from a tag-failing branch. The `None` path (no Argon2 /// derivation) runs the full 2000 mutants + every truncation; the diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs index 5290a39c8b..6c69e9e8b9 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/error.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs @@ -20,7 +20,7 @@ pub enum SecretStoreError { #[error("wrong passphrase")] WrongPassphrase, - /// Tier-2 (L-1 keystone): the caller asserted — by supplying an object + /// Tier-2 strip/downgrade guard: the caller asserted — by supplying an object /// password — that this object MUST be password-protected, but the /// stored value is a well-formed UNPROTECTED envelope (scheme-0) or a /// legacy magic-less raw value, i.e. a strip/downgrade. **Fails @@ -50,7 +50,9 @@ pub enum SecretStoreError { /// [`SecretString::is_blank`]. CWE-521. /// /// [`SecretString::is_blank`]: crate::secrets::SecretString::is_blank - #[error("passphrase must not be blank")] + #[error( + "passphrase must not be blank; for a deliberately keyless file vault use open_unprotected" + )] BlankPassphrase, /// AEAD tag failure on a stored entry (or rekey re-encrypt) *after* @@ -76,7 +78,7 @@ pub enum SecretStoreError { /// known version, a `scheme`) this build does not understand. Fails /// closed REGARDLESS of the password argument — an unparseable future /// format can be neither safely unwrapped nor safely treated as - /// unprotected, so it is refused both ways (GAP-009). Mirrors + /// unprotected, so it is refused both ways. Mirrors /// [`VersionUnsupported`] for the vault format. /// /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported @@ -288,7 +290,7 @@ impl From for SecretStoreError { /// `err.source().and_then(|s| s.downcast_ref::())`. /// These are all "the caller must act on a credential/expectation to /// proceed" states, so lossless recovery lets an SPI consumer react -/// precisely (resolves GAP-004 for these variants). +/// precisely. /// - The format/crypto group — including [`UnsupportedEnvelopeVersion`] /// (a fail-closed forward-format incompatibility, mirroring /// [`VersionUnsupported`]) — collapses into @@ -454,7 +456,7 @@ mod tests { assert!(!format!("{k}").contains("plaintext")); } - /// TS-ERR-001: the five new variants exist, are constructable, render + /// The five new variants exist, are constructable, render /// distinct non-empty messages, and the Tier-2 `WrongPassword` is NOT /// the Tier-1 `WrongPassphrase` (nor is the unseal error `Corruption`). #[test] @@ -477,7 +479,7 @@ mod tests { assert_eq!(msgs.len(), 5, "all five messages must be distinct"); } - /// TS-ERR-002: Display + Debug render static, secret-free text. The + /// Display + Debug render static, secret-free text. The /// version variant surfaces the (non-secret) version byte and nothing /// more. #[test] @@ -490,7 +492,7 @@ mod tests { assert_eq!(E::WrongPassword.to_string(), "wrong object password"); assert_eq!( E::BlankPassphrase.to_string(), - "passphrase must not be blank" + "passphrase must not be blank; for a deliberately keyless file vault use open_unprotected" ); assert_eq!( E::ExpectedProtectedButUnsealed.to_string(), @@ -513,7 +515,7 @@ mod tests { } } - /// TS-ERR-003 (resolving GAP-004): the four Tier-2 credential / + /// The four Tier-2 credential / /// protection states project to a recoverable `NoStorageAccess` with /// the typed error losslessly downcast-able, leaking no secret. #[test] @@ -540,7 +542,7 @@ mod tests { } } - /// TS-ERR-003: `UnsupportedEnvelopeVersion` projects to the + /// `UnsupportedEnvelopeVersion` projects to the /// secret-free `BadStoreFormat` group (forward-format incompat, /// mirroring `VersionUnsupported`). #[test] diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index 6cfc57ecb5..1d2471e780 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -142,7 +142,7 @@ impl EncryptedFileStore { // Tier-1 baseline: reject a blank passphrase (empty / all-whitespace) // BEFORE touching the filesystem. A blank passphrase derives a key // from a public salt only — obfuscation, not confidentiality - // (SEC-A / F-1). This is an INTENDED behavioural break for any caller + // (obfuscation, not confidentiality). This is an INTENDED behavioural break for any caller // that relied on `SecretString::empty()`; a deliberate keyless vault // must use [`open_unprotected`](Self::open_unprotected). No vault // file is created or altered for a blank passphrase. @@ -155,8 +155,9 @@ impl EncryptedFileStore { /// passphrase under the public salt, so this is **obfuscation, not /// confidentiality**: use it only where the stored secrets carry their /// own Tier-2 object password, or as a staging step before - /// [`rekey`](Self::rekey) to a real passphrase. AC-2.1 maps HERE, not to - /// [`open`](Self::open) (which now rejects a blank passphrase). + /// [`rekey`](Self::rekey) to a real passphrase. This is the explicit + /// keyless door, distinct from [`open`](Self::open) (which now rejects a + /// blank passphrase). pub fn open_unprotected(path: impl AsRef) -> Result { Self::open_inner(path.as_ref(), SecretString::empty()) } @@ -506,7 +507,7 @@ fn lock_path_for(path: &Path) -> PathBuf { /// [`SecretStoreError::BlankPassphrase`]. The floor is the coarse /// [`MIN_PASSPHRASE_LEN`] (1 today = merely non-blank); the real entropy /// policy is the consumer's (see `SECRETS.md`). A blank check alone closes -/// SEC-A/F-1; the length term keeps the floor wired for a future bump. +/// the length term keeps the floor wired for a future bump. fn reject_weak_passphrase(passphrase: &SecretString) -> Result<(), SecretStoreError> { if passphrase.is_blank() || passphrase.trimmed().len() < MIN_PASSPHRASE_LEN { return Err(SecretStoreError::BlankPassphrase); @@ -1474,7 +1475,7 @@ mod tests { ); } - /// TS-T1-001: a blank passphrase is rejected at `open` → + /// A blank passphrase is rejected at `open` → /// `BlankPassphrase`; no vault file (or lock sidecar) is created. #[test] fn open_rejects_blank_passphrase() { @@ -1499,7 +1500,7 @@ mod tests { } } - /// TS-T1-002: a blank passphrase is rejected at `rekey`; the resident + /// A blank passphrase is rejected at `rekey`; the resident /// vault, key, and on-disk file are UNCHANGED — the original passphrase /// still reads every entry, live and after reopen. #[test] @@ -1523,7 +1524,7 @@ mod tests { assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"v1"); } - /// TS-T1-003: `open_unprotected` permits a deliberate keyless vault that + /// `open_unprotected` permits a deliberate keyless vault that /// round-trips; a real-passphrase `open` of that keyless vault then /// fails with `WrongPassphrase` (it is keyless, not real-pass). #[test] @@ -1550,7 +1551,7 @@ mod tests { ); } - /// TS-T1-004: empty→real passphrase migration via `rekey`. After rekey, + /// Empty→real passphrase migration via `rekey`. After rekey, /// `open(real)` reads every entry; the keyless door no longer opens it; /// no `.bak`/`.tmp` residue beside the vault. #[test] @@ -1592,7 +1593,7 @@ mod tests { } } - /// TS-T1-004 crash-safety: a disk-write failure mid-rekey leaves the + /// Crash-safety: a disk-write failure mid-rekey leaves the /// pre-rekey keyless vault intact and readable via `open_unprotected` /// (mirrors rekey_does_not_corrupt_on_disk_temp_failure). #[cfg(unix)] diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs index 46ca130b3f..9363c57584 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs @@ -225,7 +225,7 @@ impl<'de> serde::Deserialize<'de> for SecretString { /// Render the JSON schema as a plain `string` carrying **no** length or /// value policy: no `minLength`/`maxLength`/`pattern`/`format` (would leak /// a length policy) and no `example`/`default` (would embed a value) -/// (F-7). A short, value-free `description` marks sensitivity. +/// A short, value-free `description` marks sensitivity. /// /// Gated behind the default-off `secret-schemars` feature (which implies /// `secret-serde`). Pulls in no `Serialize`/`Display` path. @@ -386,7 +386,7 @@ mod tests { assert_eq!(SecretString::default().len(), 0); } - /// TS-SER-001: `is_blank()` truth table. The boundary deliberately + /// `is_blank()` truth table. The boundary deliberately /// exercises Unicode whitespace — `str::trim` uses the `White_Space` /// property, so NBSP (`U+00A0`) trims to blank but ZWSP (`U+200B`, /// not `White_Space`) does not. @@ -410,7 +410,7 @@ mod tests { ); } - /// TS-SER-002: `is_blank` returns a `bool` and exposes no borrowed + /// `is_blank` returns a `bool` and exposes no borrowed /// plaintext, callable with only `secrets` (no serde/schemars). #[test] fn is_blank_signature_returns_bool_no_borrow() { @@ -419,7 +419,7 @@ mod tests { assert!(!f(&SecretString::new("x"))); } - /// TS-SER-005 / TS-SER-007: `SecretString` must never implement + /// `SecretString` must never implement /// `Serialize` or `Display`, even with serde compiled in. This is a /// compile-time `!impl` assertion — adding either impl breaks the /// build. `serde::Serialize` is nameable here because `secrets` always @@ -429,7 +429,7 @@ mod tests { static_assertions::assert_not_impl_any!(SecretString: serde::Serialize, std::fmt::Display); } - /// TS-SER-008 / GAP-002 regression: the `serde` DEP is on under + /// Regression: the `serde` DEP is on under /// `secrets`, yet the `Deserialize` IMPL stays ABSENT because it is /// gated on the dedicated `secret-serde` feature — proving the /// default-off gate is satisfiable even while serde is compiled. @@ -441,7 +441,7 @@ mod tests { ); } - /// TS-SER-008: with `secret-serde` on, the `Deserialize` impl is + /// With `secret-serde` on, the `Deserialize` impl is /// present (and `Serialize` is still absent — see the always-on test). #[cfg(feature = "secret-serde")] #[test] @@ -450,7 +450,7 @@ mod tests { static_assertions::assert_not_impl_any!(SecretString: serde::Serialize); } - /// TS-SER-003: `Deserialize` round-trips the value through the + /// `Deserialize` round-trips the value through the /// zeroizing constructor; the result `ct_eq`s a directly-built secret /// and has the right length. #[cfg(feature = "secret-serde")] @@ -463,7 +463,7 @@ mod tests { assert_eq!(s.len(), 28); } - /// TS-SER-006: `JsonSchema` renders a plain `string` and leaks no + /// `JsonSchema` renders a plain `string` and leaks no /// length/value policy — no `minLength`/`maxLength`/`pattern`/`format`, /// no `example`/`default`/`enum`. #[cfg(feature = "secret-schemars")] diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 552a159f32..b545f2f26a 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -20,10 +20,12 @@ use super::{default_credential_store, EncryptedFileStore, SERVICE_PREFIX}; /// A passphrase-or-OS-keyring backed store for wallet secret material. /// -/// The only public read path is [`get`](SecretStore::get), which yields a -/// zeroizing [`SecretBytes`] — a raw `Vec` never crosses this -/// boundary. Backend selection is an explicit operator decision; there is -/// no silent fallback between the two arms. +/// Every read path ([`get`](SecretStore::get), +/// [`get_secret`](SecretStore::get_secret), and the read inside +/// [`reprotect`](SecretStore::reprotect)) yields a zeroizing +/// [`SecretBytes`] — a raw `Vec` never crosses this boundary. Backend +/// selection is an explicit operator decision; there is no silent fallback +/// between the two arms. pub enum SecretStore { /// Self-contained Argon2id + XChaCha20-Poly1305 vault file. /// Recommended on headless / server hosts. @@ -76,17 +78,26 @@ impl SecretStore { /// Store `secret` under `(service, label)`, overwriting any prior value. /// - /// `password` selects the Tier-2 protection: `None` writes an - /// unprotected scheme-0 envelope; `Some(pw)` writes a scheme-1 envelope - /// sealed under the object password `pw` (Argon2id + XChaCha20-Poly1305) - /// **before** the bytes reach the backend, so a protected object stays - /// confidential even under a full backend compromise. A blank `pw` is - /// rejected ([`BlankPassphrase`](SecretStoreError::BlankPassphrase)). + /// `password` selects the protection: `None` writes an unprotected + /// envelope; `Some(pw)` seals the bytes under the object password `pw` + /// (Argon2id + XChaCha20-Poly1305) **before** they reach the backend, so + /// a protected object stays confidential even under a full backend + /// compromise. A blank `pw` is rejected + /// ([`BlankPassphrase`](SecretStoreError::BlankPassphrase)). /// - /// The write is an atomic same-slot overwrite on both arms (File: the - /// vault's atomic replace; Os: the keychain item), so add/change/remove - /// password flows — see [`reprotect`](SecretStore::reprotect) — leave - /// the prior value intact on a crash. + /// **No recovery (availability):** if a protected object's password is + /// lost, the object is permanently unrecoverable — there is no reset + /// path. The UX must state this plainly. + /// + /// **Entropy is the caller's:** a protected object's confidentiality + /// rests entirely on the password's entropy against an offline Argon2id + /// attacker who already holds the backend. This crate enforces only + /// non-blank; strength estimation / policy is the caller's job. + /// + /// The write is a same-slot overwrite that leaves the prior value intact + /// on a crash: on the `File` arm via the vault's atomic replace; on the + /// `Os` arm via the backend's single-item-replace contract. + /// Add/change/remove flows go through [`reprotect`](SecretStore::reprotect). pub fn set_secret( &self, service: &WalletId, @@ -162,23 +173,22 @@ impl SecretStore { } } - /// Retrieve the secret under `(service, label)` applying the Tier-2 - /// **strict, fail-closed** read (the L-1 keystone), or `Ok(None)` if - /// absent. + /// Retrieve the secret under `(service, label)` applying the strict, + /// fail-closed read, or `Ok(None)` if absent. /// /// `password` IS the caller's protection assertion — supply `Some(pw)` /// for an object the caller's trusted model says is protected, `None` /// otherwise. The expectation lives ONLY here, never in the stored /// blob (see [`envelope::unwrap`]): /// - /// - `Some(pw)` + valid scheme-1 → the secret (or + /// - `Some(pw)` + a protected blob → the secret (or /// [`WrongPassword`](SecretStoreError::WrongPassword) on tag fail); - /// - `Some(pw)` + a non-protected blob (scheme-0 / legacy raw) → + /// - `Some(pw)` + a non-protected blob (unprotected / legacy raw) → /// [`ExpectedProtectedButUnsealed`](SecretStoreError::ExpectedProtectedButUnsealed) - /// — a strip/downgrade, refused, no bytes returned ★; - /// - `None` + scheme-1 → + /// — a strip/downgrade, refused, no bytes returned; + /// - `None` + a protected blob → /// [`NeedsPassword`](SecretStoreError::NeedsPassword) (never ciphertext); - /// - `None` + scheme-0 / legacy raw → the secret. + /// - `None` + an unprotected / legacy raw blob → the secret. /// /// **Documented residual:** an attacker who ALSO rewrites the /// consumer's trusted DB so the caller passes `None` for a stripped @@ -200,7 +210,7 @@ impl SecretStore { } /// Add / change / remove an object password in one same-slot - /// unwrap→rewrap→overwrite — the canonical Tier-2 re-protection flow. + /// unwrap→rewrap→overwrite — the canonical re-protection flow. /// /// Reads the object under the `current` expectation (so a strip is /// caught fail-closed before any rewrap), then re-writes it under @@ -209,11 +219,19 @@ impl SecretStore { /// - **change:** `current = Some(old)`, `new = Some(pw_new)`; /// - **remove:** `current = Some(old)`, `new = None`. /// - /// An absent object is a no-op (`Ok(())`). The rewrite is the atomic - /// same-slot overwrite of [`set_secret`], so a crash between the read - /// and the commit leaves the prior value intact and readable under - /// `current`. After a successful call the consumer MUST update its own - /// trusted protection-status record (the L-1 expectation lives there). + /// An absent object is a no-op (`Ok(())`). The rewrite is the same-slot + /// overwrite of [`set_secret`], so a crash between the read and the + /// commit leaves the prior value intact and readable under `current`. + /// After a successful call the consumer MUST update its own trusted + /// protection-status record (the protection expectation lives there). + /// + /// **No recovery:** changing or removing requires the `current` + /// password; if it is lost the object cannot be re-protected or read, + /// and is permanently unrecoverable (availability trade-off). + /// + /// **Entropy is the caller's:** the `new` password's entropy is the + /// whole confidentiality guarantee for the re-protected object; this + /// crate enforces only non-blank, not strength. pub fn reprotect( &self, service: &WalletId, @@ -492,24 +510,24 @@ mod tests { } } - // ===== Tier-2 strict fail-closed read — the L-1 keystone ===== + // ===== Tier-2 strict fail-closed read ===== // // Parameterised over BOTH arms. The "attacker who can write the // backend" is modelled per arm by `Backend::place_raw`: on File it // re-seals the chosen blob under the resident vault key via `put_bytes` // (a cold/backup-swap actor could only corrupt → DoS, so the strip - // requires the vault key — §8.3 arm asymmetry); on Os it overwrites the - // keychain item directly (the bare envelope, no second AEAD — where the - // L-1 residual bites hardest, §8.3). GAP-005's writable Os fixture is - // the upstream `keyring_core::mock::Store` (a raw SPI `set_secret` - // bypasses the envelope), so no bespoke mock is needed. + // requires the vault key — the File-arm asymmetry); on Os it overwrites + // the keychain item directly (the bare envelope, no second AEAD — where + // the strip residual bites hardest). The writable Os fixture is the + // upstream `keyring_core::mock::Store` (a raw SPI `set_secret` bypasses + // the envelope), so no bespoke mock is needed. use keyring_core::mock; use crate::secrets::file::crypto::{KdfParams, ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P}; use crate::secrets::file::format::KDF_ID_ARGON2ID; - /// Argon2id floor params — fast enough for the keystone tests. + /// Argon2id floor params — fast enough for these tests. fn floor() -> KdfParams { KdfParams { id: KDF_ID_ARGON2ID, @@ -577,7 +595,7 @@ mod tests { } fn os_backend() -> Backend { - // GAP-005: the upstream in-memory mock store. The clone handed to + // The upstream in-memory mock store. The clone handed to // `SecretStore::Os` and the handle kept for raw attacker writes // share the same backing credentials by `Arc`. let mock = mock::Store::new().unwrap(); @@ -590,7 +608,7 @@ mod tests { } } - /// TS-L1-001: the strict-read QUADRANT. + /// The strict-read quadrant. fn run_quadrant(b: &Backend) { let w = wid(1); let pw = SecretString::new("object-pw"); @@ -643,7 +661,7 @@ mod tests { b.name ); - // ★ scheme-0 + Some(pw) → ExpectedProtectedButUnsealed (fail closed). + // scheme-0 + Some(pw) → ExpectedProtectedButUnsealed (fail closed). assert!( matches!( b.store.get_secret(&w, "u0", Some(&pw)).unwrap_err(), @@ -666,9 +684,8 @@ mod tests { b.name ); - // magic-less legacy raw + None → bytes: v5 §4.1 (legacy-tolerant) - // supersedes v4 TS-L1-001's Corruption row. + Some → fail closed, - // so L-1 is preserved. + // magic-less legacy raw + None → returns the bytes (legacy + // tolerance); + Some(pw) → fails closed, so the strict rule holds. b.place_raw(&w, "legacy", b"raw-legacy-seed-no-magic"); assert_eq!( b.store @@ -708,7 +725,7 @@ mod tests { run_quadrant(&os_backend()); } - /// TS-L1-002 ★ — the non-vacuous strip-injection regression. The single + /// The non-vacuous strip-injection regression. The single /// test the whole feature exists to make pass. fn run_strip_injection(b: &Backend) { let w = wid(2); @@ -736,7 +753,7 @@ mod tests { let attacker_blob = unprotected(&w, "seed", b"EVIL-SEED-S_evil"); b.place_raw(&w, "seed", &attacker_blob); - // ★ A password-supplied read of the stripped slot fails closed; + // A password-supplied read of the stripped slot fails closed; // S_evil is NEVER returned. let err = b.store.get_secret(&w, "seed", Some(&pw)).unwrap_err(); assert!( @@ -768,7 +785,7 @@ mod tests { run_strip_injection(&os_backend()); } - /// TS-L1-003: a DET bug alone fails closed in BOTH directions. + /// A consumer bug alone fails closed in BOTH directions. fn run_both_det_bug_directions(b: &Backend) { let w = wid(3); let pw = SecretString::new("pw"); @@ -796,7 +813,7 @@ mod tests { run_both_det_bug_directions(&os_backend()); } - /// TS-L1-004: the expectation is NEVER inferred from the blob's scheme + /// The expectation is NEVER inferred from the blob's scheme /// byte — identical scheme-1 blobs diverge solely on the password arg. fn run_expectation_not_inferred(b: &Backend) { let w = wid(4); @@ -828,7 +845,7 @@ mod tests { run_expectation_not_inferred(&os_backend()); } - /// TS-L1-005: unprotected→scheme-1 upgrade confusion is availability- + /// Unprotected→protected upgrade confusion is availability- /// only, fail-closed (NeedsPassword), no leak / no injection. fn run_upgrade_confusion(b: &Backend) { let w = wid(5); @@ -849,10 +866,10 @@ mod tests { run_upgrade_confusion(&os_backend()); } - /// TS-L1-006: an in-place scheme-byte flip (1→0). Some(pw) is caught by + /// An in-place scheme-byte flip (protected→unprotected). Some(pw) is caught by /// the strict rule regardless. None reads the body as scheme-0 opaque - /// bytes (never the real seed) — the GAP-010 residual, dominated by the - /// DET-DB residual; pinned, not "fixed". + /// bytes (never the real seed) — a known residual, dominated by the + /// consumer-DB residual; pinned, not "fixed". fn run_scheme_flip(b: &Backend) { let w = wid(6); let pw = SecretString::new("pw"); @@ -884,13 +901,13 @@ mod tests { run_scheme_flip(&os_backend()); } - // ===== Add / change / remove password + arm matrix (TS-PW / TS-ARM) ===== + // ===== Add / change / remove password + arm matrix ===== // // These exercise the PUBLIC set_secret/get_secret/reprotect API, so the // protected writes/reads run the real (default 64 MiB) Argon2 — kept to // a small number of derivations per test. - /// TS-PW-001/002/003: the full enrol → change → remove lifecycle, each + /// The full enrol → change → remove lifecycle, each /// step verified through the strict read. fn run_pw_lifecycle(b: &Backend) { let w = wid(10); @@ -955,7 +972,7 @@ mod tests { b.store.get_secret(&w, "seed", Some(&pw2)).unwrap_err(), SecretStoreError::ExpectedProtectedButUnsealed ), - "[{}] after remove, a password read fails closed until DET updates its DB", + "[{}] after remove, a password read fails closed until the consumer updates its DB", b.name ); } @@ -970,7 +987,7 @@ mod tests { run_pw_lifecycle(&os_backend()); } - /// TS-PW-005: losing the object password bricks the object — no recovery + /// Losing the object password bricks the object — no recovery /// path exists, every read fails closed. fn run_pw_no_recovery(b: &Backend) { let w = wid(11); @@ -1000,7 +1017,7 @@ mod tests { run_pw_no_recovery(&os_backend()); } - /// TS-ARM-003: `set`/`get` are additive `..,None` wrappers — `set` + /// `set`/`get` are additive `..,None` wrappers — `set` /// writes a scheme-0 envelope, `get` reads it byte-exact, and a /// password-supplied read of that unprotected object fails closed. fn run_set_get_wrappers(b: &Backend) { @@ -1030,7 +1047,7 @@ mod tests { run_set_get_wrappers(&os_backend()); } - /// TS-T1-005: the Os arm has no passphrase concept; the Tier-1 blank + /// The Os arm has no passphrase concept; the Tier-1 blank /// guard never fires and the round-trip is byte-exact. #[test] fn os_arm_roundtrip_no_blank_guard() { @@ -1047,7 +1064,7 @@ mod tests { assert!(b.store.get(&w, "seed").unwrap().is_none()); } - /// TS-PW-004 ★ [File]: a crash (disk-write failure) between the unwrap + /// [File]: a crash (disk-write failure) between the unwrap /// and the overwrite-commit leaves the OLD protected value intact and /// readable — no half-rotated / unprotected state. #[cfg(unix)] From b5ede7df8dffe24d7de77d4e13d5d715457c741b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:32:11 +0200 Subject: [PATCH 09/21] =?UTF-8?q?test(platform-wallet-storage):=20QA=20fix?= =?UTF-8?q?es=20=E2=80=94=20Os=20read=20bound,=20Os=20crash=20test,=20test?= =?UTF-8?q?=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidated QA-wave fix batch, part 2 (defense-in-depth + test coverage). - Os-arm read bound: `get_raw` rejects a backend blob larger than `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD` BEFORE it reaches the envelope parse/derive path (`SecretTooLarge`), mirroring the File arm whose stored bytes are already capped by `put_bytes`. The Os backend has no such ceiling; a legitimate envelope never approaches the cap. - Os crash test: a backend failure during the rewrite's write (after the read succeeds) leaves the OLD value intact — no half-rotation. Uses the upstream mock's one-shot error injection, with reprotect's read/write split so the failure lands on the write. Test gaps closed: - `SecretString::new` source-wipe: verifies the `String::zeroize` primitive `new` applies + faithful content copy (the freed-source scan would be use-after-free and the crate forbids `unsafe`). - scheme-1 accepts a plaintext at EXACTLY `MAX_PLAINTEXT_LEN` (the accept boundary) and round-trips; enveloped bytes still fit the backend cap. - value-rollback non-defence pinned: an older valid envelope still decrypts cleanly under the current password (so nobody mistakes the strict read for rollback protection). - the default-on export test references the re-exported `MAX_PLAINTEXT_LEN` and `MIN_PASSPHRASE_LEN`. - a `SecretStore::set`-path variant of the no-plaintext-in-vault test. Skipped (optional, with rationale): extracting the AAD length-prefix idiom into a shared helper — it spans `format.rs` (outside this feature's churn) and the envelope module for a 2-line idiom; the coupling outweighs the win. 147 secrets unit + secrets integration tests green; clippy --lib --tests clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../src/secrets/envelope.rs | 36 ++++++++++ .../src/secrets/file/mod.rs | 24 +++++++ .../src/secrets/secret.rs | 17 +++++ .../src/secrets/store.rs | 68 ++++++++++++++++++- .../tests/secrets_default_on_compiles.rs | 5 +- 5 files changed, 147 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs index efb271e0a2..725d76eccf 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -625,6 +625,42 @@ mod tests { assert!(enveloped.len() <= MAX_SECRET_LEN); } + /// Scheme-1 accepts a plaintext of EXACTLY `MAX_PLAINTEXT_LEN` (the + /// accept boundary), round-trips it, and the enveloped bytes still fit + /// the backend's `MAX_SECRET_LEN` cap. + #[test] + fn scheme1_accepts_plaintext_at_exact_cap() { + let p = pw("pw"); + let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap(); + assert!( + blob.len() <= MAX_SECRET_LEN, + "enveloped bytes exceed backend cap" + ); + let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), &pt[..]); + } + + /// Value rollback is intentionally NOT defended: an older valid scheme-1 + /// envelope still decrypts cleanly under the current password. Pinned so + /// a future reader does not mistake the strict read for rollback + /// protection (anti-rollback would need a monotonic anchor in the + /// consumer's integrity-protected metadata). + #[test] + fn value_rollback_is_not_defended() { + let p = pw("pw"); + let old_blob = wrap_with_params(&wid(1), "seed", Some(&p), b"OLD-VALUE", floor()).unwrap(); + // A newer value is written under the same identity + password … + let _new_blob = wrap_with_params(&wid(1), "seed", Some(&p), b"NEW-VALUE", floor()).unwrap(); + // … yet "restoring" the OLD envelope still decrypts cleanly. + let restored = unwrap(&wid(1), "seed", Some(&p), old_blob.expose_secret()).unwrap(); + assert_eq!( + restored.expose_secret(), + b"OLD-VALUE", + "older envelope still decrypts: value rollback is a known, undefended residual" + ); + } + /// magic/version discrimination: a magic-less blob is a legacy raw /// value — returned on a `None` read (with a one-time warning), refused /// fail-closed on `Some(pw)` so the strict rule holds. A magic-present diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index 1d2471e780..8131f2643b 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -1475,6 +1475,30 @@ mod tests { ); } + /// The no-plaintext-at-rest guarantee also holds through the public + /// `SecretStore::set` path (which writes an unprotected envelope sealed + /// under the vault key), not just the raw SPI entry path. + #[test] + fn no_plaintext_in_vault_file_via_secret_store_set() { + use crate::secrets::SecretStore; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let store = SecretStore::file(&path, SecretString::new("pw-correct")).unwrap(); + store + .set( + &wid(1), + "seed", + &SecretBytes::from_slice(b"PLAINTEXTNEEDLE"), + ) + .unwrap(); + let raw = fs::read(&path).unwrap(); + assert!( + raw.windows(b"PLAINTEXTNEEDLE".len()) + .all(|w| w != b"PLAINTEXTNEEDLE"), + "plaintext leaked into vault file via SecretStore::set" + ); + } + /// A blank passphrase is rejected at `open` → /// `BlankPassphrase`; no vault file (or lock sidecar) is created. #[test] diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs index 9363c57584..2fa8213a74 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs @@ -369,6 +369,23 @@ mod tests { assert_eq!(s.trimmed().expose_secret(), "abandon ability"); } + /// `SecretString::new` zeroizes its `String` source before that source + /// drops. The freed source buffer cannot be scanned after `new` returns + /// without use-after-free, and this crate forbids `unsafe`, so this + /// verifies the exact primitive `new` applies to its moved-in source — + /// `String::zeroize` empties the buffer in place — plus that `new` + /// faithfully copies the content into the wrapper. + #[test] + fn secret_string_new_zeroizes_string_source() { + // The primitive `new` calls on its owned source `String`. + let mut source = String::from("super secret seed material"); + source.zeroize(); + assert!(source.is_empty(), "String::zeroize must empty the source"); + // And construction copies the content into the (zeroizing) wrapper. + let s = SecretString::new(String::from("super secret seed material")); + assert_eq!(s.expose_secret(), "super secret seed material"); + } + #[test] fn secret_string_ct_eq_is_value_based() { // Equality goes through `ConstantTimeEq` only. diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index b545f2f26a..059979aacb 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -16,7 +16,7 @@ use super::envelope; use super::error::{OsKeyringErrorKind, SecretStoreError}; use super::secret::{SecretBytes, SecretString}; use super::validate::WalletId; -use super::{default_credential_store, EncryptedFileStore, SERVICE_PREFIX}; +use super::{default_credential_store, EncryptedFileStore, MAX_SECRET_LEN, SERVICE_PREFIX}; /// A passphrase-or-OS-keyring backed store for wallet secret material. /// @@ -165,7 +165,23 @@ impl SecretStore { Self::Os(store) => { let entry = build_os(store, service, label)?; match entry.get_secret() { - Ok(v) => Ok(Some(SecretBytes::new(v))), + Ok(v) => { + // Defense-in-depth: reject an oversized backend blob + // before it reaches the envelope parse/derive path. + // The File arm's stored bytes are already capped at + // MAX_SECRET_LEN by `put_bytes`; the Os backend has no + // such ceiling, so cap here. A legitimate envelope + // never exceeds MAX_SECRET_LEN; the overhead is + // headroom. + let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD; + if v.len() > cap { + return Err(SecretStoreError::SecretTooLarge { + found: v.len(), + max: cap, + }); + } + Ok(Some(SecretBytes::new(v))) + } Err(KeyringError::NoEntry) => Ok(None), Err(e) => Err(map_spi(e)), } @@ -1104,4 +1120,52 @@ mod tests { SecretStoreError::WrongPassword )); } + + /// [Os]: a backend failure during the rewrite's write (after the read + /// succeeds) leaves the OLD value intact — no half-rotation. The mock's + /// one-shot error injection fails the next write, simulating a crash + /// mid-rewrite. `reprotect` is read-then-`set_secret`, split here so the + /// error lands on the write. + #[test] + fn os_rewrite_mid_write_failure_leaves_old_intact() { + let mock = mock::Store::new().unwrap(); + let store = SecretStore::Os(mock.clone()); + let w = wid(15); + let old = SecretString::new("old-pw"); + let new = SecretString::new("new-pw"); + store + .set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old)) + .unwrap(); + + // Read succeeds (the rewrite's first step) … + let secret = store.get_secret(&w, "seed", Some(&old)).unwrap().unwrap(); + // … then inject a one-shot backend error so the write fails. + let service = format!("{SERVICE_PREFIX}{}", w.to_hex()); + let entry = mock.build(&service, "seed", None).unwrap(); + let cred: &mock::Cred = entry.as_any().downcast_ref().unwrap(); + cred.set_error(KeyringError::PlatformFailure(Box::new( + std::io::Error::other("simulated backend write failure"), + ))); + let err = store + .set_secret(&w, "seed", &secret, Some(&new)) + .unwrap_err(); + assert!( + matches!(err, SecretStoreError::OsKeyring { .. }), + "got {err:?}" + ); + + // The OLD value is still readable; nothing rotated to `new`. + assert_eq!( + store + .get_secret(&w, "seed", Some(&old)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL" + ); + assert!(matches!( + store.get_secret(&w, "seed", Some(&new)).unwrap_err(), + SecretStoreError::WrongPassword + )); + } } diff --git a/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs b/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs index 23cc10d582..1f45e2ceae 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs @@ -9,7 +9,7 @@ use platform_wallet_storage::secrets::{ default_credential_store, EncryptedFileStore, SecretBytes, SecretStoreError, SecretString, - WalletId, SERVICE_PREFIX, + WalletId, MAX_PLAINTEXT_LEN, MIN_PASSPHRASE_LEN, SERVICE_PREFIX, }; #[test] @@ -23,6 +23,9 @@ fn default_build_exposes_secrets_surface() { } let _ = _accepts_path as fn(_, _) -> _; let _ = SERVICE_PREFIX.len(); + // The Tier-2 public consts are re-exported on the default build. + let _ = MAX_PLAINTEXT_LEN; + let _ = MIN_PASSPHRASE_LEN; let _ = std::mem::size_of::(); let _ = std::mem::size_of::(); let _ = std::mem::size_of::(); From fb7953eabb34e24d13f0044e11fd8719ec4bb8ed Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:42:11 +0200 Subject: [PATCH 10/21] test(platform-wallet-storage): cover Os read-size guard; pin new() source-wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final QA close-out (two LOWs). - QA-010: the Os-arm read-size guard in `get_raw` had no test. Add `os_read_rejects_oversized_blob` — via the mock backend, place a raw blob one byte over `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD` into an Os slot and assert both `get_secret` and the legacy `get` return `SecretTooLarge { found, max }` with `max == MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD`. - QA-001: the source-wipe test is a proxy (it can't assert `new()` invokes `source.zeroize()` under `#![deny(unsafe_code)]`), so pin the call site with a do-not-remove comment at `secret.rs` `source.zeroize();` and reword the test doc to state what it actually checks (the `String::zeroize` primitive + faithful copy), dropping the "exact primitive new applies" overclaim. 148 secrets unit + integration tests green; clippy --lib --tests clean; fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7 --- .../src/secrets/secret.rs | 16 ++++++------- .../src/secrets/store.rs | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs index 2fa8213a74..8e4d6dc4d8 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs @@ -59,6 +59,8 @@ impl SecretString { let cap = source.len().max(DEFAULT_CAPACITY); let mut buf = String::with_capacity(cap); buf.push_str(&source); + // Do not remove: wipes the moved-in plaintext source before it drops + // (its freed buffer cannot be scanned in a test under deny(unsafe_code)). source.zeroize(); let lock = region::lock(buf.as_ptr(), buf.capacity()) .map_err(|e| { @@ -369,19 +371,17 @@ mod tests { assert_eq!(s.trimmed().expose_secret(), "abandon ability"); } - /// `SecretString::new` zeroizes its `String` source before that source - /// drops. The freed source buffer cannot be scanned after `new` returns - /// without use-after-free, and this crate forbids `unsafe`, so this - /// verifies the exact primitive `new` applies to its moved-in source — - /// `String::zeroize` empties the buffer in place — plus that `new` - /// faithfully copies the content into the wrapper. + /// Two sound checks (a direct freed-buffer scan would be use-after-free, + /// and this crate forbids `unsafe`): (1) `String::zeroize` empties a + /// buffer — the primitive `new` relies on; (2) `new` copies the content + /// into the wrapper faithfully. That `new` actually calls + /// `source.zeroize()` on its moved-in source is pinned by the + /// do-not-remove comment at that call site, not asserted here. #[test] fn secret_string_new_zeroizes_string_source() { - // The primitive `new` calls on its owned source `String`. let mut source = String::from("super secret seed material"); source.zeroize(); assert!(source.is_empty(), "String::zeroize must empty the source"); - // And construction copies the content into the (zeroizing) wrapper. let s = SecretString::new(String::from("super secret seed material")); assert_eq!(s.expose_secret(), "super secret seed material"); } diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 059979aacb..0ff9e393a9 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -1168,4 +1168,28 @@ mod tests { SecretStoreError::WrongPassword )); } + + /// [Os]: the read-size guard rejects an oversized backend blob (a + /// malicious keychain returning more than a legitimate envelope ever + /// could) BEFORE it reaches the envelope parse/derive path. The bound is + /// `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD`; both the `get_secret` and + /// legacy `get` read paths enforce it. + #[test] + fn os_read_rejects_oversized_blob() { + let b = os_backend(); + let w = wid(16); + let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD; + // Attacker writes a blob one byte over the cap straight to the slot. + b.place_raw(&w, "seed", &vec![0u8; cap + 1]); + let err = b.store.get_secret(&w, "seed", None).unwrap_err(); + assert!( + matches!(err, SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap), + "get_secret got {err:?}" + ); + // The legacy `get` path is bounded too. + assert!(matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap + )); + } } From 3e2fb63111f393fac08c82aef84c9f955fcc203f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:40:25 +0000 Subject: [PATCH 11/21] docs(rs-platform-wallet-storage): clarify why SecretString::new zeroizes the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 2-line `// Do not remove:` comment above `source.zeroize()` misled a reviewer on PR #3953 into thinking production code somehow allows `unsafe` in tests. It does not — `rs-platform-wallet-storage` declares `#![deny(unsafe_code)]` at the crate root in `src/lib.rs:28` and `src/secrets/secret.rs` has no `#[allow(unsafe_code)]` override. Rewrite the comment to spell out the WHY in 4 lines: a direct freed- buffer scan would need `unsafe` (forbidden here) and would be use- after-free anyway, so the `secret_string_new_zeroizes_string_source` test pins the `String::zeroize` primitive and this call site rather than reading freed memory. The named test makes the comment legible without re-reading the test body. Functional behavior is unchanged (comment-only edit; `cargo test -p platform-wallet-storage --lib secret::tests` count and outcomes are identical to baseline). --- packages/rs-platform-wallet-storage/src/secrets/secret.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs index 8e4d6dc4d8..87faf066ef 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs @@ -59,8 +59,10 @@ impl SecretString { let cap = source.len().max(DEFAULT_CAPACITY); let mut buf = String::with_capacity(cap); buf.push_str(&source); - // Do not remove: wipes the moved-in plaintext source before it drops - // (its freed buffer cannot be scanned in a test under deny(unsafe_code)). + // Do not remove: wipes the moved-in plaintext source before it drops. + // A direct freed-buffer scan would require `unsafe`, which this crate + // forbids; the test `secret_string_new_zeroizes_string_source` instead + // pins the `String::zeroize` primitive and this call site. source.zeroize(); let lock = region::lock(buf.as_ptr(), buf.capacity()) .map_err(|e| { From 121e8cd8bd387337f7821e1dc3c966e19bc99e26 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:40:26 +0000 Subject: [PATCH 12/21] fix(rs-platform-wallet-storage)!: reject blank object password on protected unwrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetric guard on the Tier-2 read path. Lands the BLOCKING fix flagged by the Codex / @thepastaclaw review on PR #3953. Wrap rejected `Some(blank)` at enrol (`BlankPassphrase`), but `unwrap_scheme1` accepted `Some(blank)` and ran the full Argon2id + XChaCha20-Poly1305 path — an asymmetry the wrap-side comment documents as the crate-wide invariant but which the read side did not actually enforce. Under this PR's threat model the backend bytes are attacker-writable: a backend-write attacker who plants a fresh scheme-1 envelope sealed under the blank password with the correct `(wallet_id, label)` AAD will produce a blob whose AEAD tag VERIFIES when any caller forwards `Some(SecretString::empty())` to `unwrap`. The caller then receives attacker-controlled plaintext instead of the typed `BlankPassphrase` failure the wrap path advertises. Fix mirrors wrap's guard inside `unwrap_scheme1`, sited AFTER the `MIN_SCHEME1_BODY` length check (the "blob is sealed-but-broken" signal is about bytes, not the password) and BEFORE `decode_kdf` / `enforce_bounds` / `derive_key` so no KDF or AEAD work is done on a blank object password. The guard fires regardless of whether the envelope was attacker-forged or accidentally constructed — the exploit path is implicit, what the contract pins is "blank `Some(...)` never reaches the crypto". Regression test `unwrap_scheme1_rejects_some_blank_password` builds a well-formed scheme-1 envelope under a NON-blank password (so the body is valid), then unwraps with `Some(SecretString::empty())` and pins the result to `BlankPassphrase` — explicitly not `WrongPassword`, not `Decrypt`, not plaintext. Sits next to the existing `blank_object_password_rejected_at_enrol` to keep the two halves of the invariant adjacent. 15 envelope tests pass (was 14); `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean; `cargo fmt --check` clean. --- .../src/secrets/envelope.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs index 725d76eccf..181e8b39ec 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -264,6 +264,13 @@ fn unwrap_scheme1( // payload — corrupt, not a strip. return Err(SecretStoreError::Corruption); } + // Mirror wrap's invariant: a blank object password is rejected on read + // as well as enrol, so a backend-write attacker who plants a scheme-1 + // envelope sealed under the blank password cannot inject plaintext into + // a caller that accidentally forwards Some(empty). + if password.is_blank() { + return Err(SecretStoreError::BlankPassphrase); + } let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); // Gate the inflated/unknown header BEFORE any derivation/alloc. kdf.enforce_bounds()?; @@ -596,6 +603,24 @@ mod tests { } } + /// Symmetric guard on the read side: a `Some(blank)` password reaching + /// `unwrap_scheme1` is refused with `BlankPassphrase` BEFORE any KDF or + /// AEAD work — never `WrongPassword`, never `Decrypt`, never plaintext. + /// Pins the contract that closes the asymmetry where a backend-write + /// attacker could plant a scheme-1 envelope sealed under the blank + /// password and have a caller that accidentally forwards + /// `Some(SecretString::empty())` accept attacker-controlled plaintext. + #[test] + fn unwrap_scheme1_rejects_some_blank_password() { + // Well-formed scheme-1 envelope sealed under a NON-blank password. + let blob = wrap_p(&wid(1), "seed", Some(&pw("good")), b"seed", floor()); + let err = unwrap(&wid(1), "seed", Some(&SecretString::empty()), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank object password must be refused before KDF/AEAD, got {err:?}" + ); + } + /// The plaintext is capped at `MAX_PLAINTEXT_LEN` (`MAX_SECRET_LEN − /// MAX_ENVELOPE_OVERHEAD`), uniform across schemes, so plaintext + /// overhead always fits the backend's own `MAX_SECRET_LEN` cap. Accept From abe77810db211f4fc22f7c9f0b73eeae9fa6d06c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:55:12 +0000 Subject: [PATCH 13/21] feat(rs-platform-wallet-storage)!: surface absence in SecretStore::delete and reprotect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mutators in `SecretStore` were swallowing "absent" silently — neither the caller's invariant check nor the on-disk reality could detect a mismatch. The reviewer (PR #3953, review thread #6, comment id 3480589666) asked for absence to be surfaced. Both arms now do so: - `SecretStore::delete: Result<(), _>` → `Result`. `Ok(true)` on removal, `Ok(false)` on absent. The File arm just forwards the bool from the inner `delete_bytes` (which already returned `Result`); the OS arm maps `Ok(())` → `Ok(true)` and `Err(KeyringError::NoEntry)` → `Ok(false)`. Callers that don't care still write `.delete(...)?;` — the bool drops; race-detecting callers can `match delete()?`. - `SecretStore::reprotect` absent branch: returns `Err(NoEntry)` instead of `Ok(())`. A silent no-op when the target is absent means the caller's trusted protection-status record says "protected" while the backend has nothing — a downgrade-detection blind spot. `reprotect` is operational; absence is a signal, not a no-op. Upstream `keyring-core 1.0.0` already documents `delete_credential` as returning `Err(NoEntry)` for absent credentials (lib.rs:340-358); the file backend's inner `delete_bytes` already returns `Result` (file/mod.rs:397-413). This change stops swallowing both at the `SecretStore` seam. Error API: a top-level `SecretStoreError::NoEntry` is added (backend- agnostic — file arm can hit absence too, so the previous `OsKeyringErrorKind::NoEntry` was the wrong scope). `map_spi` projects `KeyringError::NoEntry` to the new top-level variant; the SPI back-projection maps it to `KeyringError::NoEntry` — round-trippable. The now-redundant `OsKeyringErrorKind::NoEntry` is removed. Tests: `delete_is_idempotent` → `delete_returns_false_on_absent_true_on_present` asserts `Ok(false)` on absent, `Ok(true)` on present, `Ok(false)` on the second delete. New `reprotect_absent_returns_no_entry` asserts the new `NoEntry` error on an empty-store reprotect. No callers outside the storage crate. clippy `--all-targets -D warnings` clean; `cargo fmt --check` clean; `cargo check -p platform-wallet -p platform-wallet-ffi` clean; 149 `secrets` unit tests green. Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01BcZFae6ABdoMEvCBQZnaJF --- .../src/secrets/error.rs | 15 ++++- .../src/secrets/store.rs | 63 ++++++++++++------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs index 6c69e9e8b9..dbab2e3665 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/error.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs @@ -100,6 +100,17 @@ pub enum SecretStoreError { #[error("invalid label")] InvalidLabel, + /// No credential exists under `(service, label)` on either arm. Returned + /// by mutators that need an entry to operate on (e.g. [`reprotect`]) so + /// absence is a signal, not a silent no-op — caller's protection-status + /// record disagreeing with the backend must not be swallowed. Surfaced + /// by the file arm when `delete_bytes` reports `Ok(false)` and by the + /// OS arm when [`keyring_core::Error::NoEntry`] bubbles out. + /// + /// [`reprotect`]: crate::secrets::SecretStore::reprotect + #[error("no entry under (service, label)")] + NoEntry, + /// A pre-existing vault file had permissions looser than `0600`. /// Refuse rather than tighten-and-trust. #[error("vault file has insecure permissions")] @@ -237,8 +248,6 @@ impl From for IoError { /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OsKeyringErrorKind { - /// `keyring_core::Error::NoEntry`. - NoEntry, /// `keyring_core::Error::NoStorageAccess` (store locked / inaccessible). NoStorageAccess, /// `keyring_core::Error::NoDefaultStore` (no reachable backend). @@ -254,7 +263,6 @@ pub enum OsKeyringErrorKind { impl std::fmt::Display for OsKeyringErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { - Self::NoEntry => "no entry", Self::NoStorageAccess => "storage inaccessible", Self::NoDefaultStore => "no default store", Self::BadStoreFormat => "bad store format", @@ -334,6 +342,7 @@ impl From for KeyringError { E::InvalidLabel => { KeyringError::Invalid("user".to_string(), "label allowlist violation".to_string()) } + E::NoEntry => KeyringError::NoEntry, E::Io(io) => KeyringError::PlatformFailure(Box::new(io.source)), } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 0ff9e393a9..1c8a1af48d 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -235,11 +235,14 @@ impl SecretStore { /// - **change:** `current = Some(old)`, `new = Some(pw_new)`; /// - **remove:** `current = Some(old)`, `new = None`. /// - /// An absent object is a no-op (`Ok(())`). The rewrite is the same-slot - /// overwrite of [`set_secret`], so a crash between the read and the - /// commit leaves the prior value intact and readable under `current`. - /// After a successful call the consumer MUST update its own trusted - /// protection-status record (the protection expectation lives there). + /// An absent object returns [`Err(NoEntry)`][SecretStoreError::NoEntry] — + /// `reprotect` is operational; absence means the caller's protection-status + /// record disagrees with the backend, which is a signal not to be silently + /// dropped. The rewrite is the same-slot overwrite of [`set_secret`], so a + /// crash between the read and the commit leaves the prior value intact + /// and readable under `current`. After a successful call the consumer MUST + /// update its own trusted protection-status record (the protection + /// expectation lives there). /// /// **No recovery:** changing or removing requires the `current` /// password; if it is lost the object cannot be re-protected or read, @@ -256,23 +259,25 @@ impl SecretStore { new: Option<&SecretString>, ) -> Result<(), SecretStoreError> { let Some(secret) = self.get_secret(service, label, current)? else { - return Ok(()); + return Err(SecretStoreError::NoEntry); }; self.set_secret(service, label, &secret, new) } - /// Delete the secret stored under `(service, label)`. Absent entries - /// are a no-op (`Ok(())`), so deletion is idempotent. - pub fn delete(&self, service: &WalletId, label: &str) -> Result<(), SecretStoreError> { + /// Delete the secret stored under `(service, label)`. + /// + /// Returns `Ok(true)` if a credential was removed, `Ok(false)` if no + /// credential existed under `(service, label)`. Idempotent for callers + /// that don't care — `.delete(...)?;` still discards the bool; + /// race-detecting callers can `match delete()?`. + pub fn delete(&self, service: &WalletId, label: &str) -> Result { match self { - Self::File(s) => { - s.delete_bytes(service, label)?; - Ok(()) - } + Self::File(s) => s.delete_bytes(service, label), Self::Os(store) => { let entry = build_os(store, service, label)?; match entry.delete_credential() { - Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Ok(()) => Ok(true), + Err(KeyringError::NoEntry) => Ok(false), Err(e) => Err(map_spi(e)), } } @@ -320,9 +325,7 @@ impl std::fmt::Debug for SecretStore { /// The [`File`](SecretStore::File) arm never reaches this projection. fn map_spi(e: KeyringError) -> SecretStoreError { match e { - KeyringError::NoEntry => SecretStoreError::OsKeyring { - kind: OsKeyringErrorKind::NoEntry, - }, + KeyringError::NoEntry => SecretStoreError::NoEntry, KeyringError::NoStorageAccess(_) => SecretStoreError::OsKeyring { kind: OsKeyringErrorKind::NoStorageAccess, }, @@ -387,17 +390,31 @@ mod tests { } #[test] - fn delete_is_idempotent() { + fn delete_returns_false_on_absent_true_on_present() { let dir = tempfile::tempdir().unwrap(); let s = file_store(dir.path()); - // Absent → Ok, no error. - s.delete(&wid(1), "seed").unwrap(); + // Absent → Ok(false), no error. + assert!(!s.delete(&wid(1), "seed").unwrap()); s.set(&wid(1), "seed", &SecretBytes::from_slice(b"x")) .unwrap(); - s.delete(&wid(1), "seed").unwrap(); + // Present → Ok(true). + assert!(s.delete(&wid(1), "seed").unwrap()); assert!(s.get(&wid(1), "seed").unwrap().is_none()); - // Second delete on the now-absent entry is still Ok. - s.delete(&wid(1), "seed").unwrap(); + // Second delete on the now-absent entry is Ok(false). + assert!(!s.delete(&wid(1), "seed").unwrap()); + } + + #[test] + fn reprotect_absent_returns_no_entry() { + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + let err = s + .reprotect(&wid(1), "seed", None, Some(&SecretString::new("pw"))) + .unwrap_err(); + assert!( + matches!(err, SecretStoreError::NoEntry), + "expected NoEntry on absent reprotect, got {err:?}" + ); } #[test] From 7099d6adf6698a2b2321b4d2faaf1385048ea9bf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:18:08 +0000 Subject: [PATCH 14/21] refactor(rs-platform-wallet-storage)!: bootstrap secrets/wire/ bincode scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up the new `secrets/wire/` module that owns every byte crossing the AEAD seam under the Tier-2 envelope and the three vault AAD contexts. T-1 is type-only — the encoder/decoder land in the follow-up commits. - `wire/config.rs`: WIRE_CONFIG (`standard().with_big_endian().with_no_limit()`, matching `rs-platform-serialization`), ENVELOPE_VERSION = 1u32, and three pair-wise-disjoint domain tags (`PWSEV-TIER2-AAD-v2`, `PWSV-ENTRY-AAD-v2`, `PWSV-VERIFY-AAD-v2`). - `wire/kdf.rs`: KdfParamsEncoded — the bincode-derived wire image of KdfParams, with infallible From and a TryFrom that runs enforce_bounds before yielding the in-memory type. - `wire/aad.rs`: Tier2Aad<'a>, EntryAad<'a>, VerifyAad — Encode-only (producer-side) structs with explicit `scheme_discriminant` so the AAD shape is stable under a future Payload re-ordering. - `wire/envelope.rs`: Envelope + Payload (Unprotected / Password) type defs; the wrap/unwrap functions land in T-2/T-3. - Cargo.toml: `dep:bincode` added to the `secrets` feature so the module compiles under `--no-default-features --features secrets`. - secrets/mod.rs: new `mod wire;` (pub(crate) only). Pins TC-014, TC-015, TC-016 (Tier2Aad encode-side field binding), TC-025, TC-026, TC-027 (pair-wise AAD prefix-disjointness), TC-037 (EntryAad binds format_version + wallet_id + label with length-prefix sanity), TC-038 (VerifyAad binds salt + KDF, deterministic), and (via the deny lint at wire module root) TC-039. Refs design-brief.md §2.1, §2.2 (locked module layout + type defs); dev-plan.md T-1. --- .../rs-platform-wallet-storage/Cargo.toml | 4 + .../src/secrets/mod.rs | 1 + .../src/secrets/wire/aad.rs | 250 ++++++++++++++++++ .../src/secrets/wire/config.rs | 38 +++ .../src/secrets/wire/envelope.rs | 42 +++ .../src/secrets/wire/kdf.rs | 56 ++++ .../src/secrets/wire/mod.rs | 40 +++ 7 files changed, 431 insertions(+) create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/config.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index d8c9c8ad7c..3678307816 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -199,6 +199,10 @@ secrets = [ # 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 diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index 4161e42300..a113e978cf 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -29,6 +29,7 @@ mod keyring; mod secret; mod store; mod validate; +mod wire; pub use envelope::MAX_PLAINTEXT_LEN; pub use error::{IoError, OsKeyringErrorKind, SecretStoreError}; diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs new file mode 100644 index 0000000000..e3b9dc90a7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs @@ -0,0 +1,250 @@ +//! Bincode-encoded AAD structs for the three contexts that authenticate +//! ciphertexts under `secrets/`: Tier-2 scheme-1 envelopes, vault entry +//! bodies, and the vault passphrase-verify token. +//! +//! Each struct is `Encode`-only — AAD is producer-side; the decoder +//! re-builds it from the surrounding context and bincode-encodes again +//! against [`WIRE_CONFIG`]. Pair-wise byte disjointness is guaranteed by +//! the three domain constants declared in [`super::config`]; the bincode +//! varint length prefix in front of `domain` is itself distinct across +//! the three (each tag has a different byte length), so prefix +//! containment is structurally impossible. + +use crate::secrets::file::crypto::SALT_LEN; +use crate::secrets::wire::kdf::KdfParamsEncoded; + +/// AAD bound into every scheme-1 (password-protected) Tier-2 envelope. +/// Binds object identity (`wallet_id` + `label`) + header +/// (`envelope_version`, `scheme_discriminant`, `kdf`, `salt`) so any +/// in-place edit of those fields fails the AEAD tag. +/// +/// `scheme_discriminant` is explicit (not inferred from a Rust enum +/// variant tag) so the AAD shape is stable under a future `Payload` +/// re-ordering. +#[derive(bincode::Encode)] +pub(crate) struct Tier2Aad<'a> { + /// Domain tag — `TIER2_DOMAIN_V2`. Length-prefixed by bincode so a + /// future swap can never collide with the entry / verify AADs. + pub domain: &'static [u8], + /// Envelope wire version (`ENVELOPE_VERSION`). + pub envelope_version: u32, + /// `0 = Unprotected`, `1 = Password`. Authenticates the scheme byte + /// independently of the enum's bincode-derived tag. + pub scheme_discriminant: u8, + /// The exact bytes encoded into the envelope's `Payload::Password` + /// body — AAD == body, so a wire-edited KDF header fails the tag. + pub kdf: KdfParamsEncoded, + /// Per-wrap CSPRNG salt. + pub salt: [u8; SALT_LEN], + /// 32-byte wallet correlation id (public, not secret). + pub wallet_id: [u8; 32], + /// Caller-allowlisted slot label. + pub label: &'a str, +} + +/// AAD bound into every vault entry's AEAD seal. Replaces the +/// hand-rolled `format::aad()` byte concatenation; binds slot identity +/// (`wallet_id` + `label`) at a stable `format_version`. A relocated +/// or version-rolled-back blob fails the tag. +#[derive(bincode::Encode)] +pub(crate) struct EntryAad<'a> { + /// Domain tag — `ENTRY_DOMAIN_V2`. + pub domain: &'static [u8], + /// Vault `FORMAT_VERSION` (the compiled-in dispatch version, + /// never the parsed JSON version). + pub format_version: u32, + /// 32-byte wallet correlation id. + pub wallet_id: [u8; 32], + /// Caller-allowlisted slot label. + pub label: &'a str, +} + +/// AAD bound into the vault passphrase-verify token's AEAD seal. +/// Binds salt + KDF header so a flipped salt or KDF-param shift fails +/// the token tag (surfaces as `WrongPassphrase` — a tampered header +/// also yields a different derived key). +#[derive(bincode::Encode)] +pub(crate) struct VerifyAad { + /// Domain tag — `VERIFY_DOMAIN_V2`. + pub domain: &'static [u8], + /// Vault `FORMAT_VERSION`. + pub format_version: u32, + /// Vault-wide CSPRNG salt. + pub salt: [u8; SALT_LEN], + /// Vault-wide KDF parameters (the same wire image used by every + /// scheme-1 Tier-2 envelope). + pub kdf: KdfParamsEncoded, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secrets::file::crypto::KdfParams; + use crate::secrets::wire::config::{ + ENTRY_DOMAIN_V2, TIER2_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG, + }; + + fn floor_kdf() -> KdfParamsEncoded { + KdfParamsEncoded::from(KdfParams::default_target()) + } + + fn tier2_with_domain(domain: &'static [u8]) -> Vec { + let aad = Tier2Aad { + domain, + envelope_version: 1, + scheme_discriminant: 1, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn tier2_with_version(envelope_version: u32) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version, + scheme_discriminant: 1, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn tier2_with_scheme(scheme_discriminant: u8) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version: 1, + scheme_discriminant, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn entry(format_version: u32, wallet_id: [u8; 32], label: &str) -> Vec { + let aad = EntryAad { + domain: ENTRY_DOMAIN_V2, + format_version, + wallet_id, + label, + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn verify(salt: [u8; SALT_LEN], kdf: KdfParamsEncoded) -> Vec { + let aad = VerifyAad { + domain: VERIFY_DOMAIN_V2, + format_version: 1, + salt, + kdf, + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + /// Two byte strings where neither is a prefix of the other. + fn assert_prefix_disjoint(a: &[u8], b: &[u8]) { + assert!( + !a.starts_with(b) && !b.starts_with(a), + "prefix containment: a.len={} b.len={}", + a.len(), + b.len() + ); + } + + /// TC-014 — Tier2Aad.domain is bincode-encoded. + #[test] + fn tier2_aad_domain_field_binds_bytes() { + let a = tier2_with_domain(TIER2_DOMAIN_V2); + let b = tier2_with_domain(b"PWSEV-TIER2-AAD-vX"); + assert_ne!(a, b); + assert_prefix_disjoint(&a, &b); + } + + /// TC-015 — Tier2Aad.envelope_version is bincode-encoded. + #[test] + fn tier2_aad_envelope_version_field_binds_bytes() { + assert_ne!(tier2_with_version(1), tier2_with_version(2)); + } + + /// TC-016 — Tier2Aad.scheme_discriminant is bincode-encoded and + /// explicit (not inferred from a Rust enum tag). + #[test] + fn tier2_aad_scheme_discriminant_field_binds_bytes() { + assert_ne!(tier2_with_scheme(0), tier2_with_scheme(1)); + } + + /// TC-025 — Tier2Aad and EntryAad are byte-disjoint at the prefix. + #[test] + fn tier2_and_entry_aad_byte_disjoint() { + let t = tier2_with_domain(TIER2_DOMAIN_V2); + let e = entry(1, [0x11u8; 32], "seed"); + assert_prefix_disjoint(&t, &e); + } + + /// TC-026 — Tier2Aad and VerifyAad are byte-disjoint at the prefix. + #[test] + fn tier2_and_verify_aad_byte_disjoint() { + let t = tier2_with_domain(TIER2_DOMAIN_V2); + let v = verify([0x77u8; SALT_LEN], floor_kdf()); + assert_prefix_disjoint(&t, &v); + } + + /// TC-027 — EntryAad and VerifyAad are byte-disjoint at the prefix. + /// Now backed by an explicit domain constant on top of the existing + /// VERIFY_LABEL leading-NUL trick at the `format.rs` call site. + #[test] + fn entry_and_verify_aad_byte_disjoint() { + let e = entry(1, [0u8; 32], "\0verify"); + let v = verify([0x77u8; SALT_LEN], floor_kdf()); + assert_prefix_disjoint(&e, &v); + } + + /// TC-037 — EntryAad binds (format_version, wallet_id, label) and + /// the label encoding carries its length prefix (`"a"+"b"` vs + /// `"ab"` are distinct). + #[test] + fn entry_aad_binds_format_version_wallet_id_and_label() { + let base = entry(1, [1u8; 32], "a"); + assert_ne!(base, entry(2, [1u8; 32], "a")); + assert_ne!(base, entry(1, [2u8; 32], "a")); + assert_ne!(base, entry(1, [1u8; 32], "b")); + // Length-prefix sanity: "ab" must not equal the concatenation of + // the encoding of "a" with the literal byte `b`. + let ab = entry(1, [1u8; 32], "ab"); + let mut a_plus_b = base.clone(); + a_plus_b.extend_from_slice(b"b"); + assert_ne!(ab, a_plus_b); + } + + /// TC-038 — VerifyAad binds salt + KDF; identical inputs produce + /// identical bytes (determinism). + #[test] + fn verify_aad_binds_salt_and_kdf_params() { + let salt = [7u8; SALT_LEN]; + let kdf = floor_kdf(); + let base = verify(salt, kdf); + let mut salt2 = salt; + salt2[0] ^= 0x01; + assert_ne!(base, verify(salt2, kdf)); + + let kdf_mkib = KdfParamsEncoded { + m_kib: kdf.m_kib / 2, + ..kdf + }; + assert_ne!(base, verify(salt, kdf_mkib)); + let kdf_t = KdfParamsEncoded { + t: kdf.t - 1, + ..kdf + }; + assert_ne!(base, verify(salt, kdf_t)); + + // Determinism: identical inputs ⇒ identical bytes. + assert_eq!(base, verify(salt, kdf)); + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs new file mode 100644 index 0000000000..c0e67c21d6 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs @@ -0,0 +1,38 @@ +//! Single bincode configuration + domain / version constants every +//! encoder in `secrets/wire/` uses. +//! +//! `WIRE_CONFIG` matches the platform-wide +//! `bincode::config::standard().with_big_endian().with_no_limit()` +//! (`rs-platform-serialization`) — big-endian for human-readable hex +//! dumps, varint integer encoding, no decode limit. +//! +//! Changing this constant invalidates every stored Tier-2 blob; the +//! golden-vector tests in [`super::envelope::tests`] catch any drift. + +use bincode::config::{BigEndian, Configuration, NoLimit, Varint}; + +/// The one bincode config used to encode every wire byte under +/// `secrets/wire/` (envelope payload + the three AAD structs). +pub(crate) const WIRE_CONFIG: Configuration = + bincode::config::standard() + .with_big_endian() + .with_no_limit(); + +/// Tier-2 envelope wire version — bumped only on a breaking layout +/// change, independent of the vault `FORMAT_VERSION`. Bound into every +/// scheme-1 envelope's AAD so a forged version byte fails the tag. +pub(crate) const ENVELOPE_VERSION: u32 = 1; + +/// Domain-separation tag leading the Tier-2 scheme-1 AAD. `-v2` marks the +/// wire-format break from the pre-bincode hand-rolled `PWSEV-TIER2-AAD-v1`. +pub(crate) const TIER2_DOMAIN_V2: &[u8] = b"PWSEV-TIER2-AAD-v2"; + +/// Domain-separation tag leading every vault `EntryAad`. Pre-bincode +/// `aad()` had no domain tag; bound here for symmetry + cross-context +/// disjointness with [`TIER2_DOMAIN_V2`] and [`VERIFY_DOMAIN_V2`]. +pub(crate) const ENTRY_DOMAIN_V2: &[u8] = b"PWSV-ENTRY-AAD-v2"; + +/// Domain-separation tag leading every vault `VerifyAad`. Distinct +/// length from the other two so the bincode varint length prefix alone +/// makes encoded outputs prefix-disjoint at the first byte. +pub(crate) const VERIFY_DOMAIN_V2: &[u8] = b"PWSV-VERIFY-AAD-v2"; diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs new file mode 100644 index 0000000000..75f9cd0c9a --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs @@ -0,0 +1,42 @@ +//! Bincode wire format for the Tier-2 envelope — `Envelope` / +//! `Payload` struct definitions. +//! +//! The encoder + decoder land in subsequent commits; T-1 stops at the +//! type definitions so the AAD encode-side tests compile against the +//! shared bincode config. + +use crate::secrets::file::crypto::{NONCE_LEN, SALT_LEN}; +use crate::secrets::wire::kdf::KdfParamsEncoded; + +/// On-disk Tier-2 wire envelope. The whole struct is bincode-encoded +/// in one call; a wire-edited `version` is gated to +/// `SecretStoreError::UnsupportedEnvelopeVersion` before dispatch. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)] +pub(crate) struct Envelope { + /// Envelope wire version (`ENVELOPE_VERSION`). + pub version: u32, + /// Tagged payload selecting unprotected vs password-protected. + pub payload: Payload, +} + +/// Tagged payload: scheme-0 ships the plaintext as-is (the backend's +/// own at-rest crypto is the only defence); scheme-1 ships the AEAD +/// triple under an object-password-derived key. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)] +pub(crate) enum Payload { + /// Scheme 0 — unprotected passthrough; the bytes are the secret. + Unprotected(Vec), + /// Scheme 1 — sealed under an Argon2id-derived key with + /// XChaCha20-Poly1305. The AAD bound at seal time is + /// [`crate::secrets::wire::aad::Tier2Aad`]. + Password { + /// Argon2 parameters used to derive the key. + kdf: KdfParamsEncoded, + /// Per-wrap CSPRNG salt fed into Argon2. + salt: [u8; SALT_LEN], + /// Per-wrap CSPRNG nonce fed into XChaCha20-Poly1305. + nonce: [u8; NONCE_LEN], + /// Ciphertext + 16-byte Poly1305 tag. + ciphertext: Vec, + }, +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs new file mode 100644 index 0000000000..e869b29159 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs @@ -0,0 +1,56 @@ +//! Bincode-encoded wire image of [`KdfParams`] — the Argon2 parameter +//! header read out of every scheme-1 envelope. +//! +//! Kept as a separate type from [`KdfParams`] (the in-memory + JSON- +//! vault type) so the wire layer owns its own bincode derives and the +//! in-memory type keeps its serde derives for the human-debuggable JSON +//! vault format. + +use crate::secrets::error::SecretStoreError; +use crate::secrets::file::crypto::KdfParams; + +/// Wire image of [`KdfParams`]: `id ‖ m_kib ‖ t ‖ p`, each a fixed- +/// width integer under the bincode varint config. Encoded once into +/// every scheme-1 envelope's `Payload::Password` body AND into the +/// scheme-1 AAD, so the two cannot disagree without failing the tag. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) struct KdfParamsEncoded { + /// Argon2 algorithm discriminator (only `KDF_ID_ARGON2ID = 1` + /// today; enforced by [`KdfParams::enforce_bounds`]). + pub id: u8, + /// Argon2 memory cost (KiB). Bounded. + pub m_kib: u32, + /// Argon2 time cost (iterations). Bounded. + pub t: u32, + /// Argon2 parallelism. Pinned to 1. + pub p: u32, +} + +impl From for KdfParamsEncoded { + fn from(k: KdfParams) -> Self { + Self { + id: k.id, + m_kib: k.m_kib, + t: k.t, + p: k.p, + } + } +} + +impl TryFrom for KdfParams { + type Error = SecretStoreError; + + /// Convert the wire image into the in-memory [`KdfParams`], gated on + /// [`KdfParams::enforce_bounds`] so an inflated header never + /// reaches `derive_key`. + fn try_from(k: KdfParamsEncoded) -> Result { + let out = KdfParams { + id: k.id, + m_kib: k.m_kib, + t: k.t, + p: k.p, + }; + out.enforce_bounds()?; + Ok(out) + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs new file mode 100644 index 0000000000..90d7f1cc14 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs @@ -0,0 +1,40 @@ +//! Bincode wire format for the Tier-2 envelope and the three AAD +//! constructions used inside `secrets/`. +//! +//! Every byte that crosses the AEAD seam — the on-disk Tier-2 blob and the +//! AAD bound into each ciphertext — is produced by a `#[derive(bincode:: +//! Encode)]` (or `Encode + Decode`) struct in this module, against the +//! single [`config::WIRE_CONFIG`] constant. A future bincode-config drift +//! is then caught by the golden vector tests in [`envelope::tests`] +//! instead of silently corrupting every stored blob. +//! +//! Module is `pub(crate)` only — the Tier-2 wire format is an +//! implementation detail of [`SecretStore`](super::store::SecretStore); +//! external callers see the unchanged `set_secret` / `get_secret` API. +//! +//! Audit-readable layout: +//! +//! - [`config`] — the single bincode config + domain-tag / version +//! constants every encoder uses. +//! - [`kdf`] — `KdfParamsEncoded`, the wire image of [`KdfParams`]. +//! - [`aad`] — the three AAD structs (`Tier2Aad` / `EntryAad` / +//! `VerifyAad`). +//! - [`envelope`] — the `Envelope` + `Payload` structs plus the +//! `wrap` / `unwrap` API. +//! +//! [`KdfParams`]: super::file::crypto::KdfParams +//! +//! Domain tags include an explicit `-v2` suffix to mark the +//! wire-format break from the pre-bincode hand-rolled layout +//! (`PWSEV-TIER2-AAD-v1` and the implicitly-untagged +//! `secrets/file/format.rs::aad` / `verify_aad` outputs). +#![deny(missing_docs)] +// Type-only scaffolding before the encoder/decoder is wired in. The +// consumers in `envelope.rs` reference every item once the module is +// complete. +#![allow(dead_code)] + +pub(crate) mod aad; +pub(crate) mod config; +pub(crate) mod envelope; +pub(crate) mod kdf; From bb863d2dfe9e97e30bee45eecf5e381f6baec65c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:23:07 +0000 Subject: [PATCH 15/21] refactor(rs-platform-wallet-storage)!: implement wire encoder + size goldens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out `secrets/wire/envelope.rs` with the bincode-encoded `wrap` / `wrap_with_params` path and pin the wire output against golden hex vectors plus the runtime `MAX_ENVELOPE_OVERHEAD` cross-check. - `wrap_with_params` builds an `Envelope { version, payload }` and bincode-encodes it once against `WIRE_CONFIG`. Scheme-0 ships the plaintext under `Payload::Unprotected`; scheme-1 derives the Argon2id key, bincode-encodes a `Tier2Aad`, AEAD-seals under the AAD, and packs the result into `Payload::Password { kdf, salt, nonce, ciphertext }`. `crypto::derive_key`/`crypto::seal`/ `crypto::random_bytes` are reused unchanged. - `MAX_ENVELOPE_OVERHEAD = 112` — the smallest scheme-1 envelope (empty plaintext sealed → 16-byte AEAD tag) measures 81 bytes; rounded up to the next 16-byte boundary above the runtime measurement plus a 16-byte safety margin. `MAX_PLAINTEXT_LEN = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD`, with the cap enforced before any derive. - `#[cfg(test)] wrap_with_params_for_test` + `crypto::seal_with_nonce` give the deterministic encoder seam the golden vectors need. - Golden hex vectors landed as `SCHEME0_GOLDEN_HEX` / `SCHEME1_GOLDEN_HEX` constants — captured once from the runtime encoder. A future failure here is a bincode-config-drift signal, not a re-generation chore. Pins TC-028 (scheme-0 golden hex), TC-029 (scheme-1 deterministic golden hex), TC-030 (MAX_ENVELOPE_OVERHEAD runtime cross-check), TC-033 (blank-pw rejected at wrap-side enrol), TC-034 (plaintext cap accept/reject for both schemes), TC-035 size-budget half (scheme-1 at-cap envelope fits backend cap). The round-trip half of TC-035 lands with the decoder in T-3. Refs design-brief.md §2.5 (encoder), §2.6 (overhead constant); dev-plan.md T-2. --- .../src/secrets/file/crypto.rs | 27 ++ .../src/secrets/wire/envelope.rs | 337 +++++++++++++++++- 2 files changed, 358 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs index 4bb0a350b6..bbfb6b2642 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs @@ -140,6 +140,33 @@ pub(crate) fn seal( Ok((nonce_bytes, ct)) } +/// Like [`seal`] but takes a caller-supplied `nonce` instead of pulling +/// from the CSPRNG. **Test-only** — golden-vector / size-budget tests +/// need byte-deterministic ciphertext output. Production code MUST use +/// [`seal`] so nonces stay unique (XChaCha20-Poly1305 nonce reuse leaks +/// the keystream). +#[cfg(test)] +pub(crate) fn seal_with_nonce( + key: &SecretBytes, + nonce_bytes: [u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], +) -> Result<([u8; NONCE_LEN], Vec), SecretStoreError> { + let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret()) + .map_err(|_| SecretStoreError::Encrypt)?; + let nonce = XNonce::from_slice(&nonce_bytes); + let ct = cipher + .encrypt( + nonce, + chacha20poly1305::aead::Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| SecretStoreError::Encrypt)?; + Ok((nonce_bytes, ct)) +} + /// Decrypt `ciphertext` under `key`/`nonce`/`aad`. On tag failure /// returns [`SecretStoreError::Decrypt`] and **no** plaintext — the /// combined (non-detached) API never materializes unverified bytes at diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs index 75f9cd0c9a..5dc611dab8 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs @@ -1,12 +1,20 @@ -//! Bincode wire format for the Tier-2 envelope — `Envelope` / -//! `Payload` struct definitions. +//! Tier-2 envelope wire format — bincode-encoded `Envelope` / `Payload` +//! plus the [`wrap`] / [`wrap_with_params`] / [`unwrap`] API. //! -//! The encoder + decoder land in subsequent commits; T-1 stops at the -//! type definitions so the AAD encode-side tests compile against the -//! shared bincode config. +//! Encoder lives here; the decoder (and the strict fail-closed dispatch +//! table) is filled in by T-3. Every byte that crosses the AEAD seam is +//! produced by `bincode::encode_to_vec` against [`WIRE_CONFIG`], so a +//! future config drift surfaces in the golden-vector tests, not in +//! silently corrupted blobs. -use crate::secrets::file::crypto::{NONCE_LEN, SALT_LEN}; +use crate::secrets::error::SecretStoreError; +use crate::secrets::file::crypto::{self, KdfParams, NONCE_LEN, SALT_LEN}; +use crate::secrets::secret::{SecretBytes, SecretString}; +use crate::secrets::validate::WalletId; +use crate::secrets::wire::aad::Tier2Aad; +use crate::secrets::wire::config::{ENVELOPE_VERSION, TIER2_DOMAIN_V2, WIRE_CONFIG}; use crate::secrets::wire::kdf::KdfParamsEncoded; +use crate::secrets::MAX_SECRET_LEN; /// On-disk Tier-2 wire envelope. The whole struct is bincode-encoded /// in one call; a wire-edited `version` is gated to @@ -40,3 +48,320 @@ pub(crate) enum Payload { ciphertext: Vec, }, } + +/// Upper bound on the bincode-encoded envelope overhead over its +/// plaintext (header + KDF + salt + nonce + AEAD tag + bincode framing). +/// Pinned by a runtime cross-check in `tests::max_envelope_overhead_matches_runtime` +/// so any bincode-config drift surfaces immediately. The smallest +/// scheme-1 envelope (empty plaintext sealed → 16-byte tag) measures +/// 81 bytes; rounded up to the next 16-byte boundary that satisfies a +/// 16-byte safety margin (81 + 16 = 97 → 112) for headroom against a +/// future header field. +pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 112; + +/// Plaintext cap at the envelope boundary: `MAX_SECRET_LEN − +/// MAX_ENVELOPE_OVERHEAD`. Capping the plaintext (uniformly for both +/// schemes) keeps the user-visible limit stable AND guarantees the +/// enveloped bytes always fit the backend vault's own `MAX_SECRET_LEN` +/// `put_bytes` cap. +pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; + +/// Wrap `plaintext` for `(wallet_id, label)` using the shipped default +/// Argon2 target when a password is supplied. +/// +/// `None` → an unprotected (scheme-0) envelope; `Some(pw)` → a scheme-1 +/// envelope sealed under `pw`. A blank password is rejected at enrol +/// (`SecretStoreError::BlankPassphrase`). +/// +/// Returns the envelope inside a zeroizing [`SecretBytes`]. +pub(crate) fn wrap( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + plaintext: &[u8], +) -> Result { + wrap_with_params( + wallet_id, + label, + password, + plaintext, + KdfParams::default_target(), + ) +} + +/// [`wrap`] with explicit Argon2 `params` (tests use floor params for +/// speed). `params` is ignored when `password` is `None`. +pub(crate) fn wrap_with_params( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + plaintext: &[u8], + params: KdfParams, +) -> Result { + // Cap the PLAINTEXT (before overhead) uniformly for both schemes so + // the enveloped bytes always fit the backend cap. + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + + let Some(pw) = password else { + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(plaintext.to_vec()), + }; + return Ok(SecretBytes::new(encode_envelope(&envelope))); + }; + + // Reject a blank object password BEFORE any salt / derive. + if pw.is_blank() { + return Err(SecretStoreError::BlankPassphrase); + } + + let mut salt = [0u8; SALT_LEN]; + crypto::random_bytes(&mut salt)?; + let key = crypto::derive_key(pw, &salt, params)?; + let kdf = KdfParamsEncoded::from(params); + let aad = encode_tier2_aad(wallet_id, label, kdf, &salt); + let (nonce, ciphertext) = crypto::seal(&key, &aad, plaintext)?; + + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + }; + Ok(SecretBytes::new(encode_envelope(&envelope))) +} + +/// Bincode-encode the scheme-1 AAD against [`WIRE_CONFIG`]. Shared by +/// the encoder and the (T-3) decoder so the two cannot disagree. +pub(crate) fn encode_tier2_aad( + wallet_id: &WalletId, + label: &str, + kdf: KdfParamsEncoded, + salt: &[u8; SALT_LEN], +) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version: ENVELOPE_VERSION, + scheme_discriminant: 1, + kdf, + salt: *salt, + wallet_id: *wallet_id.as_bytes(), + label, + }; + // AAD encode is infallible — every field is owned/borrowed bincode- + // Encode-able. A failure would be a logic bug. + bincode::encode_to_vec(aad, WIRE_CONFIG).expect("Tier2Aad encode is infallible") +} + +/// Bincode-encode the whole envelope. Wrapping in `SecretBytes::new` +/// keeps the (possibly plaintext-bearing) scheme-0 buffer zeroizing. +fn encode_envelope(envelope: &Envelope) -> Vec { + bincode::encode_to_vec(envelope, WIRE_CONFIG).expect("Envelope encode is infallible") +} + +/// Test-only deterministic encoder: takes pre-supplied `salt` and +/// `nonce` instead of pulling from the CSPRNG, so golden-vector tests +/// produce reproducible bytes. Production callers MUST use +/// [`wrap_with_params`]. +#[cfg(test)] +pub(crate) fn wrap_with_params_for_test( + wallet_id: &WalletId, + label: &str, + pw: &SecretString, + plaintext: &[u8], + params: KdfParams, + salt: [u8; SALT_LEN], + nonce: [u8; NONCE_LEN], +) -> Result { + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + if pw.is_blank() { + return Err(SecretStoreError::BlankPassphrase); + } + let key = crypto::derive_key(pw, &salt, params)?; + let kdf = KdfParamsEncoded::from(params); + let aad = encode_tier2_aad(wallet_id, label, kdf, &salt); + let (nonce, ciphertext) = crypto::seal_with_nonce(&key, nonce, &aad, plaintext)?; + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + }; + Ok(SecretBytes::new(encode_envelope(&envelope))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secrets::file::crypto::{ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P}; + use crate::secrets::file::format::KDF_ID_ARGON2ID; + + /// Captured once from the runtime encoder; a subsequent CI failure + /// here means a wire-format drift to investigate, NOT to "fix" by + /// re-generating the constant. + /// + /// Decoding: 0x01 envelope.version=1, 0x00 Payload::Unprotected, + /// 0x05 Vec length=5, "hello". + const SCHEME0_GOLDEN_HEX: &str = "01000568656c6c6f"; + + /// scheme-1 deterministic golden: wid=[0;32], label="seed", + /// pw="pw", plaintext="hello", floor params, salt=[0x11;32], + /// nonce=[0x22;24]. Bytes: version + Payload::Password tag + + /// kdf(id,m_kib,t,p as varints) + salt[32] + nonce[24] + + /// ciphertext-with-tag length + ciphertext+tag(21B). + const SCHEME1_GOLDEN_HEX: &str = "010101fb4c000201111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222215e2ffdf3f0476b6bfb99b4f71b3039ff965132b92f0"; + + fn wid(b: u8) -> WalletId { + WalletId::from([b; 32]) + } + + fn pw(s: &str) -> SecretString { + SecretString::new(s) + } + + fn floor() -> KdfParams { + KdfParams { + id: KDF_ID_ARGON2ID, + m_kib: ARGON2_MIN_M_KIB, + t: ARGON2_MIN_T, + p: ARGON2_P, + } + } + + /// TC-033 — blank object password rejected at enrol (wrap-side). + /// The unwrap-side blank-pw guard lives on a sibling branch. + #[test] + fn blank_object_password_rejected_at_wrap() { + for blank in [SecretString::empty(), pw(""), pw(" "), pw("\t\n")] { + let err = + wrap_with_params(&wid(1), "seed", Some(&blank), b"seed", floor()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "got {err:?}" + ); + } + } + + /// TC-034 — plaintext cap accept at MAX_PLAINTEXT_LEN, reject at + /// +1, for both schemes. + #[test] + fn plaintext_cap_accept_then_reject() { + let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let over = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1]; + + // Scheme 0 + assert!(wrap(&wid(1), "seed", None, &at_cap).is_ok()); + assert!(matches!( + wrap(&wid(1), "seed", None, &over).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + + // Scheme 1 — cap check fires before any derivation. + let p = pw("pw"); + assert!(matches!( + wrap_with_params(&wid(1), "seed", Some(&p), &over, floor()).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + + // Scheme-0 enveloped bytes for an at-cap plaintext fit the backend cap. + let enveloped = wrap(&wid(1), "seed", None, &at_cap).unwrap(); + assert!(enveloped.len() <= MAX_SECRET_LEN); + } + + /// TC-035 (size-budget half) — scheme-1 accepts plaintext at the + /// exact MAX_PLAINTEXT_LEN boundary; the enveloped bytes fit the + /// backend cap. The round-trip half lands in T-3. + #[test] + fn scheme1_at_cap_envelope_fits_backend_cap() { + let p = pw("pw"); + let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap(); + assert!( + blob.len() <= MAX_SECRET_LEN, + "enveloped bytes ({} B) exceed backend cap ({} B)", + blob.len(), + MAX_SECRET_LEN + ); + } + + /// TC-028 — golden hex vector for the scheme-0 wire bytes. Any + /// bincode-config drift (endianness, varint mode, limit) trips this. + #[test] + fn scheme0_golden_vector_matches_const() { + let blob = wrap(&WalletId::from([0u8; 32]), "seed", None, b"hello").unwrap(); + let actual = hex::encode(blob.expose_secret()); + assert_eq!(actual, SCHEME0_GOLDEN_HEX); + } + + /// TC-029 — golden hex vector for the scheme-1 wire bytes, produced + /// via the deterministic encoder seam. + #[test] + fn scheme1_golden_vector_matches_const() { + let blob = wrap_with_params_for_test( + &WalletId::from([0u8; 32]), + "seed", + &pw("pw"), + b"hello", + floor(), + [0x11u8; SALT_LEN], + [0x22u8; NONCE_LEN], + ) + .unwrap(); + let actual = hex::encode(blob.expose_secret()); + assert_eq!(actual, SCHEME1_GOLDEN_HEX); + } + + /// Minimum overhead within budget AND the budget not absurdly above + /// the actual encoding — bound on both sides so the constant stays + /// honest as the wire shape evolves. + const SAFETY_MARGIN: usize = 16; + + /// TC-030 — `MAX_ENVELOPE_OVERHEAD` cross-checks the runtime + /// bincode encoding of the smallest possible scheme-1 envelope + /// (empty plaintext sealed → ciphertext == 16-byte AEAD tag). + #[test] + fn max_envelope_overhead_matches_runtime() { + let blob = wrap_with_params_for_test( + &WalletId::from([0u8; 32]), + "seed", + &pw("pw"), + b"", + floor(), + [0x11u8; SALT_LEN], + [0x22u8; NONCE_LEN], + ) + .unwrap(); + let actual = blob.len(); + assert!( + actual + SAFETY_MARGIN <= MAX_ENVELOPE_OVERHEAD, + "overhead {} + margin {} exceeds const {}", + actual, + SAFETY_MARGIN, + MAX_ENVELOPE_OVERHEAD + ); + assert!( + MAX_ENVELOPE_OVERHEAD - actual < 64, + "MAX_ENVELOPE_OVERHEAD {} is more than 64 B above the runtime measurement {} — tighten it", + MAX_ENVELOPE_OVERHEAD, + actual + ); + } +} From 8f7c175a3f0e631a648dfba28e007f039edb9ef4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:35:46 +0000 Subject: [PATCH 16/21] refactor(rs-platform-wallet-storage)!: implement wire decoder + dispatch + fuzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the strict fail-closed `unwrap` + `unwrap_password_payload` in `secrets/wire/envelope.rs`, decoding via a limited bincode config so a hostile blob declaring a multi-GiB length prefix is refused before any allocation. - `unwrap(wallet_id, label, password, blob)` decodes `Envelope` from `DECODE_CONFIG` (= `WIRE_CONFIG.with_limit::()`); every bincode `DecodeError` collapses to `Corruption`. `envelope.version` is gated to `UnsupportedEnvelopeVersion { found: as u8 }` ahead of dispatch. - The `(payload, password)` table is the strict fail-closed four-arm: `(Unprotected, None)` -> bytes; `(Unprotected, Some)` -> `ExpectedProtectedButUnsealed` (strip/downgrade); `(Password, None)` -> `NeedsPassword`; `(Password, Some)` -> `unwrap_password_payload`. - `unwrap_password_payload` runs the wider `enforce_bounds` (TC-023) AND the stricter per-read `default_target` ceiling (TC-024) BEFORE `derive_key`; AAD is re-built via the same `encode_tier2_aad` the encoder uses, so the two cannot drift; AEAD `Decrypt` maps to `WrongPassword` per CWE-347. - `DECODE_CONFIG` keeps encoding on the canonical `WIRE_CONFIG` (no-limit, matching `rs-platform-serialization`); only the decode path bounds reads. Pins TC-001 (scheme-0 round-trip), TC-002 (scheme-1 round-trip), TC-003 (fresh salt/nonce per wrap), TC-004 (wrong-pw -> WrongPassword), TC-005 .. TC-009 (wire-flip per field), TC-010 (cross-wallet-id), TC-011 (cross-label), TC-012 (version flip), TC-013 (Payload dispatch redirect), TC-017 (truncated -> Corruption), TC-018 (garbage -> Corruption), TC-019 (forged v2 envelope), TC-020 (unknown payload tag), TC-021 (Some+scheme-0), TC-022 (None+scheme-1), TC-023 (KDF ceiling pre-derive), TC-024 (per-read default_target ceiling), TC-031 (ConstantTimeEq), TC-032 (fuzz never panics), TC-035 round-trip half, TC-036 (value rollback pinning), TC-040 (proptest single-byte flip). Refs design-brief.md §2.4 (decoder), §1.1 F6 (per-read ceiling), §3.1 (AC-F1..F8); dev-plan.md T-3. --- .../src/secrets/wire/envelope.rs | 591 +++++++++++++++++- 1 file changed, 583 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs index 5dc611dab8..cef605e48d 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs @@ -1,11 +1,14 @@ //! Tier-2 envelope wire format — bincode-encoded `Envelope` / `Payload` //! plus the [`wrap`] / [`wrap_with_params`] / [`unwrap`] API. //! -//! Encoder lives here; the decoder (and the strict fail-closed dispatch -//! table) is filled in by T-3. Every byte that crosses the AEAD seam is -//! produced by `bincode::encode_to_vec` against [`WIRE_CONFIG`], so a -//! future config drift surfaces in the golden-vector tests, not in -//! silently corrupted blobs. +//! Every byte that crosses the AEAD seam is produced by +//! `bincode::encode_to_vec` against [`WIRE_CONFIG`], so a future config +//! drift surfaces in the golden-vector tests, not in silently corrupted +//! blobs. Decoding goes through [`DECODE_CONFIG`] — the same +//! configuration with a byte limit, so a hostile blob declaring a +//! multi-GiB length prefix is rejected before any allocation. + +use bincode::config::{BigEndian, Configuration, Limit, Varint}; use crate::secrets::error::SecretStoreError; use crate::secrets::file::crypto::{self, KdfParams, NONCE_LEN, SALT_LEN}; @@ -66,6 +69,18 @@ pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 112; /// `put_bytes` cap. pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; +/// Decode-side budget: caps the bytes the bincode decoder will consume +/// from a single envelope. Defends against a hostile blob whose +/// length-prefix bytes declare a multi-GiB `Vec`. Encoding stays on +/// the no-limit `WIRE_CONFIG` so legitimate outputs (always `<= +/// MAX_SECRET_LEN`) are never refused. Equal to the on-disk cap. +const DECODE_BUDGET: usize = MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD; + +/// Bincode decode config = encoder's config with the +/// [`DECODE_BUDGET`] limit applied. +const DECODE_CONFIG: Configuration> = + WIRE_CONFIG.with_limit::(); + /// Wrap `plaintext` for `(wallet_id, label)` using the shipped default /// Argon2 target when a password is supplied. /// @@ -140,7 +155,8 @@ pub(crate) fn wrap_with_params( } /// Bincode-encode the scheme-1 AAD against [`WIRE_CONFIG`]. Shared by -/// the encoder and the (T-3) decoder so the two cannot disagree. +/// [`wrap_with_params`] and [`unwrap_password_payload`] so the encode +/// and decode AADs cannot drift apart. pub(crate) fn encode_tier2_aad( wallet_id: &WalletId, label: &str, @@ -167,6 +183,93 @@ fn encode_envelope(envelope: &Envelope) -> Vec { bincode::encode_to_vec(envelope, WIRE_CONFIG).expect("Envelope encode is infallible") } +/// Unwrap `blob` for `(wallet_id, label)`, applying the strict +/// fail-closed read. +/// +/// `password` carries the caller's protection assertion — never the +/// blob's scheme byte. Decode errors (truncated, garbage bytes, unknown +/// enum tag) collapse to `Corruption`; an envelope version this build +/// does not recognise yields `UnsupportedEnvelopeVersion` ahead of +/// dispatch. +/// +/// | `password` | `payload` | result | +/// |---|---|---| +/// | `Some(pw)` | `Password { .. }` | the secret, or `WrongPassword` on tag fail | +/// | `Some(pw)` | `Unprotected(_)` | `ExpectedProtectedButUnsealed` (strip/downgrade) | +/// | `None` | `Password { .. }` | `NeedsPassword` (never ciphertext) | +/// | `None` | `Unprotected(pt)` | the secret | +pub(crate) fn unwrap( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + blob: &[u8], +) -> Result { + let (envelope, _) = bincode::decode_from_slice::(blob, DECODE_CONFIG) + .map_err(|_| SecretStoreError::Corruption)?; + + if envelope.version != ENVELOPE_VERSION { + // `found` keeps the historical u8 — the error API stayed u8 for + // back-compat; an out-of-range u32 wraps but the decoder above + // already accepts every u32 so this only narrows the diagnostic. + return Err(SecretStoreError::UnsupportedEnvelopeVersion { + found: envelope.version as u8, + }); + } + + match (envelope.payload, password) { + (Payload::Unprotected(plaintext), None) => Ok(SecretBytes::new(plaintext)), + // Caller asserted protection but blob is unprotected: strip / + // downgrade — fail closed, never return the bytes. + (Payload::Unprotected(_), Some(_)) => Err(SecretStoreError::ExpectedProtectedButUnsealed), + (Payload::Password { .. }, None) => Err(SecretStoreError::NeedsPassword), + ( + Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + Some(pw), + ) => unwrap_password_payload(wallet_id, label, pw, kdf, salt, nonce, &ciphertext), + } +} + +/// Decrypt a `Payload::Password` body. The KDF params, salt and nonce +/// come from the (attacker-controllable) envelope; `enforce_bounds` +/// AND a stricter per-read `default_target` ceiling gate the params +/// BEFORE `derive_key` allocates. +fn unwrap_password_payload( + wallet_id: &WalletId, + label: &str, + password: &SecretString, + kdf_encoded: KdfParamsEncoded, + salt: [u8; SALT_LEN], + nonce: [u8; NONCE_LEN], + ciphertext: &[u8], +) -> Result { + // (a) Wider Argon2 floors/ceilings — refuses an inflated header + // before any allocation. + let kdf = KdfParams::try_from(kdf_encoded)?; + // (b) Per-read ceiling tighter than `enforce_bounds`: a header + // declaring more memory than this build's shipped target is also + // refused before `derive_key` allocates. Closes the gap between + // `ARGON2_MAX_M_KIB` (1 GiB) and the shipped 64 MiB default. + if kdf.m_kib > KdfParams::default_target().m_kib { + return Err(SecretStoreError::KdfFailure); + } + // (c) AAD binds identity + header — the same bytes the encoder + // produced, by construction. + let aad = encode_tier2_aad(wallet_id, label, kdf_encoded, &salt); + let key = crypto::derive_key(password, &salt, kdf)?; + match crypto::open(&key, &nonce, &aad, ciphertext) { + Ok(plaintext) => Ok(plaintext), + // Tag failure (wrong password, relocated blob, header tamper): + // no plaintext ever materialises (CWE-347). + Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassword), + Err(e) => Err(e), + } +} + /// Test-only deterministic encoder: takes pre-supplied `salt` and /// `nonce` instead of pulling from the CSPRNG, so golden-vector tests /// produce reproducible bytes. Production callers MUST use @@ -245,7 +348,6 @@ mod tests { } /// TC-033 — blank object password rejected at enrol (wrap-side). - /// The unwrap-side blank-pw guard lives on a sibling branch. #[test] fn blank_object_password_rejected_at_wrap() { for blank in [SecretString::empty(), pw(""), pw(" "), pw("\t\n")] { @@ -288,7 +390,7 @@ mod tests { /// TC-035 (size-budget half) — scheme-1 accepts plaintext at the /// exact MAX_PLAINTEXT_LEN boundary; the enveloped bytes fit the - /// backend cap. The round-trip half lands in T-3. + /// backend cap. The round-trip half is `scheme1_at_cap_round_trips_within_backend_cap`. #[test] fn scheme1_at_cap_envelope_fits_backend_cap() { let p = pw("pw"); @@ -364,4 +466,477 @@ mod tests { actual ); } + + // ===== Decoder: dispatch / wire-flip / fuzz / property ===== + + use crate::secrets::file::crypto::{ARGON2_MAX_M_KIB, ARGON2_MAX_T}; + use crate::secrets::wire::config::WIRE_CONFIG; + use subtle::ConstantTimeEq; + + /// Decode a real envelope so wire-flip tests can mutate one field + /// and re-encode. + fn decode(blob: &[u8]) -> Envelope { + bincode::decode_from_slice::(blob, WIRE_CONFIG) + .unwrap() + .0 + } + + fn encode(envelope: &Envelope) -> Vec { + bincode::encode_to_vec(envelope, WIRE_CONFIG).unwrap() + } + + /// Build a fresh scheme-1 envelope (under wid(1)/"seed"/pw=`p`) and + /// hand back the bytes for mutation tests. + fn scheme1_blob(p: &SecretString) -> Vec { + wrap_with_params(&wid(1), "seed", Some(p), b"seed", floor()) + .unwrap() + .expose_secret() + .to_vec() + } + + /// TC-001 — scheme-0 round-trip preserves plaintext. + #[test] + fn scheme0_round_trip_preserves_plaintext() { + let blob = wrap(&wid(1), "seed", None, b"top secret seed bytes").unwrap(); + let got = unwrap(&wid(1), "seed", None, blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"top secret seed bytes"); + } + + /// TC-002 — scheme-1 round-trip preserves plaintext. + #[test] + fn scheme1_round_trip_preserves_plaintext() { + let p = pw("hunter2"); + let blob = wrap_with_params( + &wid(7), + "seed", + Some(&p), + b"correct horse battery staple", + floor(), + ) + .unwrap(); + assert_ne!(blob.expose_secret(), b"correct horse battery staple"); + let got = unwrap(&wid(7), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"correct horse battery staple"); + } + + /// TC-003 — scheme-1 produces a fresh salt + nonce per wrap. + #[test] + fn scheme1_uses_fresh_salt_and_nonce_per_wrap() { + let p = pw("pw"); + let a = scheme1_blob(&p); + let b = scheme1_blob(&p); + let (sa, na) = match decode(&a).payload { + Payload::Password { salt, nonce, .. } => (salt, nonce), + _ => panic!("scheme-1 wrap must yield Password"), + }; + let (sb, nb) = match decode(&b).payload { + Payload::Password { salt, nonce, .. } => (salt, nonce), + _ => panic!("scheme-1 wrap must yield Password"), + }; + assert_ne!(sa, sb, "salt must be fresh per wrap"); + assert_ne!(na, nb, "nonce must be fresh per wrap"); + } + + /// TC-004 — wrong object password yields WrongPassword. + #[test] + fn wrong_password_fails_closed() { + let blob = scheme1_blob(&pw("right")); + let err = unwrap(&wid(1), "seed", Some(&pw("wrong")), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// Mutate the `Payload::Password` body in-place via decode → patch + /// → encode. Returns the new blob. + fn mutate_scheme1( + blob: &[u8], + patch: impl FnOnce(&mut KdfParamsEncoded, &mut [u8; SALT_LEN], &mut [u8; NONCE_LEN]), + ) -> Vec { + let mut env = decode(blob); + match env.payload { + Payload::Password { + ref mut kdf, + ref mut salt, + ref mut nonce, + .. + } => patch(kdf, salt, nonce), + _ => panic!("mutate_scheme1 expects a Password payload"), + } + encode(&env) + } + + /// TC-005 — wire-flip of kdf.m_kib (in-bounds shift) yields WrongPassword. + #[test] + fn wire_flip_kdf_m_kib_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = ARGON2_MIN_M_KIB + 1024; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-006 — wire-flip of kdf.t (in-bounds shift) yields WrongPassword. + #[test] + fn wire_flip_kdf_t_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.t = ARGON2_MIN_T + 1; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-007 — wire-flip of kdf.id to an unknown value is rejected by + /// `enforce_bounds` BEFORE `derive_key` allocates. + #[test] + fn wire_flip_kdf_id_unknown_rejected_pre_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.id = 7; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// TC-008 — wire-flip of salt[0] yields WrongPassword. + #[test] + fn wire_flip_salt_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |_, salt, _| { + salt[0] ^= 0x01; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-009 — wire-flip of nonce[0] yields WrongPassword. + #[test] + fn wire_flip_nonce_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |_, _, nonce| { + nonce[0] ^= 0x01; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-010 — re-binding the unwrap to a different wallet_id rejects. + #[test] + fn relocation_across_wallet_id_rejected() { + let p = pw("pw"); + let blob = wrap_with_params(&wid(0xA), "seed", Some(&p), b"seed", floor()).unwrap(); + let err = unwrap(&wid(0xB), "seed", Some(&p), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-011 — re-binding the unwrap to a different label rejects. + #[test] + fn relocation_across_label_rejected() { + let p = pw("pw"); + let blob = wrap_with_params(&wid(1), "labelA", Some(&p), b"seed", floor()).unwrap(); + let err = unwrap(&wid(1), "labelB", Some(&p), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-012 — wire-flip of envelope.version (via re-encode) is gated + /// to UnsupportedEnvelopeVersion before AAD bind. + #[test] + fn wire_flip_version_rejected_pre_aad() { + let blob = scheme1_blob(&pw("pw")); + let mut env = decode(&blob); + env.version = 2; + let tampered = encode(&env); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &tampered).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } + ), + "got {err:?}" + ); + } + + /// TC-013 — forged `Payload::Unprotected` with ciphertext bytes + + /// `Some(pw)` redirects to ExpectedProtectedButUnsealed. + #[test] + fn wire_flip_scheme_dispatch_redirects_safely() { + let env = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(vec![0xDEu8; 32]), + }; + let blob = encode(&env); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "got {err:?}" + ); + } + + /// TC-017 — truncated blob (< minimum envelope length) yields + /// Corruption. + #[test] + fn truncated_blob_yields_corruption() { + let blob = scheme1_blob(&pw("pw")); + let cut = blob.len() / 2; + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob[..cut]).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-018 — random-byte blob yields Corruption (both arms). + #[test] + fn random_garbage_yields_corruption() { + let garbage = b"NOTANEVELOPE........................."; + let err = unwrap(&wid(1), "seed", None, garbage).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), garbage).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-019 — a manually-built envelope at version=2 fails closed + /// regardless of password. + #[test] + fn unsupported_version_rejected_for_any_password() { + let env = Envelope { + version: 2, + payload: Payload::Unprotected(b"x".to_vec()), + }; + let blob = encode(&env); + for arg in [None, Some(&pw("pw"))] { + let err = unwrap(&wid(1), "seed", arg, &blob).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } + ), + "got {err:?}" + ); + } + } + + /// TC-020 — a hand-crafted byte stream with an unknown payload + /// enum tag yields Corruption (bincode's natural fail-closed). + #[test] + fn unknown_scheme_discriminant_yields_corruption() { + // envelope.version = 1 (varint = 0x01) then a Payload enum tag + // of 7 (varint = 0x07) — the two-variant enum decode rejects. + let blob = [0x01u8, 0x07]; + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-021 — Some(pw) + scheme-0 yields ExpectedProtectedButUnsealed. + #[test] + fn some_pw_on_scheme0_fails_closed() { + let blob = wrap(&wid(1), "seed", None, b"attacker-seed").unwrap(); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "got {err:?}" + ); + } + + /// TC-022 — None + scheme-1 yields NeedsPassword. + #[test] + fn none_pw_on_scheme1_yields_needs_password() { + let blob = scheme1_blob(&pw("pw")); + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::NeedsPassword), + "got {err:?}" + ); + } + + /// TC-023 — inflated KDF param rejected by `enforce_bounds` before + /// `derive_key` allocates (a ~4 TiB allocation would OOM the test). + #[test] + fn kdf_enforce_bounds_rejects_before_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = u32::MAX; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.t = ARGON2_MAX_T + 1; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// TC-024 — per-read `default_target` ceiling rejects an envelope + /// whose `m_kib` exceeds the shipped target even when still inside + /// `enforce_bounds`. Catches inflated headers BEFORE `derive_key`. + #[test] + fn per_read_default_target_ceiling_rejects_inflated_header() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let bumped = KdfParams::default_target().m_kib * 2; + // Sanity: the bumped value stays inside the wider enforce_bounds + // ceiling, so only the per-read gate can refuse it. + assert!(bumped <= ARGON2_MAX_M_KIB); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = bumped; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// TC-031 — round-tripped secret matches the original under a + /// constant-time compare. + #[test] + fn round_trip_is_constant_time_equal() { + let p = pw("pw"); + let original = SecretBytes::from_slice(b"seed material"); + let blob = + wrap_with_params(&wid(1), "seed", Some(&p), original.expose_secret(), floor()).unwrap(); + let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert!(bool::from(got.ct_eq(&original))); + } + + /// TC-035 (round-trip half) — scheme-1 at exact MAX_PLAINTEXT_LEN + /// round-trips and the enveloped bytes fit the backend cap. + #[test] + fn scheme1_at_cap_round_trips_within_backend_cap() { + let p = pw("pw"); + let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap(); + assert!(blob.len() <= MAX_SECRET_LEN); + let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), &pt[..]); + } + + /// TC-036 — value rollback is intentionally NOT defended. + #[test] + fn value_rollback_is_not_defended() { + let p = pw("pw"); + let old = wrap_with_params(&wid(1), "seed", Some(&p), b"OLD-VALUE", floor()).unwrap(); + let _new = wrap_with_params(&wid(1), "seed", Some(&p), b"NEW-VALUE", floor()).unwrap(); + let got = unwrap(&wid(1), "seed", Some(&p), old.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"OLD-VALUE"); + } + + /// TC-032 — random byte mutations and truncations never panic; + /// every outcome is a permitted typed variant. + #[test] + fn fuzz_byte_mutation_and_truncation_never_panics() { + let p = pw("fuzz-pw"); + let valid = scheme1_blob(&p); + // Pristine envelope unwraps cleanly. + assert_eq!( + unwrap(&wid(1), "seed", Some(&p), &valid) + .unwrap() + .expose_secret(), + b"seed" + ); + + let mut state: u32 = 0x9E37_79B9; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + + let assert_typed = |arg: Option<&SecretString>, buf: &[u8]| { + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unwrap(&wid(1), "seed", arg, buf) + })) + .expect("unwrap must never panic on hostile input"); + match res { + Ok(_) + | Err(SecretStoreError::Corruption) + | Err(SecretStoreError::WrongPassword) + | Err(SecretStoreError::NeedsPassword) + | Err(SecretStoreError::ExpectedProtectedButUnsealed) + | Err(SecretStoreError::UnsupportedEnvelopeVersion { .. }) + | Err(SecretStoreError::KdfFailure) => {} + Err(other) => panic!("unexpected error variant: {other:?}"), + } + }; + + for i in 0..2_000 { + let mut buf = valid.clone(); + let flips = 1 + (next() % 4) as usize; + for _ in 0..flips { + let idx = (next() as usize) % buf.len(); + buf[idx] ^= (next() & 0xFF) as u8; + } + // None path every iteration (cheap, no derive). + assert_typed(None, &buf); + // Some path on a representative subset (each may derive). + if i % 16 == 0 { + assert_typed(Some(&p), &buf); + } + } + + // Truncation at every offset — a short read must never panic. + for cut in 0..valid.len() { + assert_typed(None, &valid[..cut]); + assert_typed(Some(&p), &valid[..cut]); + } + } + + // TC-040 — proptest: no single-byte flip surfaces the plaintext. + // Minimises to the offset that breaks coverage if one exists. + proptest::proptest! { + #[test] + fn prop_single_byte_flip_never_yields_plaintext( + (offset, mask) in (0usize..200usize, 1u8..=255u8), + ) { + // Re-built per case so the proptest harness can shrink + // independently of the host RNG. + let plaintext: &[u8] = b"goldfinch"; + let p = pw("pw"); + let valid = wrap_with_params(&wid(1), "seed", Some(&p), plaintext, floor()) + .unwrap() + .expose_secret() + .to_vec(); + if offset >= valid.len() { + // Out-of-bounds offset → skip via prop_assume so proptest + // shrinks toward in-bounds offsets. + proptest::prop_assume!(offset < valid.len()); + } + let mut buf = valid.clone(); + buf[offset] ^= mask; + match unwrap(&wid(1), "seed", Some(&p), &buf) { + Ok(secret) => { + proptest::prop_assert_ne!( + secret.expose_secret(), + plaintext, + "single-byte flip at offset {} surfaced the plaintext", + offset + ); + } + Err(_) => { /* any typed error is fine */ } + } + } + } } From ff7e56022e26eed0ece6d29110bd9d349d72b90c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:50:48 +0000 Subject: [PATCH 17/21] refactor(rs-platform-wallet-storage)!: switch over to wire/, drop legacy magic-less path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the new `secrets/wire/` module as the sole producer of the Tier-2 envelope wire bytes and the three AAD constructions. The old hand-rolled `secrets/envelope.rs` (`MAGIC`, `HEADER_LEN`, manual `encode_kdf` / `decode_kdf` / `scheme1_aad`, `warn_legacy_once`, the magic-peek dispatch, and the in-module test block) collapses to a thin ~5-line re-export of `wire::envelope::{wrap, wrap_with_params, unwrap, MAX_PLAINTEXT_LEN, MAX_ENVELOPE_OVERHEAD}`. `store.rs` keeps its public surface unchanged (`set_secret` / `get_secret` / `reprotect` — caller-compat). - `format.rs::aad()` / `verify_aad()` now `bincode::encode_to_vec` an `EntryAad` / `VerifyAad` against `WIRE_CONFIG`. Domain tags `ENTRY_DOMAIN_V2` / `VERIFY_DOMAIN_V2` replace the implicit `VERIFY_WALLET_ID` + `VERIFY_LABEL` disjointness trick. The three AAD tests in `format.rs` are superseded by the eight in `wire/aad.rs`. - `store.rs::run_quadrant` drops the magic-less legacy raw + magic- present-but-truncated branches: raw non-envelope bytes and a truncated envelope now both surface as `Corruption` (no legacy tolerance — A1 + A8 in the design brief). - `store.rs::run_scheme_flip` flips the scheme via bincode decode/mutate/re-encode instead of byte-poking at a magic offset. - `error.rs::ExpectedProtectedButUnsealed` / `UnsupportedEnvelopeVersion` docstrings drop the stale magic-less-raw / magic-byte references. - `wire/mod.rs` drops its scaffolding `#![allow(dead_code)]`; every pub(crate) item now has a real consumer. Caller-side compatibility (TC-041): `cargo check -p platform-wallet -p platform-wallet-ffi --all-targets` is clean — neither crate sees a surface change at the `SecretStore` boundary. `cargo test -p platform-wallet-storage --all-targets` passes (451 tests; the new wire suite adds 40 cases; 3 superseded AAD tests in `format.rs` and the in-module envelope test block are deleted). **Wire-format break**: previously stored Tier-2 envelopes from earlier #3953 commits no longer decode. PR #3953 is already `feat(...)!:`. Refs design-brief.md §2.3, §2.7, §3.3; dev-plan.md T-4. --- .../src/secrets/envelope.rs | 817 +----------------- .../src/secrets/error.rs | 24 +- .../src/secrets/file/format.rs | 138 +-- .../src/secrets/store.rs | 105 +-- .../src/secrets/wire/mod.rs | 4 - 5 files changed, 115 insertions(+), 973 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs index 725d76eccf..0d56a4cb5d 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs @@ -1,807 +1,22 @@ //! Tier-2 opt-in per-object password envelope (backend-independent). //! -//! Sits ABOVE [`SecretStore`](crate::secrets::SecretStore), over both the -//! `File` vault and `Os` keyring arms: the backend stores opaque bytes, -//! and a chosen critical object (a seed wallet, a single privkey) can be -//! wrapped under an extra, user-supplied **object password** before it -//! ever reaches the backend. Reading a protected object then needs BOTH -//! backend access AND the password — the first control that survives a -//! full backend compromise (the keychain scraped, the vault stolen and its -//! passphrase cracked). +//! Sits ABOVE [`SecretStore`](crate::secrets::SecretStore), over both +//! the `File` vault and `Os` keyring arms: the backend stores opaque +//! bytes, and a chosen critical object (a seed wallet, a single +//! privkey) can be wrapped under an extra, user-supplied **object +//! password** before it ever reaches the backend. Reading a protected +//! object then needs BOTH backend access AND the password — the first +//! control that survives a full backend compromise. //! -//! # Wire format (self-describing, authenticated) -//! -//! ```text -//! magic b"PWSEV" (5) -//! version u8 = 1 (ENVELOPE_VERSION — independent of the vault FORMAT_VERSION) -//! scheme u8 (0 = unprotected passthrough, 1 = argon2id-xchacha password) -//! ── scheme 0 ── payload: raw secret bytes -//! ── scheme 1 ── kdf(id u8 ‖ m_kib u32 LE ‖ t u32 LE ‖ p u32 LE) (13) -//! ‖ salt[32] ‖ nonce[24] ‖ ciphertext+tag -//! ``` -//! -//! The header proves what the blob **is**, never what the caller -//! **expected** — that expectation lives solely in the caller's `Some/None` -//! password argument (see [`unwrap`]'s strict, fail-closed table). The -//! self-description is a convenience for `NeedsPassword`/`WrongPassword`/ -//! version UX, **not** the security boundary. -//! -//! ## Reused, never reinvented -//! - KDF: [`crypto::derive_key`] (Argon2id) with a fresh 32-byte salt; the -//! param **ceiling is enforced BEFORE derivation** on the -//! attacker-controllable header ([`KdfParams::enforce_bounds`]). -//! - AEAD: [`crypto::seal`]/[`crypto::open`] (XChaCha20-Poly1305), fresh -//! per-wrap nonce; a tag failure maps to -//! [`SecretStoreError::WrongPassword`] with no plaintext. -//! - AAD binds `domain ‖ magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ wallet_id -//! ‖ label`, mirroring [`format::aad`]/[`format::verify_aad`] so a -//! relocated/confused blob fails the tag. -//! -//! No bespoke crypto. -//! -//! [`format::aad`]: super::file::format::aad -//! [`format::verify_aad`]: super::file::format::verify_aad - -use std::sync::Once; - -use super::error::SecretStoreError; -use super::file::crypto::{self, KdfParams, NONCE_LEN, SALT_LEN}; -use super::secret::{SecretBytes, SecretString}; -use super::validate::WalletId; -use super::MAX_SECRET_LEN; - -/// 5-byte sentinel marking a Tier-2 envelope. A decrypted entry NOT -/// starting with this is a legacy magic-less raw value (see [`unwrap`]). -pub(crate) const MAGIC: &[u8; 5] = b"PWSEV"; - -/// Envelope wire version — bumped only on a breaking layout change, and -/// independent of the vault `FORMAT_VERSION` (the envelope rides inside the -/// entry bytes, identical over File/Os). -pub(crate) const ENVELOPE_VERSION: u8 = 1; - -/// Scheme 0: unprotected passthrough — payload is the raw secret. -pub(crate) const SCHEME_UNPROTECTED: u8 = 0; -/// Scheme 1: Argon2id + XChaCha20-Poly1305 under an object password. -pub(crate) const SCHEME_PASSWORD: u8 = 1; - -/// Domain-separation tag leading the scheme-1 AAD, so a Tier-2 tag can -/// never be confused with the vault's own verify/entry AAD. -const TIER2_DOMAIN: &[u8] = b"PWSEV-TIER2-AAD-v1"; - -/// Fixed header: `magic ‖ version ‖ scheme`. -const HEADER_LEN: usize = MAGIC.len() + 2; -/// Encoded KDF-params field: `id u8 ‖ m_kib u32 ‖ t u32 ‖ p u32`. -const KDF_FIELD_LEN: usize = 1 + 4 + 4 + 4; -/// Poly1305 tag length — present even for empty plaintext. -const AEAD_TAG_LEN: usize = 16; -/// Smallest valid scheme-1 body (kdf ‖ salt ‖ nonce ‖ bare tag). -const MIN_SCHEME1_BODY: usize = KDF_FIELD_LEN + SALT_LEN + NONCE_LEN + AEAD_TAG_LEN; - -/// Fixed, bounded envelope overhead (`magic 5 + version 1 + scheme 1 + kdf -/// 13 + salt 32 + nonce 24 + tag 16 = 92`), rounded up to 128 for headroom -/// (future header fields / versions). Used to derive the plaintext cap. -pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 128; - -/// Plaintext cap at the envelope boundary: `MAX_SECRET_LEN − -/// MAX_ENVELOPE_OVERHEAD`. Capping the **plaintext** (uniformly for both -/// schemes) keeps the user-visible limit stable AND guarantees the -/// enveloped bytes always fit the backend vault's own `MAX_SECRET_LEN` -/// `put_bytes` cap. Re-exported at -/// [`crate::secrets`] as the documented, stable user-facing cap. -pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; - -/// Wrap `plaintext` for `(wallet_id, label)` using the shipped default -/// Argon2 target (64 MiB / t=3) when a password is supplied. -/// -/// `None` → an unprotected (scheme-0) envelope; `Some(pw)` → a scheme-1 -/// envelope sealed under `pw`. A blank password is rejected at enrol -/// ([`SecretStoreError::BlankPassphrase`]). -/// -/// Returns the envelope inside a zeroizing [`SecretBytes`]: a scheme-0 -/// envelope embeds the raw plaintext, so the wire bytes are handled as -/// sensitive (mlock'd, wiped on drop) by construction — symmetric with -/// [`unwrap`]'s return. -pub(crate) fn wrap( - wallet_id: &WalletId, - label: &str, - password: Option<&SecretString>, - plaintext: &[u8], -) -> Result { - wrap_with_params( - wallet_id, - label, - password, - plaintext, - KdfParams::default_target(), - ) -} - -/// [`wrap`] with explicit Argon2 `params` (tests use the floor params for -/// speed; production uses [`KdfParams::default_target`]). `params` is -/// ignored when `password` is `None`. -pub(crate) fn wrap_with_params( - wallet_id: &WalletId, - label: &str, - password: Option<&SecretString>, - plaintext: &[u8], - params: KdfParams, -) -> Result { - // Cap the PLAINTEXT (before overhead) uniformly for both schemes so the - // enveloped bytes always fit the backend cap and the limit is stable. - if plaintext.len() > MAX_PLAINTEXT_LEN { - return Err(SecretStoreError::SecretTooLarge { - found: plaintext.len(), - max: MAX_PLAINTEXT_LEN, - }); - } - - let Some(pw) = password else { - // Scheme 0: magic ‖ version ‖ scheme ‖ raw payload. - let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len()); - out.extend_from_slice(MAGIC); - out.push(ENVELOPE_VERSION); - out.push(SCHEME_UNPROTECTED); - out.extend_from_slice(plaintext); - // `SecretBytes::new` moves `out` into a zeroizing, mlock'd buffer - // (no copy) — the scheme-0 plaintext never lives in a bare Vec. - return Ok(SecretBytes::new(out)); - }; - - // Reject a blank object password BEFORE any derivation. - if pw.is_blank() { - return Err(SecretStoreError::BlankPassphrase); - } +//! The wire format lives in [`super::wire::envelope`]: every byte that +//! crosses the AEAD seam is bincode-encoded against a single +//! `WIRE_CONFIG`, so a future bincode-config drift is caught by the +//! golden-vector tests instead of silently corrupting every stored +//! blob. This module is a thin re-export hub for the call sites in +//! [`super::store`]. - // Fresh per-object salt so the same password on two objects yields - // different keys and precomputation is defeated. - let mut salt = [0u8; SALT_LEN]; - crypto::random_bytes(&mut salt)?; - // `derive_key` enforces the param bounds before allocating. - let key = crypto::derive_key(pw, &salt, params)?; - let aad = scheme1_aad(¶ms, &salt, wallet_id.as_bytes(), label); - let (nonce, ciphertext) = crypto::seal(&key, &aad, plaintext)?; - - let mut out = - Vec::with_capacity(HEADER_LEN + KDF_FIELD_LEN + SALT_LEN + NONCE_LEN + ciphertext.len()); - out.extend_from_slice(MAGIC); - out.push(ENVELOPE_VERSION); - out.push(SCHEME_PASSWORD); - out.extend_from_slice(&encode_kdf(¶ms)); - out.extend_from_slice(&salt); - out.extend_from_slice(&nonce); - out.extend_from_slice(&ciphertext); - Ok(SecretBytes::new(out)) -} - -/// Unwrap `blob` for `(wallet_id, label)`, applying the **strict, -/// fail-closed** read. The "expected-protected" bit is -/// the caller's assertion, surfaced solely by `password`, and is NEVER -/// inferred from the blob's scheme byte. -/// -/// | `password` | stored blob | result | -/// |---|---|---| -/// | `Some(pw)` | valid scheme-1 | secret, or [`WrongPassword`] on tag fail | -/// | `Some(pw)` | scheme-0 **or** magic-less (legacy raw) | [`ExpectedProtectedButUnsealed`] | -/// | `Some(pw)` | scheme-1 but too short | [`Corruption`] (sealed-but-broken) | -/// | `Some/None` | magic present, unknown version/scheme | [`UnsupportedEnvelopeVersion`] | -/// | `None` | valid scheme-1 | [`NeedsPassword`] (never ciphertext) | -/// | `None` | scheme-0 | secret | -/// | `None` | magic-less (legacy raw) | secret (+ one-time warn; re-wrapped on next write) | -/// | `None` | magic present but truncated header | [`Corruption`] | -/// -/// The load-bearing row is `Some(pw)` + non-envelope ⇒ -/// [`ExpectedProtectedButUnsealed`]: with a password in hand, a -/// non-protected blob can only mean a strip → refuse, return no bytes. -/// -/// [`WrongPassword`]: SecretStoreError::WrongPassword -/// [`ExpectedProtectedButUnsealed`]: SecretStoreError::ExpectedProtectedButUnsealed -/// [`Corruption`]: SecretStoreError::Corruption -/// [`UnsupportedEnvelopeVersion`]: SecretStoreError::UnsupportedEnvelopeVersion -/// [`NeedsPassword`]: SecretStoreError::NeedsPassword -pub(crate) fn unwrap( - wallet_id: &WalletId, - label: &str, - password: Option<&SecretString>, - blob: &[u8], -) -> Result { - // Magic-less ⇒ a legacy unprotected raw value (scheme-0-equivalent), - // (legacy-tolerant read-path: a None read returns it, a Some(pw) read refuses). - if !blob.starts_with(MAGIC) { - return match password { - None => { - warn_legacy_once(); - Ok(SecretBytes::from_slice(blob)) - } - // Caller asserted protection but found a magic-less raw value: - // a strip/downgrade ⇒ FAIL CLOSED. Never returns bytes. - Some(_) => Err(SecretStoreError::ExpectedProtectedButUnsealed), - }; - } - - // Magic present but truncated before version+scheme: a broken envelope. - if blob.len() < HEADER_LEN { - return Err(SecretStoreError::Corruption); - } - - let version = blob[MAGIC.len()]; - if version != ENVELOPE_VERSION { - // Fail closed regardless of password — an unparseable future format - // can be neither safely unwrapped nor treated as scheme-0. - return Err(SecretStoreError::UnsupportedEnvelopeVersion { found: version }); - } - - let scheme = blob[MAGIC.len() + 1]; - let body = &blob[HEADER_LEN..]; - match scheme { - SCHEME_UNPROTECTED => match password { - None => Ok(SecretBytes::from_slice(body)), - // Strip: caller expected protection, blob is unprotected. - Some(_) => Err(SecretStoreError::ExpectedProtectedButUnsealed), - }, - SCHEME_PASSWORD => match password { - None => Err(SecretStoreError::NeedsPassword), - Some(pw) => unwrap_scheme1(wallet_id, label, pw, body), - }, - // Unknown scheme under a known version ⇒ forward-incompatible - // layout; report the (known) version byte. Fail closed. - _ => Err(SecretStoreError::UnsupportedEnvelopeVersion { found: version }), - } -} - -/// Decrypt a scheme-1 body. The KDF params, salt, and nonce are all read -/// from the (attacker-controllable) header; the param **ceiling is -/// enforced before** [`crypto::derive_key`] allocates, and every -/// header field that feeds key/AAD is bound into the AAD so any in-place -/// edit fails the tag. -fn unwrap_scheme1( - wallet_id: &WalletId, - label: &str, - password: &SecretString, - body: &[u8], -) -> Result { - if body.len() < MIN_SCHEME1_BODY { - // The scheme byte says protected, but the body cannot hold a sealed - // payload — corrupt, not a strip. - return Err(SecretStoreError::Corruption); - } - let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); - // Gate the inflated/unknown header BEFORE any derivation/alloc. - kdf.enforce_bounds()?; - - let mut salt = [0u8; SALT_LEN]; - salt.copy_from_slice(&body[KDF_FIELD_LEN..KDF_FIELD_LEN + SALT_LEN]); - let mut nonce = [0u8; NONCE_LEN]; - nonce.copy_from_slice(&body[KDF_FIELD_LEN + SALT_LEN..KDF_FIELD_LEN + SALT_LEN + NONCE_LEN]); - let ciphertext = &body[KDF_FIELD_LEN + SALT_LEN + NONCE_LEN..]; - - let aad = scheme1_aad(&kdf, &salt, wallet_id.as_bytes(), label); - let key = crypto::derive_key(password, &salt, kdf)?; - match crypto::open(&key, &nonce, &aad, ciphertext) { - Ok(plaintext) => Ok(plaintext), - // Tag failure (wrong password, relocated blob, or header tamper): - // no plaintext is ever materialized (CWE-347). - Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassword), - Err(e) => Err(e), - } -} - -/// Build the scheme-1 AAD binding object identity + header, -/// length-prefixed for the variable fields, mirroring -/// [`format::aad`](super::file::format::aad)/`verify_aad`. -fn scheme1_aad( - kdf: &KdfParams, - salt: &[u8; SALT_LEN], - wallet_id: &[u8; 32], - label: &str, -) -> Vec { - let lb = label.as_bytes(); - let mut v = Vec::with_capacity( - TIER2_DOMAIN.len() - + MAGIC.len() - + 2 - + KDF_FIELD_LEN - + 4 - + SALT_LEN - + 4 - + wallet_id.len() - + 4 - + lb.len(), - ); - v.extend_from_slice(TIER2_DOMAIN); - v.extend_from_slice(MAGIC); - v.push(ENVELOPE_VERSION); - v.push(SCHEME_PASSWORD); - v.extend_from_slice(&encode_kdf(kdf)); - v.extend_from_slice(&(salt.len() as u32).to_le_bytes()); - v.extend_from_slice(salt); - v.extend_from_slice(&(wallet_id.len() as u32).to_le_bytes()); - v.extend_from_slice(wallet_id); - v.extend_from_slice(&(lb.len() as u32).to_le_bytes()); - v.extend_from_slice(lb); - v -} - -/// Encode KDF params to the fixed 13-byte header field (LE). -fn encode_kdf(kdf: &KdfParams) -> [u8; KDF_FIELD_LEN] { - let mut out = [0u8; KDF_FIELD_LEN]; - out[0] = kdf.id; - out[1..5].copy_from_slice(&kdf.m_kib.to_le_bytes()); - out[5..9].copy_from_slice(&kdf.t.to_le_bytes()); - out[9..13].copy_from_slice(&kdf.p.to_le_bytes()); - out -} - -/// Decode the fixed 13-byte KDF header field. Out-of-range values are -/// caught downstream by [`KdfParams::enforce_bounds`]. -fn decode_kdf(b: &[u8]) -> KdfParams { - debug_assert_eq!(b.len(), KDF_FIELD_LEN); - KdfParams { - id: b[0], - m_kib: u32::from_le_bytes([b[1], b[2], b[3], b[4]]), - t: u32::from_le_bytes([b[5], b[6], b[7], b[8]]), - p: u32::from_le_bytes([b[9], b[10], b[11], b[12]]), - } -} - -/// Emit a single process-lifetime warning that a legacy magic-less entry -/// was read. Carries no secret (the message is static). -fn warn_legacy_once() { - static WARN: Once = Once::new(); - WARN.call_once(|| { - tracing::warn!( - "read a legacy unprotected secret entry with no envelope header; \ - it will be re-wrapped on the next write" - ); - }); -} +pub use super::wire::envelope::MAX_PLAINTEXT_LEN; +pub(crate) use super::wire::envelope::{unwrap, wrap, MAX_ENVELOPE_OVERHEAD}; #[cfg(test)] -mod tests { - use subtle::ConstantTimeEq; - - use super::super::file::crypto::{ - ARGON2_MAX_M_KIB, ARGON2_MAX_T, ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P, - }; - use super::super::file::format::KDF_ID_ARGON2ID; - use super::*; - - // Wire offsets into a scheme-1 envelope (for surgical tampering). - const O_VERSION: usize = 5; - const O_SCHEME: usize = 6; - const O_KDF: usize = HEADER_LEN; // 7 - const O_ID: usize = O_KDF; // 7 - const O_MKIB: usize = O_KDF + 1; // 8 - const O_T: usize = O_KDF + 5; // 12 - const O_SALT: usize = O_KDF + KDF_FIELD_LEN; // 20 - const O_NONCE: usize = O_SALT + SALT_LEN; // 52 - - fn wid(b: u8) -> WalletId { - WalletId::from([b; 32]) - } - - /// Argon2id floor params — fast enough for unit tests. - fn floor() -> KdfParams { - KdfParams { - id: KDF_ID_ARGON2ID, - m_kib: ARGON2_MIN_M_KIB, - t: ARGON2_MIN_T, - p: ARGON2_P, - } - } - - fn pw(s: &str) -> SecretString { - SecretString::new(s) - } - - /// Wrap and expose the envelope as a `Vec` for byte-level - /// inspection/mutation in tests (the production `wrap` returns a - /// zeroizing `SecretBytes`). - fn wrap_bytes( - w: &WalletId, - label: &str, - password: Option<&SecretString>, - pt: &[u8], - ) -> Vec { - wrap(w, label, password, pt) - .unwrap() - .expose_secret() - .to_vec() - } - - /// [`wrap_bytes`] with explicit (floor) params, for the scheme-1 tests. - fn wrap_p( - w: &WalletId, - label: &str, - password: Option<&SecretString>, - pt: &[u8], - params: KdfParams, - ) -> Vec { - wrap_with_params(w, label, password, pt, params) - .unwrap() - .expose_secret() - .to_vec() - } - - /// scheme-0 passthrough round-trip; the wrapped form leads - /// with magic, version=1, scheme=0, then the raw payload. - #[test] - fn scheme0_passthrough_round_trip() { - let secret = b"top secret seed bytes"; - let blob = wrap_bytes(&wid(1), "seed", None, secret); - assert!(blob.starts_with(MAGIC)); - assert_eq!(blob[O_VERSION], 1); - assert_eq!(blob[O_SCHEME], 0); - assert_eq!(&blob[HEADER_LEN..], secret); - let got = unwrap(&wid(1), "seed", None, &blob).unwrap(); - assert_eq!(got.expose_secret(), secret); - } - - /// scheme-1 round-trip; header records the argon2id id, a - /// 32-byte fresh salt and 24-byte nonce, ct != pt, and two wraps of the - /// same secret/pw differ in salt+nonce (no reuse). - #[test] - fn scheme1_round_trip_and_fresh_salt_nonce() { - let secret = b"correct horse battery staple seed"; - let p = pw("hunter2-but-better"); - let blob = wrap_p(&wid(7), "seed", Some(&p), secret, floor()); - assert!(blob.starts_with(MAGIC)); - assert_eq!(blob[O_VERSION], 1); - assert_eq!(blob[O_SCHEME], 1); - assert_eq!(blob[O_ID], KDF_ID_ARGON2ID); - // ciphertext differs from plaintext. - assert_ne!(&blob[O_NONCE + NONCE_LEN..], secret); - - let got = unwrap(&wid(7), "seed", Some(&p), &blob).unwrap(); - assert_eq!(got.expose_secret(), secret); - - let blob2 = wrap_p(&wid(7), "seed", Some(&p), secret, floor()); - assert_ne!( - &blob[O_SALT..O_SALT + SALT_LEN], - &blob2[O_SALT..O_SALT + SALT_LEN], - "salt must be fresh per wrap" - ); - assert_ne!( - &blob[O_NONCE..O_NONCE + NONCE_LEN], - &blob2[O_NONCE..O_NONCE + NONCE_LEN], - "nonce must be fresh per wrap" - ); - } - - /// Wrong object password → WrongPassword, no plaintext. - #[test] - fn wrong_password_fails_closed() { - let blob = wrap_p(&wid(1), "seed", Some(&pw("right")), b"seed", floor()); - let err = unwrap(&wid(1), "seed", Some(&pw("wrong")), &blob).unwrap_err(); - assert!( - matches!(err, SecretStoreError::WrongPassword), - "got {err:?}" - ); - } - - /// Identity AAD — a protected blob unwrapped at any - /// other (wallet, label) fails the tag; same-identity still succeeds. - #[test] - fn relocation_across_identity_is_rejected() { - let p = pw("pw"); - let blob = wrap_p(&wid(0xA), "labelA", Some(&p), b"seed", floor()); - for (w, l) in [(0xB, "labelB"), (0xA, "labelB"), (0xB, "labelA")] { - let err = unwrap(&wid(w), l, Some(&p), &blob).unwrap_err(); - assert!( - matches!(err, SecretStoreError::WrongPassword), - "relocation to ({w:#x},{l}) must fail, got {err:?}" - ); - } - let ok = unwrap(&wid(0xA), "labelA", Some(&p), &blob).unwrap(); - assert_eq!(ok.expose_secret(), b"seed"); - } - - /// Per-field header tamper. Unknown KDF id is rejected by - /// `enforce_bounds` (KdfFailure) before derive; in-bounds KDF shifts, - /// salt, and nonce all fail the AEAD tag (WrongPassword) — never the - /// plaintext. - #[test] - fn header_tamper_fails_closed_per_field() { - let p = pw("pw"); - let base = wrap_p(&wid(1), "seed", Some(&p), b"seed", floor()); - - // kdf.id → 7 (unknown) ⇒ KdfFailure (bounds reject pre-derive). - let mut b = base.clone(); - b[O_ID] = 7; - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), - SecretStoreError::KdfFailure - )); - - // kdf.m_kib → a different IN-BOUNDS value ⇒ WrongPassword (AAD + key). - let mut b = base.clone(); - b[O_MKIB..O_MKIB + 4].copy_from_slice(&(ARGON2_MIN_M_KIB + 1024).to_le_bytes()); - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), - SecretStoreError::WrongPassword - )); - - // kdf.t → a different IN-BOUNDS value ⇒ WrongPassword. - let mut b = base.clone(); - b[O_T..O_T + 4].copy_from_slice(&(ARGON2_MIN_T + 1).to_le_bytes()); - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), - SecretStoreError::WrongPassword - )); - - // salt[0] flip ⇒ WrongPassword (wrong key + AAD-bound salt). - let mut b = base.clone(); - b[O_SALT] ^= 1; - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), - SecretStoreError::WrongPassword - )); - - // nonce[0] flip ⇒ WrongPassword (nonce feeds decrypt ⇒ tag fail). - let mut b = base; - b[O_NONCE] ^= 1; - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), - SecretStoreError::WrongPassword - )); - } - - /// An inflated KDF param on a forged header is - /// rejected by `enforce_bounds` BEFORE `derive_key` allocates — the - /// ~4 TiB allocation never happens (the test would OOM if it did). The - /// exact ceilings remain valid params. - #[test] - fn kdf_ceiling_enforced_before_derivation() { - let p = pw("pw"); - let base = wrap_p(&wid(1), "seed", Some(&p), b"seed", floor()); - - // m_kib = u32::MAX ⇒ KdfFailure, no allocation. - let mut b = base.clone(); - b[O_MKIB..O_MKIB + 4].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), - SecretStoreError::KdfFailure - )); - - // t = ARGON2_MAX_T + 1 ⇒ KdfFailure. - let mut b = base; - b[O_T..O_T + 4].copy_from_slice(&(ARGON2_MAX_T + 1).to_le_bytes()); - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &b).unwrap_err(), - SecretStoreError::KdfFailure - )); - - // The exact ceilings are accepted by the bounds check (no derive - // here — a 1 GiB Argon2 run is not a unit-test concern). - assert!(KdfParams { - id: KDF_ID_ARGON2ID, - m_kib: ARGON2_MAX_M_KIB, - t: ARGON2_MAX_T, - p: ARGON2_P, - } - .enforce_bounds() - .is_ok()); - } - - /// A blank object password is rejected at enrol; nothing - /// is sealed. - #[test] - fn blank_object_password_rejected_at_enrol() { - for blank in [SecretString::empty(), pw(""), pw(" "), pw("\t\n")] { - let err = - wrap_with_params(&wid(1), "seed", Some(&blank), b"seed", floor()).unwrap_err(); - assert!( - matches!(err, SecretStoreError::BlankPassphrase), - "got {err:?}" - ); - } - } - - /// The plaintext is capped at `MAX_PLAINTEXT_LEN` (`MAX_SECRET_LEN − - /// MAX_ENVELOPE_OVERHEAD`), uniform across schemes, so plaintext + - /// overhead always fits the backend's own `MAX_SECRET_LEN` cap. Accept - /// at the cap, reject at cap+1 with `max = MAX_PLAINTEXT_LEN`. - #[test] - fn plaintext_size_cap_at_envelope_boundary() { - let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; - let over = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1]; - - // Unprotected (scheme 0): cap accepted, +1 rejected. - assert!(wrap(&wid(1), "seed", None, &at_cap).is_ok()); - assert!(matches!( - wrap(&wid(1), "seed", None, &over).unwrap_err(), - SecretStoreError::SecretTooLarge { found, max } - if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN - )); - - // Protected (scheme 1): same cap (checked before any derivation). - let p = pw("pw"); - assert!(matches!( - wrap_with_params(&wid(1), "seed", Some(&p), &over, floor()).unwrap_err(), - SecretStoreError::SecretTooLarge { found, max } - if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN - )); - // The enveloped bytes for an at-cap plaintext fit the backend cap. - let enveloped = wrap(&wid(1), "seed", None, &at_cap).unwrap(); - assert!(enveloped.len() <= MAX_SECRET_LEN); - } - - /// Scheme-1 accepts a plaintext of EXACTLY `MAX_PLAINTEXT_LEN` (the - /// accept boundary), round-trips it, and the enveloped bytes still fit - /// the backend's `MAX_SECRET_LEN` cap. - #[test] - fn scheme1_accepts_plaintext_at_exact_cap() { - let p = pw("pw"); - let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; - let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap(); - assert!( - blob.len() <= MAX_SECRET_LEN, - "enveloped bytes exceed backend cap" - ); - let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); - assert_eq!(got.expose_secret(), &pt[..]); - } - - /// Value rollback is intentionally NOT defended: an older valid scheme-1 - /// envelope still decrypts cleanly under the current password. Pinned so - /// a future reader does not mistake the strict read for rollback - /// protection (anti-rollback would need a monotonic anchor in the - /// consumer's integrity-protected metadata). - #[test] - fn value_rollback_is_not_defended() { - let p = pw("pw"); - let old_blob = wrap_with_params(&wid(1), "seed", Some(&p), b"OLD-VALUE", floor()).unwrap(); - // A newer value is written under the same identity + password … - let _new_blob = wrap_with_params(&wid(1), "seed", Some(&p), b"NEW-VALUE", floor()).unwrap(); - // … yet "restoring" the OLD envelope still decrypts cleanly. - let restored = unwrap(&wid(1), "seed", Some(&p), old_blob.expose_secret()).unwrap(); - assert_eq!( - restored.expose_secret(), - b"OLD-VALUE", - "older envelope still decrypts: value rollback is a known, undefended residual" - ); - } - - /// magic/version discrimination: a magic-less blob is a legacy raw - /// value — returned on a `None` read (with a one-time warning), refused - /// fail-closed on `Some(pw)` so the strict rule holds. A magic-present - /// blob with an unknown version fails closed both ways; truncated- - /// after-magic is corruption. - #[test] - fn magic_and_version_discrimination() { - let p = pw("pw"); - // (a) Magic-less / wrong magic. - let legacy = b"NOTPWSEV raw legacy seed bytes".to_vec(); - // None ⇒ legacy raw bytes (adopted contingency; NOT Corruption). - let got = unwrap(&wid(1), "seed", None, &legacy).unwrap(); - assert_eq!(got.expose_secret(), &legacy[..]); - // Some(pw) ⇒ strip/downgrade ⇒ fail closed. - assert!(matches!( - unwrap(&wid(1), "seed", Some(&p), &legacy).unwrap_err(), - SecretStoreError::ExpectedProtectedButUnsealed - )); - - // (b) Magic present but truncated below the header ⇒ Corruption. - let mut trunc = MAGIC.to_vec(); - trunc.push(ENVELOPE_VERSION); // no scheme byte - assert!(matches!( - unwrap(&wid(1), "seed", None, &trunc).unwrap_err(), - SecretStoreError::Corruption - )); - - // (c) Magic OK but version = 2 ⇒ UnsupportedEnvelopeVersion{2}, - // regardless of password. - let mut v2 = wrap_bytes(&wid(1), "seed", None, b"x"); - v2[O_VERSION] = 2; - for arg in [None, Some(&p)] { - assert!(matches!( - unwrap(&wid(1), "seed", arg, &v2).unwrap_err(), - SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } - )); - } - - // (d) Magic+version OK but unknown scheme = 9 ⇒ fail closed. - let mut s9 = wrap_bytes(&wid(1), "seed", None, b"x"); - s9[O_SCHEME] = 9; - assert!(matches!( - unwrap(&wid(1), "seed", None, &s9).unwrap_err(), - SecretStoreError::UnsupportedEnvelopeVersion { found: 1 } - )); - } - - /// Non-vacuity helper for the strict read (used here and by the store - /// tests): a scheme-0 blob carrying `secret` DOES decode under `None`. - #[test] - fn scheme0_some_password_fails_closed_strip() { - let blob = wrap_bytes(&wid(1), "seed", None, b"attacker-seed"); - // None ⇒ it WOULD decode to the (attacker) bytes… - assert_eq!( - unwrap(&wid(1), "seed", None, &blob) - .unwrap() - .expose_secret(), - b"attacker-seed" - ); - // …but Some(pw) ⇒ ExpectedProtectedButUnsealed, no bytes. - assert!(matches!( - unwrap(&wid(1), "seed", Some(&pw("pw")), &blob).unwrap_err(), - SecretStoreError::ExpectedProtectedButUnsealed - )); - } - - /// `ct_eq` sanity: a round-tripped secret matches the original under a - /// constant-time compare (no `==` on secret bytes). - #[test] - fn round_trip_is_constant_time_equal() { - let p = pw("pw"); - let original = SecretBytes::from_slice(b"seed material"); - let blob = wrap_p(&wid(1), "seed", Some(&p), original.expose_secret(), floor()); - let got = unwrap(&wid(1), "seed", Some(&p), &blob).unwrap(); - assert!(bool::from(got.ct_eq(&original))); - } - - /// Deterministic byte-level fuzz. Every mutant unwrap is a - /// clean `Ok` or a TYPED `SecretStoreError` — never a panic, never - /// plaintext from a tag-failing branch. The `None` path (no Argon2 - /// derivation) runs the full 2000 mutants + every truncation; the - /// `Some(pw)` path — each mutant of which may trigger a real Argon2 - /// derive — runs a representative subset so the suite stays fast while - /// still exercising the derive/open code path. - #[test] - fn fuzz_byte_mutation_never_panics() { - let p = pw("fuzz-pw"); - let valid = wrap_p(&wid(0xAB), "seed", Some(&p), b"seed-bytes", floor()); - // The pristine envelope unwraps. - assert_eq!( - unwrap(&wid(0xAB), "seed", Some(&p), &valid) - .unwrap() - .expose_secret(), - b"seed-bytes" - ); - - // xorshift32 — deterministic, std-only. - let mut state: u32 = 0x9E37_79B9; - let mut next = || { - state ^= state << 13; - state ^= state >> 17; - state ^= state << 5; - state - }; - - let assert_typed = |arg: Option<&SecretString>, buf: &[u8]| { - let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - unwrap(&wid(0xAB), "seed", arg, buf) - })) - .expect("unwrap must never panic on hostile input"); - match res { - Ok(_) - | Err(SecretStoreError::Corruption) - | Err(SecretStoreError::WrongPassword) - | Err(SecretStoreError::NeedsPassword) - | Err(SecretStoreError::ExpectedProtectedButUnsealed) - | Err(SecretStoreError::UnsupportedEnvelopeVersion { .. }) - | Err(SecretStoreError::KdfFailure) => {} - Err(other) => panic!("unexpected error variant: {other:?}"), - } - }; - - for i in 0..2_000 { - let mut buf = valid.clone(); - let flips = 1 + (next() % 4) as usize; - for _ in 0..flips { - let idx = (next() as usize) % buf.len(); - buf[idx] ^= (next() & 0xFF) as u8; - } - // None path every iteration (cheap, no derive). - assert_typed(None, &buf); - // Some path on a representative subset (each may derive Argon2). - if i % 16 == 0 { - assert_typed(Some(&p), &buf); - } - } - - // Truncation at every offset — a short read must never panic. - for cut in 0..valid.len() { - assert_typed(None, &valid[..cut]); - assert_typed(Some(&p), &valid[..cut]); - } - } -} +pub(crate) use super::wire::envelope::wrap_with_params; diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs index 6c69e9e8b9..16917bab7b 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/error.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs @@ -20,11 +20,11 @@ pub enum SecretStoreError { #[error("wrong passphrase")] WrongPassphrase, - /// Tier-2 strip/downgrade guard: the caller asserted — by supplying an object - /// password — that this object MUST be password-protected, but the - /// stored value is a well-formed UNPROTECTED envelope (scheme-0) or a - /// legacy magic-less raw value, i.e. a strip/downgrade. **Fails - /// closed:** the stored bytes are NEVER returned (CWE-757/CWE-345). + /// Tier-2 strip/downgrade guard: the caller asserted — by supplying + /// an object password — that this object MUST be password-protected, + /// but the stored value is a well-formed UNPROTECTED envelope + /// (scheme-0), i.e. a strip/downgrade. **Fails closed:** the stored + /// bytes are NEVER returned (CWE-757/CWE-345). #[error("expected a password-protected secret but the stored value is unprotected")] ExpectedProtectedButUnsealed, @@ -74,19 +74,17 @@ pub enum SecretStoreError { found: u32, }, - /// A Tier-2 secret envelope carried the magic but a `version` (or, at a - /// known version, a `scheme`) this build does not understand. Fails - /// closed REGARDLESS of the password argument — an unparseable future - /// format can be neither safely unwrapped nor safely treated as - /// unprotected, so it is refused both ways. Mirrors - /// [`VersionUnsupported`] for the vault format. + /// A Tier-2 secret envelope decoded with a `version` this build does + /// not understand. Fails closed REGARDLESS of the password argument + /// — an unparseable future format can be neither safely unwrapped + /// nor safely treated as unprotected, so it is refused both ways. + /// Mirrors [`VersionUnsupported`] for the vault format. /// /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported #[error("unsupported secret envelope version {found}")] UnsupportedEnvelopeVersion { /// The envelope `version` byte read from the (unauthenticated) - /// header. An unknown `scheme` under a known version reports the - /// known version byte (a forward-incompatible scheme). + /// header. found: u8, }, diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs index bb6d90769e..c137afb243 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs @@ -37,6 +37,9 @@ use serde::{Deserialize, Serialize}; use super::crypto::{KdfParams, NONCE_LEN, SALT_LEN}; use crate::secrets::error::SecretStoreError; +use crate::secrets::wire::aad::{EntryAad, VerifyAad}; +use crate::secrets::wire::config::{ENTRY_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG}; +use crate::secrets::wire::kdf::KdfParamsEncoded; pub(crate) const FORMAT_VERSION: u32 = 1; pub(crate) const KDF_ID_ARGON2ID: u8 = 1; @@ -46,17 +49,6 @@ pub(crate) const KDF_ID_ARGON2ID: u8 = 1; /// value itself is not secret. pub(crate) const VERIFY_CONSTANT: &[u8] = b"PWSVAULT-VERIFY-v1"; -/// AAD slot label for the verification token. The leading NUL keeps it -/// disjoint from every allowlisted entry label, so the token can never -/// alias a real entry's AAD. -pub(crate) const VERIFY_LABEL: &str = "\0verify"; - -/// Sentinel wallet id for the verify-token AAD slot. Keeps the AAD shape -/// identical to entry AAD without aliasing a real wallet: even a real -/// `[0u8; 32]` id yields a different AAD because [`VERIFY_LABEL`] differs -/// from any allowlisted label. -const VERIFY_WALLET_ID: [u8; 32] = [0u8; 32]; - /// Minimum AEAD ciphertext length: the Poly1305 tag is always present /// even for an empty plaintext, so any `verify_ct`/`ciphertext` shorter /// than this is structurally impossible and rejected. @@ -96,43 +88,43 @@ pub(crate) struct EntryBody { pub ciphertext: Vec, } -/// Canonical length-prefixed AAD binding ciphertext to its slot: -/// `format_version ‖ wallet_id ‖ label`. A blob moved to another slot, or -/// a rolled-back `format_version`, fails the tag. +/// Canonical AAD binding a vault entry's ciphertext to its slot: +/// `domain ‖ format_version ‖ wallet_id ‖ label`, bincode-encoded +/// against [`WIRE_CONFIG`]. A blob moved to another slot, or one +/// version-rolled-back, fails the tag. /// /// Determinism invariant: AAD is built solely from this typed triple, /// never from serialized JSON bytes or key order. `format_version` is -/// always the compiled-in [`FORMAT_VERSION`]; the JSON `version` field is -/// a dispatch gate only and is never routed into AAD. +/// always the compiled-in [`FORMAT_VERSION`]; the JSON `version` field +/// is a dispatch gate only and is never routed into AAD. pub(crate) fn aad(format_version: u32, wallet_id: &[u8; 32], label: &str) -> Vec { - let lb = label.as_bytes(); - let mut v = Vec::with_capacity(4 + 4 + 32 + 4 + lb.len()); - v.extend_from_slice(&format_version.to_le_bytes()); - v.extend_from_slice(&(wallet_id.len() as u32).to_le_bytes()); - v.extend_from_slice(wallet_id); - v.extend_from_slice(&(lb.len() as u32).to_le_bytes()); - v.extend_from_slice(lb); - v + bincode::encode_to_vec( + EntryAad { + domain: ENTRY_DOMAIN_V2, + format_version, + wallet_id: *wallet_id, + label, + }, + WIRE_CONFIG, + ) + .expect("EntryAad encode is infallible") } -/// AAD for the verify-token. Reuses the entry-AAD construction (sentinel -/// wallet id + NUL-prefixed [`VERIFY_LABEL`], disjoint from any real -/// slot), then binds the KDF header: `salt` plus a length-prefixed LE -/// encoding of (`id`, `m_kib`, `t`, `p`). -/// -/// Folding the header in makes the token authenticate the salt + KDF -/// params it was derived under, so header tamper / KDF downgrade is -/// detected fail-closed (it surfaces as `WrongPassphrase` because a -/// tampered header also yields a different derived key). +/// AAD for the verify-token: bincode-encoded `VerifyAad` binding the +/// vault-wide salt + KDF header against the verify domain tag. A +/// tampered header yields a different AAD AND a different derived key, +/// so the token surfaces `WrongPassphrase`. pub(crate) fn verify_aad(format_version: u32, salt: &[u8; SALT_LEN], kdf: &KdfParams) -> Vec { - let mut v = aad(format_version, &VERIFY_WALLET_ID, VERIFY_LABEL); - v.extend_from_slice(&(salt.len() as u32).to_le_bytes()); - v.extend_from_slice(salt); - v.extend_from_slice(&[kdf.id]); - v.extend_from_slice(&kdf.m_kib.to_le_bytes()); - v.extend_from_slice(&kdf.t.to_le_bytes()); - v.extend_from_slice(&kdf.p.to_le_bytes()); - v + bincode::encode_to_vec( + VerifyAad { + domain: VERIFY_DOMAIN_V2, + format_version, + salt: *salt, + kdf: KdfParamsEncoded::from(*kdf), + }, + WIRE_CONFIG, + ) + .expect("VerifyAad encode is infallible") } /// Serde helpers encoding `Vec` as lowercase hex. Hex is already a @@ -249,70 +241,6 @@ pub(crate) fn deserialize(buf: &[u8]) -> Result { mod tests { use super::*; - #[test] - fn aad_binds_slot() { - let w = [1u8; 32]; - assert_ne!(aad(1, &w, "a"), aad(1, &w, "b")); - assert_ne!(aad(1, &w, "a"), aad(2, &w, "a")); - assert_ne!(aad(1, &w, "a"), aad(1, &[2u8; 32], "a")); - // Length-prefix defeats `"a"+"bc"` vs `"ab"+"c"` ambiguity. - assert_ne!(aad(1, &w, "ab"), { - let mut v = aad(1, &w, "a"); - v.extend_from_slice(b"b"); - v - }); - } - - #[test] - fn verify_aad_disjoint_from_every_entry_aad() { - // VERIFY_LABEL starts with NUL (allowlist-forbidden), so no real - // entry's AAD can collide — even on the all-zero wallet id. - let salt = [7u8; SALT_LEN]; - let kdf = KdfParams::default_target(); - let v = verify_aad(FORMAT_VERSION, &salt, &kdf); - assert_ne!(v, aad(FORMAT_VERSION, &VERIFY_WALLET_ID, "seed")); - assert_ne!(v, aad(FORMAT_VERSION, &[1u8; 32], "seed")); - } - - #[test] - fn verify_aad_binds_salt_and_kdf_params() { - // The verify-token AAD authenticates the salt + KDF header, so a - // flipped salt or an in-bounds KDF-param shift yields a different - // AAD (and hence a token-tag failure at verify). - let salt = [7u8; SALT_LEN]; - let kdf = KdfParams::default_target(); - let base = verify_aad(FORMAT_VERSION, &salt, &kdf); - - let mut salt2 = salt; - salt2[0] ^= 0x01; - assert_ne!(base, verify_aad(FORMAT_VERSION, &salt2, &kdf)); - - assert_ne!( - base, - verify_aad( - FORMAT_VERSION, - &salt, - &KdfParams { - m_kib: kdf.m_kib / 2, - ..kdf - } - ) - ); - assert_ne!( - base, - verify_aad( - FORMAT_VERSION, - &salt, - &KdfParams { - t: kdf.t - 1, - ..kdf - } - ) - ); - // Identical inputs are deterministic. - assert_eq!(base, verify_aad(FORMAT_VERSION, &salt, &kdf)); - } - fn test_vault(wallets: BTreeMap>) -> Vault { Vault { version: FORMAT_VERSION, diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 0ff9e393a9..a736fa54b1 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -145,12 +145,13 @@ impl SecretStore { self.get_secret(service, label, None) } - /// Read the opaque bytes stored under `(service, label)`, or `Ok(None)` - /// if absent — the raw backend value (a Tier-2 envelope once writes go - /// through [`set_secret`](SecretStore::set_secret), or a legacy raw - /// value). The typed-vs-SPI distinction is preserved exactly as the - /// pre-Tier-2 path did. This is the shared seam under [`get`] and - /// [`get_secret`]; it does NOT interpret the envelope. + /// Read the opaque bytes stored under `(service, label)`, or + /// `Ok(None)` if absent — the raw backend value, always a Tier-2 + /// envelope (writes go through + /// [`set_secret`](SecretStore::set_secret)). The typed-vs-SPI + /// distinction is preserved exactly as the pre-Tier-2 path did. This + /// is the shared seam under [`get`] and [`get_secret`]; it does NOT + /// interpret the envelope. /// /// [`get`]: SecretStore::get fn get_raw( @@ -199,12 +200,12 @@ impl SecretStore { /// /// - `Some(pw)` + a protected blob → the secret (or /// [`WrongPassword`](SecretStoreError::WrongPassword) on tag fail); - /// - `Some(pw)` + a non-protected blob (unprotected / legacy raw) → + /// - `Some(pw)` + an unprotected blob → /// [`ExpectedProtectedButUnsealed`](SecretStoreError::ExpectedProtectedButUnsealed) /// — a strip/downgrade, refused, no bytes returned; /// - `None` + a protected blob → /// [`NeedsPassword`](SecretStoreError::NeedsPassword) (never ciphertext); - /// - `None` + an unprotected / legacy raw blob → the secret. + /// - `None` + an unprotected blob → the secret. /// /// **Documented residual:** an attacker who ALSO rewrites the /// consumer's trusted DB so the caller passes `None` for a stripped @@ -687,40 +688,35 @@ mod tests { b.name ); - // magic-present-but-truncated + None → Corruption. - let mut trunc = envelope::MAGIC.to_vec(); - trunc.push(envelope::ENVELOPE_VERSION); // no scheme byte - b.place_raw(&w, "broken", &trunc); - assert!( - matches!( - b.store.get_secret(&w, "broken", None).unwrap_err(), - SecretStoreError::Corruption - ), - "[{}] truncated-with-magic + None", - b.name - ); + // Truncated envelope (below the bincode minimum) → Corruption, + // both with and without a password — no magic byte to peek at. + b.place_raw(&w, "broken", &[0x01]); + for arg in [None, Some(&pw)] { + assert!( + matches!( + b.store.get_secret(&w, "broken", arg).unwrap_err(), + SecretStoreError::Corruption + ), + "[{}] truncated envelope ({:?})", + b.name, + arg.map(|_| "Some") + ); + } - // magic-less legacy raw + None → returns the bytes (legacy - // tolerance); + Some(pw) → fails closed, so the strict rule holds. - b.place_raw(&w, "legacy", b"raw-legacy-seed-no-magic"); - assert_eq!( - b.store - .get_secret(&w, "legacy", None) - .unwrap() - .unwrap() - .expose_secret(), - b"raw-legacy-seed-no-magic", - "[{}] legacy magic-less + None", - b.name - ); - assert!( - matches!( - b.store.get_secret(&w, "legacy", Some(&pw)).unwrap_err(), - SecretStoreError::ExpectedProtectedButUnsealed - ), - "[{}] legacy magic-less + Some", - b.name - ); + // Raw, non-envelope bytes → Corruption (the legacy magic-less + // tolerance path is gone — every read goes through bincode). + b.place_raw(&w, "raw", b"raw-bytes-not-a-valid-envelope"); + for arg in [None, Some(&pw)] { + assert!( + matches!( + b.store.get_secret(&w, "raw", arg).unwrap_err(), + SecretStoreError::Corruption + ), + "[{}] raw non-envelope bytes ({:?})", + b.name, + arg.map(|_| "Some") + ); + } // absent entry → Ok(None) under either arg (deletion = DoS). assert!(b.store.get_secret(&w, "absent", None).unwrap().is_none()); @@ -882,18 +878,27 @@ mod tests { run_upgrade_confusion(&os_backend()); } - /// An in-place scheme-byte flip (protected→unprotected). Some(pw) is caught by - /// the strict rule regardless. None reads the body as scheme-0 opaque - /// bytes (never the real seed) — a known residual, dominated by the - /// consumer-DB residual; pinned, not "fixed". + /// A scheme-flip from `Password` → `Unprotected`: `Some(pw)` is + /// caught by the strict rule regardless; `None` reads the body as + /// scheme-0 opaque bytes (never the real seed) — a known residual, + /// dominated by the consumer-DB residual; pinned, not "fixed". fn run_scheme_flip(b: &Backend) { + use crate::secrets::wire::config::WIRE_CONFIG; + use crate::secrets::wire::envelope::{Envelope, Payload}; + let w = wid(6); let pw = SecretString::new("pw"); - let mut blob = protected(&w, "x", "pw", b"real-seed"); - let scheme_off = envelope::MAGIC.len() + 1; - assert_eq!(blob[scheme_off], envelope::SCHEME_PASSWORD); - blob[scheme_off] = envelope::SCHEME_UNPROTECTED; - b.place_raw(&w, "x", &blob); + let blob = protected(&w, "x", "pw", b"real-seed"); + let (env, _): (Envelope, usize) = bincode::decode_from_slice(&blob, WIRE_CONFIG).unwrap(); + let flipped = match env.payload { + Payload::Password { ciphertext, .. } => Envelope { + version: env.version, + payload: Payload::Unprotected(ciphertext), + }, + Payload::Unprotected(_) => panic!("protected() must yield a Password payload"), + }; + let flipped_blob = bincode::encode_to_vec(&flipped, WIRE_CONFIG).unwrap(); + b.place_raw(&w, "x", &flipped_blob); assert!(matches!( b.store.get_secret(&w, "x", Some(&pw)).unwrap_err(), diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs index 90d7f1cc14..d53ff9bbb9 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs @@ -29,10 +29,6 @@ //! (`PWSEV-TIER2-AAD-v1` and the implicitly-untagged //! `secrets/file/format.rs::aad` / `verify_aad` outputs). #![deny(missing_docs)] -// Type-only scaffolding before the encoder/decoder is wired in. The -// consumers in `envelope.rs` reference every item once the module is -// complete. -#![allow(dead_code)] pub(crate) mod aad; pub(crate) mod config; From 7c344b00b51f42c218e9b40b346ca87e64677fea Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:55:48 +0000 Subject: [PATCH 18/21] refactor(rs-platform-wallet-storage)!: polish docs + drop tombstone comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final regression-sweep commit: add the `wire` module pointer to `secrets/mod.rs` rustdoc so audit readers find the bincode-as-wire-format decision, and rephrase the `run_quadrant` raw-bytes branch comment to describe present state instead of the removed magic-less tolerance path (coding-best-practices: no tombstone comments). All four workflow checks land clean on this commit: - `cargo fmt --all -- --check` - `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` - `cargo test -p platform-wallet-storage --all-targets` — 211 lib + 240 integration + 3 doc tests pass - `cargo check -p platform-wallet -p platform-wallet-ffi --all-targets` — no caller-side change (AC-NF4) Refs dev-plan.md T-5. --- packages/rs-platform-wallet-storage/src/secrets/mod.rs | 7 +++++++ packages/rs-platform-wallet-storage/src/secrets/store.rs | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index a113e978cf..2b537aee0c 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -21,6 +21,13 @@ //! This `src/secrets/` tree is the sole secret-bearing module: //! `tests/secrets_scan.rs` exempts it, so it owns its own review //! discipline via `tests/secrets_guard.rs`. +//! +//! Cryptographic wire format lives in [`mod@wire`]: the Tier-2 +//! envelope (`wire::envelope`) and the three AAD constructions +//! (`wire::aad`) are bincode-encoded against a single `WIRE_CONFIG`, so +//! a future bincode-config drift is caught by the golden-vector tests +//! in `wire::envelope::tests` rather than silently corrupting every +//! stored blob. mod envelope; mod error; diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index a736fa54b1..d99075fc9a 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -703,8 +703,8 @@ mod tests { ); } - // Raw, non-envelope bytes → Corruption (the legacy magic-less - // tolerance path is gone — every read goes through bincode). + // Raw, non-envelope bytes → Corruption under either password + // arg: every read goes through the bincode decoder. b.place_raw(&w, "raw", b"raw-bytes-not-a-valid-envelope"); for arg in [None, Some(&pw)] { assert!( From c4af266e4eac67564382b1b0f08c2394124392fa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:29:03 +0000 Subject: [PATCH 19/21] refactor(rs-platform-wallet-storage)!: address Phase-3 review findings on wire layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single follow-up commit on top of the five-commit bincode refactor landed earlier on this branch. Picks up the post-audit findings flagged by the Phase-3 reviewers; no functional change to the public Tier-2 surface (set_secret / get_secret / reprotect / delete unaffected). * PROJ-003 (HIGH) — Rewrite the stale SECRETS.md sections (envelope wire format, AAD construction, strict-read truth table, "Greenfield / legacy entries"). The doc no longer references the deleted PWSEV magic-byte layout, hand-rolled AAD concatenation, or magic-less raw legacy quadrant; it now points at src/secrets/wire/mod.rs and src/secrets/wire/envelope.rs as the wire-format source of truth and names the bincode Tier2Aad / EntryAad / VerifyAad struct shapes. * S3.1 (MEDIUM) — Extend the per-read default_target ceiling in unwrap_password_payload to gate the `t` axis symmetrically with `m_kib`. Closes the CPU-axis gap between ARGON2_MAX_T=16 and the shipped t=3 default (worst-case forged read ~5.3× the shipped iteration count). New test `kdf_t_ceiling_fires_before_derive`. * QA-008 (LOW) — Justify the DECODE_CONFIG asymmetry in its docstring (hostile decode-allocation defense vs. design-brief NF2's no-limit encoder convention), naming this as a security-positive deviation and explaining why the encoder still uses WIRE_CONFIG. * S2.3 (LOW) — Assert `consumed == blob.len()` after bincode::decode_from_slice; refuse trailing bytes as Corruption (truncation/extension probe defense). New test `decode_rejects_trailing_garbage`. * QA-005 (LOW) — Fix the inaccurate `VERIFY_DOMAIN_V2` rustdoc claim of "distinct length from the other two" (TIER2 and VERIFY are both 18 bytes; ENTRY is 17). Rewrite to describe disjointness by content past the common prefix and cite the empirical disjointness tests. Adjacent cleanups: wire/aad.rs module header and Tier2Aad.domain rustdoc had the same length-distinct miscalibration — fixed. PROJ-002 / PROJ-005 / QA-A1 / QA-018 are out of scope per the brief (accepted-with-rationale or design-brief defect, not in this work). Refs PR #3953. --- .../rs-platform-wallet-storage/SECRETS.md | 121 +++++++++++------- .../src/secrets/wire/aad.rs | 14 +- .../src/secrets/wire/config.rs | 11 +- .../src/secrets/wire/envelope.rs | 84 ++++++++++-- 4 files changed, 164 insertions(+), 66 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md index 8732e041cc..1457e1ccee 100644 --- a/packages/rs-platform-wallet-storage/SECRETS.md +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -124,37 +124,69 @@ Every value written through `set_secret`/`set` is wrapped in a self-describing, authenticated envelope before it reaches the backend. The backend (file vault or OS keychain) stores only these opaque bytes. -```text -magic b"PWSEV" (5) -version u8 = 1 (envelope version — independent of the vault FORMAT_VERSION) -scheme u8 (0 = unprotected passthrough, 1 = password) -── scheme 0 ── payload: the raw secret bytes -── scheme 1 ── kdf(id u8 ‖ m_kib u32 LE ‖ t u32 LE ‖ p u32 LE) (13) - ‖ salt[32] ‖ nonce[24] ‖ ciphertext+tag +The canonical wire format is **bincode-encoded** under a single +`WIRE_CONFIG = standard().with_big_endian().with_no_limit()` against +two `pub(crate)` types whose shapes are the source of truth — see +[`src/secrets/wire/envelope.rs`](src/secrets/wire/envelope.rs) and +[`src/secrets/wire/mod.rs`](src/secrets/wire/mod.rs): + +```rust +struct Envelope { version: u32, payload: Payload } +enum Payload { + Unprotected(Vec), // scheme 0 + Password { // scheme 1 + kdf: KdfParamsEncoded, // id u8 ‖ m_kib u32 ‖ t u32 ‖ p u32 + salt: [u8; 32], nonce: [u8; 24], + ciphertext: Vec, // includes the 16-byte Poly1305 tag + }, +} ``` -- **AAD (scheme 1)** binds `domain ‖ magic ‖ version ‖ scheme ‖ kdf ‖ salt - ‖ wallet_id ‖ label` (length-prefixed), mirroring the vault's own - `aad()`/`verify_aad()`. A protected blob relocated to another slot — or - any in-place header edit — fails the tag (relocation/header-tamper - resistance). On the file arm this AAD is *in addition* to the vault's own - per-entry AAD + tag; on the OS arm it is the only authentication layer. -- **KDF ceiling before derivation (anti-DoS).** The KDF params live in the - (attacker-controllable) header, so on a read the Argon2 **ceiling is - enforced before** any derivation/allocation — a forged `m_kib`/`t` cannot - force a giant allocation or an unbounded stall on the victim's unlock. -- **No vault format bump.** The envelope lives *inside* the entry bytes, - identical over File and Os, so there is no vault-parser or migration - change. +`ENVELOPE_VERSION = 1` is bumped only on a breaking layout change, +independent of the vault `FORMAT_VERSION`. Decoding goes through a +budget-limited `DECODE_CONFIG = WIRE_CONFIG.with_limit::()` so a +hostile blob declaring a multi-GiB length prefix is rejected before +allocation (security-positive deviation from the no-limit encoder +config). Trailing bytes after a valid decode are also refused — +`consumed == blob.len()` is a fail-closed invariant. + +- **AAD (scheme 1)** is bincode-encoded from `Tier2Aad` + ([`src/secrets/wire/aad.rs`](src/secrets/wire/aad.rs)), which binds + `domain (PWSEV-TIER2-AAD-v2) ‖ envelope_version ‖ scheme_discriminant + ‖ kdf ‖ salt ‖ wallet_id ‖ label`. The vault's own per-entry AAD goes + through `EntryAad` (`domain (PWSV-ENTRY-AAD-v2) ‖ format_version ‖ + wallet_id ‖ label`) and the vault verify-token AAD through `VerifyAad` + (`domain (PWSV-VERIFY-AAD-v2) ‖ format_version ‖ salt ‖ kdf`). All + three domain tags are pair-wise byte-disjoint by construction. A + protected blob relocated to another slot — or any in-place header + edit — fails the tag (relocation/header-tamper resistance). On the + file arm this AAD is *in addition* to the vault's own per-entry AAD + + tag; on the OS arm it is the only authentication layer. +- **KDF ceiling before derivation (anti-DoS).** The KDF params live in + the (attacker-controllable) header, so on a read the Argon2 ceiling + is enforced **before** any derivation/allocation — both the wider + `enforce_bounds` (algorithm id + floors/ceilings) AND a tighter + per-read gate that refuses any `m_kib > default_target().m_kib` OR + `t > default_target().t`. A forged header cannot inflate memory by + more than the shipped default or CPU by more than the shipped + iteration count. +- **No vault format bump.** The envelope lives *inside* the entry + bytes, identical over File and Os, so there is no vault-parser or + migration change. - **Size cap.** The plaintext is capped at `MAX_PLAINTEXT_LEN` - (`MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD` = 64 KiB − 128 = 65 408 bytes), - uniformly for both schemes, so the enveloped bytes always fit the - backend's own `MAX_SECRET_LEN` cap and the user-visible limit is stable - regardless of scheme. Oversize → `SecretTooLarge { found, max }` with + (`MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD`), uniformly for both + schemes, so the enveloped bytes always fit the backend's own + `MAX_SECRET_LEN` cap and the user-visible limit is stable regardless + of scheme. Oversize → `SecretTooLarge { found, max }` with `max = MAX_PLAINTEXT_LEN` (re-exported as `secrets::MAX_PLAINTEXT_LEN`). -- **Unknown version/scheme** (magic present) → `UnsupportedEnvelopeVersion` - — fail closed **regardless of the password**: an unparseable future - format can be neither safely unwrapped nor treated as unprotected. +- **Unknown envelope version** → `UnsupportedEnvelopeVersion` — fail + closed **regardless of the password**: an envelope tagged for a + future layout can be neither safely unwrapped nor treated as + unprotected. +- **Unparseable bytes / unknown scheme tag / trailing garbage** → + `Corruption`. There is no magic-byte peek — every blob runs through + the bincode decoder, and anything that does not round-trip cleanly + with `consumed == blob.len()` fails closed. #### The strict, fail-closed read @@ -175,20 +207,19 @@ object must be protected": | `password` arg | stored blob | result | |---|---|---| | `Some(pw)` | valid scheme-1 | the secret, or `WrongPassword` on tag fail | -| **`Some(pw)`** | **scheme-0 / legacy magic-less raw** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED** | +| **`Some(pw)`** | **valid scheme-0 envelope** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED** | | `Some(pw)` | scheme-1 but truncated/corrupt | `Corruption` | -| `Some/None` | magic present, unknown version/scheme | `UnsupportedEnvelopeVersion` | +| `Some/None` | unknown envelope version | `UnsupportedEnvelopeVersion` | +| `Some/None` | unparseable / non-envelope bytes / trailing garbage | `Corruption` | | `None` | valid scheme-1 | `NeedsPassword` (never ciphertext) | -| `None` | scheme-0 | the secret | -| `None` | legacy magic-less raw | the secret (+ a one-time warning; re-wrapped on next write) | -| `None` | magic present but truncated header | `Corruption` | +| `None` | valid scheme-0 envelope | the secret | | any | absent entry | `Ok(None)` (deletion = DoS, never injection) | -The load-bearing row is **`Some(pw)` + non-envelope ⇒ -`ExpectedProtectedButUnsealed`**: with a password in hand, a non-protected -blob can only mean a strip, so it is refused and **no bytes are returned**. -A consumer bug alone — over- or under-supplying a password — fails closed -in *every* direction. +The load-bearing row is **`Some(pw)` + scheme-0 envelope ⇒ +`ExpectedProtectedButUnsealed`**: with a password in hand, an +unprotected envelope can only mean a strip, so it is refused and **no +bytes are returned**. A consumer bug alone — over- or under-supplying +a password — fails closed in *every* direction. **Arm asymmetry.** On the file arm the stored bytes are themselves sealed under the vault key, so producing a *readable* stripped blob at a slot @@ -240,16 +271,14 @@ dictionary checks, UX feedback) is locale- and threat-specific and is the entropy is the *whole* guarantee against an offline Argon2id attacker who already holds the backend — choose it accordingly. -#### Greenfield / legacy entries +#### Greenfield only — no legacy tolerance -The envelope is net-new, so post-feature reads/writes go through it. A -decrypted entry that lacks the `PWSEV` magic is treated as a **legacy -unprotected** value: returned on a `None` read (with a one-time warning, -and re-wrapped on the next write) and refused (`ExpectedProtectedButUnsealed`) -on a `Some(pw)` read — so legacy tolerance never weakens the strict read. -(A pre-feature build that persisted vault files is a deployment fact outside -this crate; the legacy-tolerant read makes the transition seamless either -way.) +The envelope is the only on-disk Tier-2 format this build understands. +A decrypted entry that does not bincode-decode to a valid `Envelope` +under `WIRE_CONFIG` (including trailing-byte extension probes) surfaces +as `Corruption` on every read — there is no magic-byte peek and no +magic-less raw legacy path. The shipped wire layer is the source of +truth; older non-enveloped stored values are out of scope. ### Internal SPI diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs index e3b9dc90a7..f6b750eef7 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs @@ -5,10 +5,10 @@ //! Each struct is `Encode`-only — AAD is producer-side; the decoder //! re-builds it from the surrounding context and bincode-encodes again //! against [`WIRE_CONFIG`]. Pair-wise byte disjointness is guaranteed by -//! the three domain constants declared in [`super::config`]; the bincode -//! varint length prefix in front of `domain` is itself distinct across -//! the three (each tag has a different byte length), so prefix -//! containment is structurally impossible. +//! the three domain constants declared in [`super::config`] and pinned +//! empirically by the tests `tier2_and_entry_aad_byte_disjoint`, +//! `tier2_and_verify_aad_byte_disjoint`, and +//! `entry_and_verify_aad_byte_disjoint`. use crate::secrets::file::crypto::SALT_LEN; use crate::secrets::wire::kdf::KdfParamsEncoded; @@ -23,8 +23,10 @@ use crate::secrets::wire::kdf::KdfParamsEncoded; /// re-ordering. #[derive(bincode::Encode)] pub(crate) struct Tier2Aad<'a> { - /// Domain tag — `TIER2_DOMAIN_V2`. Length-prefixed by bincode so a - /// future swap can never collide with the entry / verify AADs. + /// Domain tag — `TIER2_DOMAIN_V2`. Length-prefixed by bincode and + /// byte-disjoint from `ENTRY_DOMAIN_V2` / `VERIFY_DOMAIN_V2` by + /// content past the common prefix; pinned by the disjointness tests + /// in [`super::aad::tests`]. pub domain: &'static [u8], /// Envelope wire version (`ENVELOPE_VERSION`). pub envelope_version: u32, diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs index c0e67c21d6..b6c6031a63 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs @@ -32,7 +32,12 @@ pub(crate) const TIER2_DOMAIN_V2: &[u8] = b"PWSEV-TIER2-AAD-v2"; /// disjointness with [`TIER2_DOMAIN_V2`] and [`VERIFY_DOMAIN_V2`]. pub(crate) const ENTRY_DOMAIN_V2: &[u8] = b"PWSV-ENTRY-AAD-v2"; -/// Domain-separation tag leading every vault `VerifyAad`. Distinct -/// length from the other two so the bincode varint length prefix alone -/// makes encoded outputs prefix-disjoint at the first byte. +/// Domain-separation tag leading every vault `VerifyAad`. Disjoint +/// from [`TIER2_DOMAIN_V2`] and [`ENTRY_DOMAIN_V2`] by **content past +/// the common prefix** (the three tags are NOT length-distinct — +/// TIER2 and VERIFY are both 18 bytes; ENTRY is 17). Pair-wise +/// byte-disjointness is pinned by the tests +/// `tier2_and_verify_aad_byte_disjoint`, +/// `tier2_and_entry_aad_byte_disjoint`, and +/// `entry_and_verify_aad_byte_disjoint`. pub(crate) const VERIFY_DOMAIN_V2: &[u8] = b"PWSV-VERIFY-AAD-v2"; diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs index cef605e48d..c0a4863edd 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs @@ -70,14 +70,25 @@ pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 112; pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; /// Decode-side budget: caps the bytes the bincode decoder will consume -/// from a single envelope. Defends against a hostile blob whose -/// length-prefix bytes declare a multi-GiB `Vec`. Encoding stays on -/// the no-limit `WIRE_CONFIG` so legitimate outputs (always `<= -/// MAX_SECRET_LEN`) are never refused. Equal to the on-disk cap. +/// from a single envelope. Equal to the on-disk cap. const DECODE_BUDGET: usize = MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD; -/// Bincode decode config = encoder's config with the -/// [`DECODE_BUDGET`] limit applied. +/// Bincode decode config — derived from [`WIRE_CONFIG`] but with a +/// [`DECODE_BUDGET`] byte limit applied. +/// +/// **Asymmetric on purpose, security-positive deviation from +/// design-brief NF2** (which locks the wire config to +/// `with_no_limit()`). The deviation exists for hostile-decode +/// hardening: an attacker-controlled length prefix in the blob would +/// otherwise drive `Vec::with_capacity` to a multi-GiB allocation +/// before any tag check. With `Limit`, bincode refuses the +/// allocation up front and the unwrap fails closed as `Corruption`. +/// +/// The encoder retains [`WIRE_CONFIG`] (no limit) because AAD and +/// envelope encoding are producer-only — every input is library-owned +/// and bounded by `MAX_PLAINTEXT_LEN`, so a limit there has no +/// security benefit and would be a foot-gun against legitimate +/// at-cap secrets. const DECODE_CONFIG: Configuration> = WIRE_CONFIG.with_limit::(); @@ -204,8 +215,13 @@ pub(crate) fn unwrap( password: Option<&SecretString>, blob: &[u8], ) -> Result { - let (envelope, _) = bincode::decode_from_slice::(blob, DECODE_CONFIG) + let (envelope, consumed) = bincode::decode_from_slice::(blob, DECODE_CONFIG) .map_err(|_| SecretStoreError::Corruption)?; + // Trailing bytes after a valid decode are a truncation/extension + // probe — fail closed. + if consumed != blob.len() { + return Err(SecretStoreError::Corruption); + } if envelope.version != ENVELOPE_VERSION { // `found` keeps the historical u8 — the error API stayed u8 for @@ -251,10 +267,14 @@ fn unwrap_password_payload( // before any allocation. let kdf = KdfParams::try_from(kdf_encoded)?; // (b) Per-read ceiling tighter than `enforce_bounds`: a header - // declaring more memory than this build's shipped target is also - // refused before `derive_key` allocates. Closes the gap between - // `ARGON2_MAX_M_KIB` (1 GiB) and the shipped 64 MiB default. - if kdf.m_kib > KdfParams::default_target().m_kib { + // declaring more memory OR more time than this build's shipped + // target is refused before `derive_key` allocates. Closes the gaps + // between `ARGON2_MAX_M_KIB` (1 GiB) / `ARGON2_MAX_T` (16) and the + // shipped 64 MiB / t=3 default — bounds the worst-case forged read + // at the shipped target on both axes (no headroom for an attacker + // to inflate memory by 16× or CPU by 5.3×). + let target = KdfParams::default_target(); + if kdf.m_kib > target.m_kib || kdf.t > target.t { return Err(SecretStoreError::KdfFailure); } // (c) AAD binds identity + header — the same bytes the encoder @@ -809,6 +829,48 @@ mod tests { assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); } + /// Sibling to TC-024 on the `t` axis — per-read `default_target` + /// ceiling rejects an envelope whose `t` exceeds the shipped target + /// even when still inside `enforce_bounds` (`ARGON2_MAX_T = 16`). + /// Closes the CPU-axis gap that would otherwise let a forged header + /// run Argon2 at 5.3× the shipped iteration count. + #[test] + fn kdf_t_ceiling_fires_before_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let target = KdfParams::default_target(); + let bumped_t = target.t + 1; + // Sanity: the bumped t stays inside the wider enforce_bounds + // ceiling, so only the per-read gate can refuse it. + assert!(bumped_t <= ARGON2_MAX_T); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + // Keep m_kib at the shipped default so the m_kib gate + // cannot fire — t must be the sole reason this rejects. + kdf.m_kib = target.m_kib; + kdf.t = bumped_t; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// Trailing bytes appended after a valid envelope are rejected as + /// `Corruption` — defends against a truncation/extension probe. + #[test] + fn decode_rejects_trailing_garbage() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let mut extended = blob.clone(); + extended.extend_from_slice(&[0xFFu8; 16]); + let err = unwrap(&wid(1), "seed", Some(&p), &extended).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + + // The same blob without the suffix still unwraps cleanly — + // proves the rejection is on the trailing bytes, not the + // envelope itself. + let ok = unwrap(&wid(1), "seed", Some(&p), &blob).unwrap(); + assert_eq!(ok.expose_secret(), b"seed"); + } + /// TC-031 — round-tripped secret matches the original under a /// constant-time compare. #[test] From 483e667bcfc18dda7f2ab6e6374992e70e6de93b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:56:30 +0000 Subject: [PATCH 20/21] docs(rs-platform-wallet-storage): update SECRETS.md reprotect to Err(NoEntry) semantics The reprotect paragraph in SECRETS.md still described an absent object as a no-op returning `Ok(())`, but `SecretStore::reprotect` returns `Err(SecretStoreError::NoEntry)` on absent since `abe77810db` (test `reprotect_absent_returns_no_entry` pins the contract). Public design doc now matches the implementation and the rustdoc. Closes PR #3953 review thread (comment 3481600904 / thepastaclaw). Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01BcZFae6ABdoMEvCBQZnaJF --- .../rs-platform-wallet-storage/SECRETS.md | 6 +++-- .../src/secrets/envelope.rs | 22 ------------------- 2 files changed, 4 insertions(+), 24 deletions(-) delete mode 100644 packages/rs-platform-wallet-storage/src/secrets/envelope.rs diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md index 1457e1ccee..9f1361702b 100644 --- a/packages/rs-platform-wallet-storage/SECRETS.md +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -251,8 +251,10 @@ rollback protection. `reprotect(service, label, current, new)` does it in one same-slot unwrap→rewrap→overwrite: read under the `current` expectation (so a strip is caught before any rewrite), then write under `new` — `None`→`Some` adds, -`Some`→`Some` changes, `Some`→`None` removes. An absent object is a no-op -(`Ok(())`). The rewrite is a same-slot overwrite — atomic on the file arm, +`Some`→`Some` changes, `Some`→`None` removes. An absent object returns +`Err(SecretStoreError::NoEntry)` — `reprotect` is operational, so absence +means the caller's protection-status record disagrees with the backend and +must not be silently dropped. The rewrite is a same-slot overwrite — atomic on the file arm, and on the OS arm inheriting the backend's single-item-replace contract — so a crash between the read and the commit leaves the prior value intact and readable under `current`. **After a successful call the consumer MUST diff --git a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/envelope.rs deleted file mode 100644 index 0d56a4cb5d..0000000000 --- a/packages/rs-platform-wallet-storage/src/secrets/envelope.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Tier-2 opt-in per-object password envelope (backend-independent). -//! -//! Sits ABOVE [`SecretStore`](crate::secrets::SecretStore), over both -//! the `File` vault and `Os` keyring arms: the backend stores opaque -//! bytes, and a chosen critical object (a seed wallet, a single -//! privkey) can be wrapped under an extra, user-supplied **object -//! password** before it ever reaches the backend. Reading a protected -//! object then needs BOTH backend access AND the password — the first -//! control that survives a full backend compromise. -//! -//! The wire format lives in [`super::wire::envelope`]: every byte that -//! crosses the AEAD seam is bincode-encoded against a single -//! `WIRE_CONFIG`, so a future bincode-config drift is caught by the -//! golden-vector tests instead of silently corrupting every stored -//! blob. This module is a thin re-export hub for the call sites in -//! [`super::store`]. - -pub use super::wire::envelope::MAX_PLAINTEXT_LEN; -pub(crate) use super::wire::envelope::{unwrap, wrap, MAX_ENVELOPE_OVERHEAD}; - -#[cfg(test)] -pub(crate) use super::wire::envelope::wrap_with_params; From 07579e3e984d896c52e01d5c26edaebe78d3810e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:57:29 +0000 Subject: [PATCH 21/21] refactor(rs-platform-wallet-storage): rewire envelope re-exports after shim removal Completes the envelope.rs shim collapse begun in 483e667bcf (which deleted the 22-line re-export file alongside the SECRETS.md doc fix). The `MAX_PLAINTEXT_LEN` re-export now goes directly from `wire::envelope`, and `secrets::store` imports `wire::envelope` instead of the now-gone `secrets::envelope`. Call sites stay `envelope::wrap` / `envelope::unwrap` since the module is brought into scope by the new `use` path. No public API change; cargo check on `platform-wallet` / `platform-wallet-ffi` clean. Closes PR #3953 review thread (comment 3481683194 / lklimek). Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01BcZFae6ABdoMEvCBQZnaJF --- packages/rs-platform-wallet-storage/src/secrets/mod.rs | 3 +-- packages/rs-platform-wallet-storage/src/secrets/store.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index 2b537aee0c..6989a6e69e 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -29,7 +29,6 @@ //! in `wire::envelope::tests` rather than silently corrupting every //! stored blob. -mod envelope; mod error; mod file; mod keyring; @@ -38,7 +37,6 @@ mod store; mod validate; mod wire; -pub use envelope::MAX_PLAINTEXT_LEN; pub use error::{IoError, OsKeyringErrorKind, SecretStoreError}; pub use file::{ EncryptedFileCredential, EncryptedFileStore, MAX_SECRET_LEN, MAX_VAULT_SIZE_BYTES, @@ -48,3 +46,4 @@ pub use keyring::default_credential_store; pub use secret::{SecretBytes, SecretString, MIN_PASSPHRASE_LEN}; pub use store::SecretStore; pub use validate::WalletId; +pub use wire::envelope::MAX_PLAINTEXT_LEN; diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 7125962c46..7f0147f165 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -12,10 +12,10 @@ use std::sync::Arc; use keyring_core::api::CredentialStoreApi; use keyring_core::{Entry, Error as KeyringError}; -use super::envelope; use super::error::{OsKeyringErrorKind, SecretStoreError}; use super::secret::{SecretBytes, SecretString}; use super::validate::WalletId; +use super::wire::envelope; use super::{default_credential_store, EncryptedFileStore, MAX_SECRET_LEN, SERVICE_PREFIX}; /// A passphrase-or-OS-keyring backed store for wallet secret material.