feat(account): hold key passphrases as wiped secrets and report a locked account as KeysLocked

A wrong mailbox password surfaced as InvalidOperation("no active user key could
be unlocked"), which a caller could only recognise by its text. It is now
ProtonError::KeysLocked, and AccountClient::unlock() checks a secret with a
single core/v4/users request instead of loading every address.

Derived passphrases, the mailbox password and the cache encryption key were
plain Vec<u8>s, cloned freely and never wiped. They are Zeroizing now, and the
passphrase map is KeyPassphrases: wiped on drop, redacted in Debug, with
derive_secret() for a client that wants a key only an unlocked account can
reproduce. with_key_passphrases takes it.

Breaking: ProtonError gains a variant, key_passphrases() and
with_key_passphrases() change type, and derive_key_passphrase returns
Zeroizing<Vec<u8>>.
This commit is contained in:
nvrl committed 2026-10-07 02:24:51 +02:00
1 parent 909eefd06e
commit 325c326c49
11 files changed
+351 -28

No files matched your search

+3 -2
View File
@@ -23,6 +23,7 @@ use async_trait::async_trait;
use base64::Engine;
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::error::{ProtonError, Result};
@@ -289,7 +290,7 @@ impl CacheRepository for InMemoryCacheRepository {
/// (matching the C# behavior).
pub struct EncryptedCacheRepository {
inner: Arc<dyn CacheRepository>,
encryption_key: Vec<u8>,
encryption_key: Zeroizing<Vec<u8>>,
}
const SALT_LEN: usize = 16;
@@ -303,7 +304,7 @@ impl EncryptedCacheRepository {
pub fn new(inner: Arc<dyn CacheRepository>, encryption_key: impl Into<Vec<u8>>) -> Self {
Self {
inner,
encryption_key: encryption_key.into(),
encryption_key: Zeroizing::new(encryption_key.into()),
}
}