Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a4ed0b5
feat(platform-wallet-storage): SecretString serde/schemars/is_blank (…
lklimek Jun 22, 2026
1827a39
feat(platform-wallet-storage): error taxonomy for Tier-2 secret prote…
lklimek Jun 22, 2026
f68f00e
feat(platform-wallet-storage): Tier-2 secret envelope (wrap/unwrap)
lklimek Jun 22, 2026
491229b
feat(platform-wallet-storage)!: strict fail-closed Tier-2 read (L-1 k…
lklimek Jun 22, 2026
c19c23b
feat(platform-wallet-storage): SecretStore Tier-2 write API + reprotect
lklimek Jun 22, 2026
d4d311c
feat(platform-wallet-storage)!: Tier-1 blank-passphrase guard + open_…
lklimek Jun 22, 2026
351d1e6
refactor(platform-wallet-storage): use keyring_core::mock::Store; ann…
lklimek Jun 22, 2026
d3df41e
docs(platform-wallet-storage): QA fixes — rustdoc clarity + ephemeral…
lklimek Jun 22, 2026
b5ede7d
test(platform-wallet-storage): QA fixes — Os read bound, Os crash tes…
lklimek Jun 22, 2026
fb7953e
test(platform-wallet-storage): cover Os read-size guard; pin new() so…
lklimek Jun 22, 2026
1e70ebd
Merge branch 'fix/wallet-core-derived-rehydration' into feat/platform…
lklimek Jun 26, 2026
3e2fb63
docs(rs-platform-wallet-storage): clarify why SecretString::new zeroi…
lklimek Jun 26, 2026
121e8cd
fix(rs-platform-wallet-storage)!: reject blank object password on pro…
lklimek Jun 26, 2026
abe7781
feat(rs-platform-wallet-storage)!: surface absence in SecretStore::de…
lklimek Jun 26, 2026
7099d6a
refactor(rs-platform-wallet-storage)!: bootstrap secrets/wire/ bincod…
lklimek Jun 26, 2026
bb863d2
refactor(rs-platform-wallet-storage)!: implement wire encoder + size …
lklimek Jun 26, 2026
8f7c175
refactor(rs-platform-wallet-storage)!: implement wire decoder + dispa…
lklimek Jun 26, 2026
ff7e560
refactor(rs-platform-wallet-storage)!: switch over to wire/, drop leg…
lklimek Jun 26, 2026
7c344b0
refactor(rs-platform-wallet-storage)!: polish docs + drop tombstone c…
lklimek Jun 26, 2026
c4af266
refactor(rs-platform-wallet-storage)!: address Phase-3 review finding…
lklimek Jun 26, 2026
cf7894e
Merge branch 'chore/rs-platform-wallet-storage-secret-comment-clarity…
lklimek Jun 26, 2026
11338b6
Merge branch 'fix/rs-platform-wallet-storage-tier2-unwrap-blank-pw' i…
lklimek Jun 26, 2026
a3ecc72
Merge branch 'feat/rs-platform-wallet-storage-mutators-surface-absenc…
lklimek Jun 26, 2026
a29cbf8
Merge branch 'refactor/rs-platform-wallet-storage-bincode-envelope-aa…
lklimek Jun 26, 2026
483e667
docs(rs-platform-wallet-storage): update SECRETS.md reprotect to Err(…
lklimek Jun 26, 2026
07579e3
refactor(rs-platform-wallet-storage): rewire envelope re-exports afte…
lklimek Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

18 changes: 18 additions & 0 deletions packages/rs-platform-wallet-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,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
Expand All @@ -213,6 +222,15 @@ secrets = [
"dep:apple-native-keyring-store",
"dep:windows-native-keyring-store",
]
# Opt-in `SecretString` serde/schemars impls. Deliberately DEFAULT-OFF
# even though `secrets` (and, via it, the `serde` dep) are default-on:
# these gate the IMPLS, not the dep, so the impls are absent unless a
# consumer explicitly opts in. `secret-serde` requires `secrets` (the type
# only exists under it). NO `Serialize` is ever provided. `secret-schemars`
# implies `secret-serde`. (design §5.4 / GAP-001 names / GAP-002 satisfiable
# default-off.)
secret-serde = ["secrets", "dep:serde"]
secret-schemars = ["secret-serde", "dep:schemars"]
# Per-object-type key/value metadata API
# (`platform_wallet_storage::{KvStore, KvError, ObjectId}`) plus the
# SQLite-backed impl. Requires `sqlite` because the only shipped backend
Expand Down
252 changes: 247 additions & 5 deletions packages/rs-platform-wallet-storage/SECRETS.md

Large diffs are not rendered by default.

185 changes: 179 additions & 6 deletions packages/rs-platform-wallet-storage/src/secrets/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,41 @@ 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), 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; for a deliberately keyless file vault use open_unprotected"
)]
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).
Expand All @@ -39,6 +74,20 @@ pub enum SecretStoreError {
found: u32,
},

/// 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.
found: u8,
},

/// The vault file was malformed (bad magic, truncated header, bad
/// record framing) — no plaintext was produced.
#[error("malformed vault file")]
Expand All @@ -49,6 +98,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")]
Expand Down Expand Up @@ -186,8 +246,6 @@ impl From<std::io::Error> 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).
Expand All @@ -203,7 +261,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",
Expand Down Expand Up @@ -231,28 +288,47 @@ impl From<std::io::Error> 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::<SecretStoreError>())`.
/// - 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.
/// - 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", _)`;
/// [`Io`] → [`KeyringError::PlatformFailure`].
///
/// [`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<SecretStoreError> 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 { .. }
Expand All @@ -264,6 +340,7 @@ impl From<SecretStoreError> 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)),
}
}
Expand Down Expand Up @@ -386,6 +463,102 @@ mod tests {
assert!(!format!("{k}").contains("plaintext"));
}

/// 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<String> = [
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");
}

/// 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; for a deliberately keyless file vault use open_unprotected"
);
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"));
}
}

/// 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::<SecretStoreError>();
assert!(
matches!(recovered, Some(e) if e.to_string() == want),
"expected recoverable {want}, got {recovered:?}"
);
}
other => panic!("expected NoStorageAccess for {want}, got {other:?}"),
}
}
}

/// `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 {
Expand Down
27 changes: 27 additions & 0 deletions packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>), 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
Expand Down
Loading
Loading