Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
35a4a04
docs(secret-seam): Phase-1 design artifacts (UX disclosure + test cas…
lklimek Jun 19, 2026
9d313b7
feat(wallet-backend): add raw-SecretBytes secret seam + typed errors …
lklimek Jun 19, 2026
890cae1
fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd)
lklimek Jun 19, 2026
85e8c4f
feat(model): PrivateKeyData::InVault placeholder + migration probes (T1)
lklimek Jun 19, 2026
f1cd234
feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, sc…
lklimek Jun 19, 2026
1880461
chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch
lklimek Jun 19, 2026
e503bbd
feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAcc…
lklimek Jun 19, 2026
aa3c34d
feat(wallet-backend): identity_key_store + seed/single-key seam-raw w…
lklimek Jun 19, 2026
a6c11a7
feat: crash-safe dual-format migration + InVault resolver + vault del…
lklimek Jun 19, 2026
aadf532
feat: key_info_screen JIT identity signing + single-key Copy B disclo…
lklimek Jun 19, 2026
dd570b8
chore: fmt + clippy for the T3-T8 integration batch
lklimek Jun 19, 2026
c155020
fix(wallet-backend): dual-format read for WalletMeta + ImportedKey si…
lklimek Jun 19, 2026
12b34f3
test(identity-db): identity-key migration, deletion, write-fault no-l…
lklimek Jun 19, 2026
99b5926
test(wallet-lifecycle): assert lazy-migration secret post-conditions …
lklimek Jun 19, 2026
a727179
test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts…
lklimek Jun 19, 2026
73c189d
refactor(wallet-backend): zeroize migration source, flavor identity-k…
lklimek Jun 19, 2026
66c3204
test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-le…
lklimek Jun 19, 2026
906a2f1
test(kittest): disclosure-banner copy coverage (QA-007/Diziet)
lklimek Jun 19, 2026
551d208
docs: comment hygiene + CLAUDE.md seam pointer + user-story softening…
lklimek Jun 19, 2026
4dabef6
chore: nightly fmt for the QA-findings batch
lklimek Jun 19, 2026
1be4bef
test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InV…
lklimek Jun 19, 2026
3cb2d83
Merge remote-tracking branch 'origin/docs/platform-wallet-migration-d…
lklimek Jun 22, 2026
bf7d17b
chore(deps): repin platform deps to feat/platform-wallet-secret-prote…
lklimek Jun 22, 2026
01939db
fix(secret): open the vault keyless (file_unprotected) for the Tier-1…
lklimek Jun 22, 2026
972cf7a
feat(secret): add Tier-2 seam capability (protected set/get + scheme …
lklimek Jun 22, 2026
fd7f078
feat(secret): adopt Tier-2 per-secret passwords for HD seeds
lklimek Jun 22, 2026
6dafbda
refactor(secret): clean keep-protection replacement of the downgrade …
lklimek Jun 22, 2026
8d17f57
feat(secret): adopt Tier-2 keep-protection for imported single keys
lklimek Jun 22, 2026
465f10d
fix(secret): address Smythe Tier-2 review findings (SEC-001/002/004/005)
lklimek Jun 22, 2026
904dc83
docs(migration): note the wallet.uses_password/password_hint schema i…
lklimek Jun 22, 2026
83414f6
fix(test): eliminate register_wallet_from_seed race in cold-boot test
lklimek Jun 23, 2026
564fe7d
fix(wallet-backend): keep Tier-2 protected wallets visible at cold bo…
lklimek Jun 23, 2026
bf435c4
fix(wallet-backend): seal fresh protected single-key imports Tier-2, …
lklimek Jun 23, 2026
0eaa422
docs(secret-seam): correct drifted docs to Tier-2 keep-protection rea…
lklimek Jun 23, 2026
ff15f82
feat(wallet-backend): optional per-identity at-rest encryption for id…
lklimek Jun 23, 2026
d965ca5
fix(wallet-backend): seal new keys on a protected identity Tier-2, ne…
lklimek Jun 23, 2026
fcf6da1
fix(identity): fail closed before broadcast when adding a key to a pr…
lklimek Jun 23, 2026
cf8beab
fix(identity): harden SEC-001 identity-key paths (r2 review)
lklimek Jun 24, 2026
2f40b30
docs(single-key): correct has_passphrase on-disk-shape doc to Tier-2-…
lklimek Jun 24, 2026
925bf08
test(dashpay-e2e): use real curve points in tc_045 fixture (QA-008)
lklimek Jun 24, 2026
ab658a4
fix(wallet-backend): return WalletNotFound for an unknown seed hash (…
lklimek Jun 24, 2026
338d81a
test(core-e2e): expect SingleKeyWalletsUnsupported in tc_009 (QA-001)
lklimek Jun 24, 2026
25c97b6
fix(identity): compute a meaningful top-up fee after a backend reload…
lklimek Jun 24, 2026
00d21b5
test(spv-e2e): assert restart-in-place reconnect contract (QA-003)
lklimek Jun 24, 2026
3609d44
fix(wallet): gate sends on spendable balance, not confirmed (QA-010)
lklimek Jun 24, 2026
7567087
test(identity-e2e): poll for key visibility after broadcast (QA-004)
lklimek Jun 24, 2026
a530367
test(harness): retry transient wallet registration with backoff (QA-013)
lklimek Jun 24, 2026
3150d23
test(wallet-e2e): mark tc_012 address-advance assertion PENDING (QA-005)
lklimek Jun 24, 2026
e71e68e
docs(wallet-lifecycle): correct stop_spv rustdoc to restart-in-place …
lklimek Jun 24, 2026
36b6f2b
test(identity-e2e): widen cold-boot funding to clear top-up minimum (…
lklimek Jun 24, 2026
cf6497f
test(dashpay-e2e): defer dashpay backend-e2e module pending upstream …
lklimek Jun 24, 2026
f2936c0
test(harness): widen funded-wallet SPV-pickup budget to 120s (QA-017)
lklimek Jun 24, 2026
a0411a3
fix(identity): fail closed when opt-in protection leaves resident pla…
lklimek Jun 24, 2026
dedead2
test(identity): prove the protect fail-closed guard is wired into the…
lklimek Jun 24, 2026
a846acd
fix(fee-estimation): fall back to estimate when balance_before is sta…
lklimek Jun 24, 2026
a1cb9b4
fix(identity-db): zeroize rollback clone after successful vault migra…
lklimek Jun 24, 2026
3774a7a
fix(error): reword IdentityKeyProtectionIncomplete message (PROJ-002)
lklimek Jun 24, 2026
1b13d43
docs(wallet-lifecycle): correct inline comment to reflect restart-in-…
lklimek Jun 24, 2026
ed98fe6
docs(backend-e2e): fix dashpay-deferral TODO — count 12 tests, correc…
lklimek Jun 24, 2026
ec4527c
test(backend-e2e): strengthen tc_012 with positive assertion on secon…
lklimek Jun 24, 2026
f0608db
docs(harness): replace history narrative with present-state comment (…
lklimek Jun 24, 2026
7918d82
fix(identity): fail-closed the protect guard for legacy Encrypted key…
lklimek Jun 24, 2026
6d460c7
docs(wallet-lifecycle): note dash-spv reinit-window filter-gap (dashp…
lklimek Jun 25, 2026
d63ad9f
Merge remote-tracking branch 'origin/docs/platform-wallet-migration-d…
lklimek Jun 26, 2026
b98515d
fix(identity): preflight-verify all protected keys before unseal down…
lklimek Jun 26, 2026
2042153
fix(fee): reject partial-stale top-up fee deltas via estimate band
lklimek Jun 26, 2026
d78b930
fix(identity): give legacy Encrypted keys an honest recovery instruction
lklimek Jun 26, 2026
e97592e
fix(identity): minimize migrated-key plaintext residency before DB write
lklimek Jun 26, 2026
5957c1a
fix(identity): use the active fee estimator for the top-up estimate
lklimek Jun 26, 2026
ad8738a
test(wallet): regression for WalletNotFound vs WalletNotLoaded split
lklimek Jun 26, 2026
829c1d3
test(backend-e2e): drop dead single-key funding block, fix stale comment
lklimek Jun 26, 2026
aa78ed7
fix(identity): use the active fee estimator for the top-up-from-addre…
lklimek Jun 26, 2026
9218eb5
test(backend-e2e): present-state the TC-009 header comment
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
19 changes: 18 additions & 1 deletion src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,27 @@ pub enum TaskError {
/// migration outcome is logged where it happens; no secret or raw error
/// string is stored here.
#[error(
"Some of this identity's keys could not be protected this time, so it is not fully protected yet. Check available disk space, then try protecting this identity again."
"Some of this identity's keys are not fully protected yet. \
Close and reopen the application, then try protecting this identity again."
)]
IdentityKeyProtectionIncomplete,

/// SEC-001 fail-closed guard at the opt-in protect boundary: the identity
/// still carries one or more keys saved in the legacy on-disk format this
/// version can neither read nor migrate into the protected store. Unlike
/// resident plaintext — which the load-path migration finishes on the next
/// launch — there is NO automatic migration for these keys, so reopening the
/// application would loop on the same error. The only way forward is to add
/// the identity again from its recovery phrase or private key, which replaces
/// the legacy key entries with ones this version can protect. Fieldless: the
/// offending key's presence is logged at the guard; no secret or raw error
/// string is stored here.
#[error(
"Some of this identity's keys are saved in an older format that cannot be protected. \
Load this identity again using its recovery phrase or private key, then try protecting it."
)]
IdentityKeyProtectionLegacyFormat,

/// The DET wallet-metadata sidecar (alias / `is_main` /
/// `core_wallet_name`) could not be read or written. Distinct from
/// [`Self::WalletStorage`] because the cause sits in the cross-
Expand Down
6 changes: 3 additions & 3 deletions src/backend_task/identity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,11 +889,11 @@ impl AppContext {
inputs: BTreeMap<dash_sdk::dpp::address_funds::PlatformAddress, Credits>,
wallet_seed_hash: WalletSeedHash,
) -> Result<BackendTaskSuccessResult, TaskError> {
use crate::model::fee_estimation::PlatformFeeEstimator;
use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddresses;

// Estimate fee for top-up from platform addresses
let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup();
// Estimate the top-up fee with the active network fee multiplier
// (context estimator) so the figure shown to the user is accurate.
let estimated_fee = self.fee_estimator().estimate_identity_topup();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Use the platform-address top-up estimator on the address-funded path

top_up_identity_from_platform_addresses calls estimate_identity_topup(), which is the wallet-funded variant and ignores per-input costs. The model already provides estimate_identity_topup_from_addresses(input_count) (model/fee_estimation.rs:376), which factors in address_funding_asset_lock_cost, address_funds_transfer_input_cost * inputs, plus a 20% safety buffer — and the matching UI screen already calls it (src/ui/identities/top_up_identity_screen/by_platform_address.rs:103). Because the backend returns FeeResult::new(estimated_fee, estimated_fee) from the same u64, using the wallet-funded estimator can under-report fees for this flow and silently diverges the UI estimate from the backend estimate, especially with >1 input.

Suggested change
let estimated_fee = self.fee_estimator().estimate_identity_topup();
let estimated_fee = self
.fee_estimator()
.estimate_identity_topup_from_addresses(inputs.len());

source: ['codex']


tracing::info!(
"top_up_identity_from_platform_addresses: identity={}, inputs={:?}",
Expand Down
107 changes: 102 additions & 5 deletions src/backend_task/identity/protect_identity_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,22 @@ fn validate_protection_password(password: &Secret) -> Result<(), TaskError> {
/// (`AtWalletDerivationPath`) and already-vaulted (`InVault`) keys carry no
/// resident plaintext, so a legitimately keyless / wallet-derived identity is
/// never rejected.
///
/// Also rejects legacy `Encrypted` keys (decode-only, no current producer):
/// their vault scheme is also `Absent`, so the seal step would silently skip
/// them and issue a false-protected result. See [`KeyStorage::has_encrypted_legacy_keys`].
///
/// The two rejections carry DIFFERENT recovery actions, so they map to distinct
/// errors: resident plaintext is finished by the load-path migration on the next
/// launch ([`TaskError::IdentityKeyProtectionIncomplete`] → "close and reopen"),
/// whereas a legacy `Encrypted` key has no migration path
/// ([`TaskError::IdentityKeyProtectionLegacyFormat`] → "load the identity again").
/// Legacy keys are checked first: re-loading the identity also clears any
/// resident plaintext, so it is the single action that resolves both.
fn reject_resident_identity_plaintext(private_keys: &KeyStorage) -> Result<(), TaskError> {
if private_keys.has_encrypted_legacy_keys() {
return Err(TaskError::IdentityKeyProtectionLegacyFormat);
}
if private_keys.has_plaintext_for_vault() {
return Err(TaskError::IdentityKeyProtectionIncomplete);
}
Expand Down Expand Up @@ -217,12 +232,12 @@ fn seal_identity_keys(
}

/// Verify `password` opens EVERY already-`Protected` key in `keys`, before any
/// sealing mutates the vault. Enforces SEC-001's one-password-per-identity
/// invariant on a Mixed-state opt-in re-run: if a prior partial run sealed some
/// keys under password A and the user now supplies password B, the mismatch
/// vault mutation. Both SEC-001 migrations call this up front so they are atomic
/// by construction: if `password` fails to open any protected key, the mismatch
/// surfaces from `get_protected` as [`TaskError::IdentityKeyPassphraseIncorrect`]
/// (no oracle) with zero state changes. Keyless (`Unprotected`) and `Absent`
/// keys impose no password constraint and are skipped.
/// (no oracle) with zero state changes — opt-in can't seal the rest under a
/// second password, and opt-out can't strip a prefix before aborting. Keyless
/// (`Unprotected`) and `Absent` keys impose no password constraint and are skipped.
fn verify_existing_protection_password(
view: &IdentityKeyView<'_>,
keys: &IdentityKeySet,
Expand All @@ -246,6 +261,11 @@ fn unseal_identity_keys(
keys: &IdentityKeySet,
password: &SecretString,
) -> Result<usize, TaskError> {
// SEC-001 atomic opt-out: prove `password` opens EVERY `Protected` key
// BEFORE downgrading any label (mirrors the opt-in preflight), so a password
// that opens only a prefix can't leave that prefix stripped. Mismatch → no-op.
verify_existing_protection_password(view, keys, password)?;

let mut reverted = 0usize;
for (target, key_id) in keys {
if view.scheme(target, *key_id)? == SecretScheme::Protected {
Expand Down Expand Up @@ -364,6 +384,50 @@ mod tests {
assert_eq!(view.scheme(&M, 1).unwrap(), SecretScheme::Protected);
}

/// SEC-001 atomic opt-out (CWE-460): on a Mixed-password identity — key 0
/// sealed under password A, key 1 under password B — an opt-out with
/// password A must NOT downgrade the key it CAN open before aborting on the
/// one it cannot. The one-password invariant forbids this state, but a
/// tampered or legacy vault could still present it, so opt-out must be
/// all-or-nothing by construction. The all-keys preflight rejects up front
/// with `IdentityKeyPassphraseIncorrect`, leaving BOTH keys protected — no
/// silent partial protection downgrade. Without the preflight, key 0 (which
/// password A opens, and which sorts first) would be stripped to keyless
/// plaintext while key 1 stayed sealed.
#[test]
fn unseal_mixed_password_aborts_without_partial_downgrade() {
let dir = tempfile::tempdir().unwrap();
let store = fresh_store(dir.path());
let view = IdentityKeyView::new(&store, [0x08u8; 32]);
let pw_a = SecretString::new("password-for-key-zero");
let pw_b = SecretString::new("password-for-key-one-");
// (M, 0) sorts before (M, 1): a downgrade-as-you-go loop would reach
// key 0 first and strip it before failing the password check on key 1.
view.store_protected(&M, 0, &[0x80; 32], &pw_a).unwrap();
view.store_protected(&M, 1, &[0x81; 32], &pw_b).unwrap();
let keys = key_set(&[(M, 0), (M, 1)]);

let err = unseal_identity_keys(&view, &keys, &pw_a)
.expect_err("password A does not open key 1 — opt-out must abort");
assert!(
matches!(err, TaskError::IdentityKeyPassphraseIncorrect),
"expected IdentityKeyPassphraseIncorrect, got {err:?}"
);
// Neither key was downgraded: key 0 — which password A COULD open — is
// still Protected because the preflight ran before any mutation.
assert_eq!(view.scheme(&M, 0).unwrap(), SecretScheme::Protected);
assert_eq!(view.scheme(&M, 1).unwrap(), SecretScheme::Protected);
// The sealed bytes are intact under each key's original password.
assert_eq!(
*view.get_protected(&M, 0, &pw_a).unwrap().unwrap(),
[0x80; 32]
);
assert_eq!(
*view.get_protected(&M, 1, &pw_b).unwrap().unwrap(),
[0x81; 32]
);
}

/// A partial-crash mix (some keys Tier-2, some Tier-1) re-runs to a clean,
/// fully-protected state — the same-label upsert never loses a key.
#[test]
Expand Down Expand Up @@ -497,6 +561,23 @@ mod tests {
ks
}

/// A `KeyStorage` holding a single legacy `Encrypted` key — the decode-only
/// variant an old DET version left behind. Its vault scheme is `Absent` (no
/// migration path), so the seal step would silently skip it.
fn ks_with_encrypted_legacy() -> KeyStorage {
let pv = PlatformVersion::latest();
let mut ks = KeyStorage::default();
let k = IdentityPublicKey::random_key(1, Some(1), pv);
ks.private_keys.insert(
(M, k.id()),
(
QualifiedIdentityPublicKey::from(k),
PrivateKeyData::Encrypted(vec![0x33; 48]),
),
);
ks
}

/// A `KeyStorage` whose keys are all legitimately not-resident: one already
/// vault-backed (`InVault`) and one wallet-derived (`AtWalletDerivationPath`,
/// whose vault scheme is `Absent` by design, not by a failed migration).
Expand Down Expand Up @@ -593,6 +674,22 @@ mod tests {
);
}

/// SEC-001 fail-closed: an identity carrying a legacy `Encrypted` key (no
/// migration path) is rejected with the dedicated
/// [`TaskError::IdentityKeyProtectionLegacyFormat`] — NOT the resident-
/// plaintext `IdentityKeyProtectionIncomplete` — so the user is told to load
/// the identity again rather than uselessly close and reopen.
#[test]
fn protect_rejects_legacy_encrypted_key_with_distinct_error() {
let ks = ks_with_encrypted_legacy();
let err = reject_resident_identity_plaintext(&ks)
.expect_err("legacy Encrypted key must fail closed");
assert!(
matches!(err, TaskError::IdentityKeyProtectionLegacyFormat),
"expected IdentityKeyProtectionLegacyFormat, got {err:?}"
);
}

/// No false positive: an identity whose keys are wallet-derived
/// (`AtWalletDerivationPath`, legitimately `Absent`) or already vault-backed
/// (`InVault`) carries no resident plaintext and is accepted — opt-in must
Expand Down
11 changes: 6 additions & 5 deletions src/backend_task/identity/top_up_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use crate::backend_task::error::TaskError;
use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod};
use crate::backend_task::{BackendTaskSuccessResult, FeeResult};
use crate::context::AppContext;
use crate::model::fee_estimation::PlatformFeeEstimator;
use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0};

impl AppContext {
Expand All @@ -17,7 +16,11 @@ impl AppContext {
} = input;

let balance_before = qualified_identity.identity.balance();
let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup();
// This estimate is shown to the user and feeds the actual-fee
// plausibility band, so it must track the active network fee multiplier —
// use the context estimator rather than the hardcoded default.
let fee_estimator = self.fee_estimator();
let estimated_fee = fee_estimator.estimate_identity_topup();

// Both wallet-funded top-up paths (fresh asset lock or resume from a
// tracked asset lock) run end-to-end through the upstream
Expand Down Expand Up @@ -61,9 +64,7 @@ impl AppContext {

let actual_fee = match amount_duffs_for_fee {
Some(amount) => {
let expected_credits = amount.saturating_mul(1000);
let balance_increase = new_balance.saturating_sub(balance_before);
expected_credits.saturating_sub(balance_increase)
fee_estimator.resolve_identity_topup_actual_fee(amount, balance_before, new_balance)
}
None => estimated_fee,
};
Expand Down
58 changes: 57 additions & 1 deletion src/backend_task/wallet/generate_receive_address.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::backend_task::BackendTaskSuccessResult;
use crate::backend_task::error::TaskError;
use crate::context::AppContext;
use crate::model::wallet::WalletSeedHash;
use std::sync::Arc;
Expand All @@ -8,9 +9,64 @@ impl AppContext {
pub(crate) async fn generate_receive_address(
self: &Arc<Self>,
seed_hash: WalletSeedHash,
) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> {
) -> Result<BackendTaskSuccessResult, TaskError> {
// A seed hash that matches no wallet in the local store is a genuine
// "not found". This is distinct from a known wallet whose backend is
// still loading: the backend reports the latter as the transient,
// retryable `WalletNotLoaded`. Resolving the existence question here,
// where the DET-side wallet store lives, keeps that distinction honest
// instead of collapsing both cases into `WalletNotLoaded`.
if !self.wallets.read()?.contains_key(&seed_hash) {
return Err(TaskError::WalletNotFound);
}
let backend = self.wallet_backend()?;
let address = backend.next_receive_address(&seed_hash).await?;
Ok(BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address })
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::app_dir::ensure_env_file;
use crate::context::connection_status::ConnectionStatus;
use crate::database::test_helpers::create_database_at_path;
use crate::utils::tasks::TaskManager;
use dash_sdk::dpp::dashcore::Network;

/// Regression for #860: a receive-address request for a seed hash that
/// matches no locally-stored wallet must return `WalletNotFound`, NOT the
/// transient `WalletNotLoaded`. The existence check runs before the wallet
/// backend is consulted, so this holds even with no backend wired.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_seed_hash_returns_wallet_not_found() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let data_dir = temp_dir.path().to_path_buf();
ensure_env_file(&data_dir);
let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db"));
let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv");
let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store");
let ctx = AppContext::new(
data_dir,
Network::Testnet,
db,
Arc::new(TaskManager::new()),
Arc::new(ConnectionStatus::new()),
egui::Context::default(),
app_kv,
secret_store,
)
.expect("offline testnet AppContext::new");

// No wallets are loaded, so any seed hash is genuinely unknown.
let unknown: WalletSeedHash = [0xAB; 32];
let err = ctx
.generate_receive_address(unknown)
.await
.expect_err("an unknown seed hash must fail, not succeed");
assert!(
matches!(err, TaskError::WalletNotFound),
"expected WalletNotFound, got {err:?}"
);
}
}
9 changes: 8 additions & 1 deletion src/context/identity_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ fn migrate_keystore_to_vault(
);
return KeystoreMigration::ProtectedSkipped;
}
let before = qi.private_keys.clone();
let mut before = qi.private_keys.clone();
let taken = qi.private_keys.take_plaintext_for_vault();
let view = crate::wallet_backend::IdentityKeyView::new(secret_store, *id);
if let Err(e) = view.store_all(&taken) {
Expand All @@ -322,6 +322,13 @@ fn migrate_keystore_to_vault(
return KeystoreMigration::VaultWriteFailed;
}
let migrated = taken.len();
// The migrated plaintext now lives only in the vault; drop the `taken` copy
// (it zeroizes on drop) so its key bytes do not linger across the DB write.
drop(taken);
// SEC-002: the vault write succeeded — the rollback clone is no longer
// needed. Zeroize its plaintext bytes (Clear/AlwaysClear) before it drops
// so no identity private key lingers in freed heap.
let _ = before.take_plaintext_for_vault();
Comment thread
lklimek marked this conversation as resolved.
if let Err(e) = persist(qi) {
tracing::warn!(
target = "context::identity_db",
Expand Down
Loading
Loading