mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
909eefd06e
commit
325c326c49
11 files changed
+351
-28
No files matched your search
@@ -14,7 +14,7 @@ readme = "../../README.md"
|
||||
documentation = "https://docs.rs/proton-drive-rs"
|
||||
|
||||
[dependencies]
|
||||
proton-sdk = { path = "../proton-sdk", version = "0.7.4" }
|
||||
proton-sdk = { path = "../proton-sdk", version = "0.8.0" }
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -15,7 +15,7 @@ use lru::LruCache;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore, TryAcquireError, mpsc};
|
||||
|
||||
use proton_sdk::account::{AccountClient, KeySalt};
|
||||
use proton_sdk::account::{AccountClient, KeyPassphrases, KeySalt};
|
||||
use proton_sdk::api::ResponseCode;
|
||||
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
|
||||
use proton_sdk::crypto::PrivateKey;
|
||||
@@ -525,7 +525,7 @@ impl ProtonDriveClient {
|
||||
/// password. They unlock the keys all the same, so keep them as secret.
|
||||
pub fn with_key_passphrases(
|
||||
session: &ProtonApiSession,
|
||||
key_passphrases: HashMap<String, Vec<u8>>,
|
||||
key_passphrases: KeyPassphrases,
|
||||
) -> Self {
|
||||
Self::with_account(
|
||||
session,
|
||||
|
||||
@@ -31,6 +31,7 @@ sha2.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
hkdf.workspace = true
|
||||
bcrypt.workspace = true
|
||||
zeroize.workspace = true
|
||||
pgp.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
//! derived in step 1.) Decrypted keys are cached for the lifetime of the client.
|
||||
|
||||
mod dtos;
|
||||
mod passphrases;
|
||||
|
||||
pub use passphrases::KeyPassphrases;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::crypto::{self, PrivateKey, PublicKey};
|
||||
use crate::error::{ProtonError, Result};
|
||||
@@ -79,7 +83,7 @@ pub struct AccountClient {
|
||||
|
||||
struct Inner {
|
||||
http: ApiHttpClient,
|
||||
mailbox_password: Vec<u8>,
|
||||
mailbox_password: Zeroizing<Vec<u8>>,
|
||||
cache: Mutex<Cache>,
|
||||
}
|
||||
|
||||
@@ -89,7 +93,7 @@ struct Cache {
|
||||
/// caller (see [`AccountClient::with_key_salts`]).
|
||||
key_salts: Option<Vec<KeySalt>>,
|
||||
/// key id → passphrase derived from the mailbox password and key salts.
|
||||
key_passphrases: Option<HashMap<String, Vec<u8>>>,
|
||||
key_passphrases: Option<KeyPassphrases>,
|
||||
user_keys: Option<Vec<PrivateKey>>,
|
||||
addresses: Option<Vec<Address>>,
|
||||
address_keys: HashMap<AddressId, Vec<PrivateKey>>,
|
||||
@@ -102,7 +106,7 @@ impl AccountClient {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
http: session.http().clone(),
|
||||
mailbox_password: mailbox_password.into(),
|
||||
mailbox_password: Zeroizing::new(mailbox_password.into()),
|
||||
cache: Mutex::new(Cache::default()),
|
||||
}),
|
||||
}
|
||||
@@ -125,7 +129,7 @@ impl AccountClient {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
http: session.http().clone(),
|
||||
mailbox_password: mailbox_password.into(),
|
||||
mailbox_password: Zeroizing::new(mailbox_password.into()),
|
||||
cache: Mutex::new(Cache {
|
||||
key_salts: Some(key_salts),
|
||||
..Cache::default()
|
||||
@@ -142,12 +146,12 @@ impl AccountClient {
|
||||
/// missing from the map does not unlock, exactly as a key without a salt.
|
||||
pub fn with_key_passphrases(
|
||||
session: &ProtonApiSession,
|
||||
key_passphrases: HashMap<String, Vec<u8>>,
|
||||
key_passphrases: KeyPassphrases,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
http: session.http().clone(),
|
||||
mailbox_password: Vec::new(),
|
||||
mailbox_password: Zeroizing::new(Vec::new()),
|
||||
cache: Mutex::new(Cache {
|
||||
key_passphrases: Some(key_passphrases),
|
||||
..Cache::default()
|
||||
@@ -297,6 +301,19 @@ impl AccountClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Check that this client's password (or passphrases) unlocks the account.
|
||||
///
|
||||
/// Fetches the user's keys once (`core/v4/users`) and unlocks them, nothing
|
||||
/// more: no address is loaded or decrypted. Fails with
|
||||
/// [`ProtonError::KeysLocked`] when the secret is wrong, which is the
|
||||
/// answer a login prompt wants for "try again", and with a transport error
|
||||
/// when Proton could not be reached, which says nothing about the secret.
|
||||
/// The unlocked keys are cached, so a client that passes this has done the
|
||||
/// work its first real call would have.
|
||||
pub async fn unlock(&self) -> Result<()> {
|
||||
self.user_keys().await.map(drop)
|
||||
}
|
||||
|
||||
/// Decrypted user (account) keys.
|
||||
async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
|
||||
{
|
||||
@@ -327,9 +344,14 @@ impl AccountClient {
|
||||
}
|
||||
|
||||
if keys.is_empty() {
|
||||
return Err(ProtonError::invalid_operation(
|
||||
"no active user key could be unlocked",
|
||||
));
|
||||
// Keys the account has, none of which this client's secret opens —
|
||||
// as opposed to an account with no active key at all, which no
|
||||
// password fixes.
|
||||
return Err(if response.user.keys.iter().any(|k| k.is_active()) {
|
||||
ProtonError::KeysLocked
|
||||
} else {
|
||||
ProtonError::invalid_operation("the account has no active user key")
|
||||
});
|
||||
}
|
||||
|
||||
self.inner.cache.lock().await.user_keys = Some(keys.clone());
|
||||
@@ -345,7 +367,7 @@ impl AccountClient {
|
||||
/// itself out of storage. Deriving needs the salts, so on a session
|
||||
/// resumed without them call this right after login, as for
|
||||
/// [`key_salts`](Self::key_salts).
|
||||
pub async fn key_passphrases(&self) -> Result<HashMap<String, Vec<u8>>> {
|
||||
pub async fn key_passphrases(&self) -> Result<KeyPassphrases> {
|
||||
{
|
||||
let cache = self.inner.cache.lock().await;
|
||||
if let Some(passphrases) = &cache.key_passphrases {
|
||||
@@ -354,7 +376,7 @@ impl AccountClient {
|
||||
}
|
||||
|
||||
let salts = self.key_salts().await?;
|
||||
let mut passphrases = HashMap::new();
|
||||
let mut passphrases = KeyPassphrases::new();
|
||||
for salt in &salts {
|
||||
let Some(salt_b64) = &salt.value else {
|
||||
continue;
|
||||
@@ -365,7 +387,7 @@ impl AccountClient {
|
||||
let salt_bytes = decode_base64(salt_b64)?;
|
||||
let passphrase =
|
||||
crypto::derive_key_passphrase(&self.inner.mailbox_password, &salt_bytes)?;
|
||||
passphrases.insert(salt.key_id.clone(), passphrase);
|
||||
passphrases.insert(salt.key_id.clone(), passphrase.to_vec());
|
||||
}
|
||||
|
||||
self.inner.cache.lock().await.key_passphrases = Some(passphrases.clone());
|
||||
@@ -392,7 +414,7 @@ impl AccountClient {
|
||||
let passphrase = match (&key_dto.token, &key_dto.signature) {
|
||||
(Some(token), Some(_signature)) => {
|
||||
match crypto::decrypt_armored_with_keys(token, &user_keys) {
|
||||
Ok(passphrase) => passphrase,
|
||||
Ok(passphrase) => Zeroizing::new(passphrase),
|
||||
Err(e) => {
|
||||
tracing::warn!(key_id = %key_dto.id, error = %e, "failed to decrypt address key token");
|
||||
continue;
|
||||
@@ -400,7 +422,7 @@ impl AccountClient {
|
||||
}
|
||||
}
|
||||
_ => match passphrases.get(key_dto.id.as_str()) {
|
||||
Some(passphrase) => passphrase.clone(),
|
||||
Some(passphrase) => Zeroizing::new(passphrase.to_vec()),
|
||||
None => {
|
||||
tracing::warn!(key_id = %key_dto.id, "no passphrase for address key");
|
||||
continue;
|
||||
@@ -489,10 +511,129 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeded_key_passphrases_are_used_without_deriving_or_a_request() {
|
||||
let passphrases = HashMap::from([("key-1".to_owned(), b"derived".to_vec())]);
|
||||
let passphrases = KeyPassphrases::from_iter([("key-1", b"derived".to_vec())]);
|
||||
let client = AccountClient::with_key_passphrases(&session(), passphrases.clone());
|
||||
// No salts were given and the base URL is unreachable from a test, so
|
||||
// anything but the seeded map would fail here.
|
||||
assert_eq!(client.key_passphrases().await.unwrap(), passphrases);
|
||||
}
|
||||
|
||||
/// A session pointed at a loopback server that answers every request with
|
||||
/// `body`, and the number of requests it has served.
|
||||
async fn session_serving(
|
||||
body: String,
|
||||
) -> (
|
||||
ProtonApiSession,
|
||||
std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let served = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let counter = served.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(head.as_bytes()).await;
|
||||
let _ = sock.write_all(body.as_bytes()).await;
|
||||
let _ = sock.flush().await;
|
||||
}
|
||||
});
|
||||
|
||||
let session = ProtonApiSession::resume(
|
||||
ProtonClientConfiguration::new("test@1.0.0")
|
||||
.with_base_url(format!("http://{addr}/"))
|
||||
.with_retry_policy(crate::config::RetryPolicy::disabled()),
|
||||
ResumeParameters {
|
||||
session_id: "session".to_owned().into(),
|
||||
username: "user".to_owned(),
|
||||
user_id: "user-id".to_owned().into(),
|
||||
access_token: "access".to_owned(),
|
||||
refresh_token: "refresh".to_owned(),
|
||||
scopes: Vec::new(),
|
||||
is_waiting_for_second_factor_code: false,
|
||||
password_mode: PasswordMode::Dual,
|
||||
},
|
||||
)
|
||||
.expect("resume");
|
||||
(session, served)
|
||||
}
|
||||
|
||||
/// The `core/v4/users` answer for one account key locked with `passphrase`.
|
||||
fn user_with_key(armored: &str, active: i32) -> String {
|
||||
serde_json::json!({
|
||||
"Code": 1000,
|
||||
"User": {
|
||||
"ID": "user-id", "MaxSpace": 1, "UsedSpace": 0,
|
||||
"Keys": [{ "ID": "key-1", "PrivateKey": armored, "Primary": 1, "Active": active }]
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_accepts_the_passphrase_that_locks_the_key() {
|
||||
let key = crate::crypto::generate_node_key().unwrap();
|
||||
let (session, served) = session_serving(user_with_key(&key.locked_armored, 1)).await;
|
||||
let passphrases = KeyPassphrases::from_iter([("key-1", key.passphrase.clone())]);
|
||||
|
||||
AccountClient::with_key_passphrases(&session, passphrases)
|
||||
.unlock()
|
||||
.await
|
||||
.expect("right passphrase");
|
||||
assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_reports_a_wrong_passphrase_as_keys_locked() {
|
||||
let key = crate::crypto::generate_node_key().unwrap();
|
||||
let (session, _) = session_serving(user_with_key(&key.locked_armored, 1)).await;
|
||||
let passphrases = KeyPassphrases::from_iter([("key-1", b"not it".to_vec())]);
|
||||
|
||||
let error = AccountClient::with_key_passphrases(&session, passphrases)
|
||||
.unlock()
|
||||
.await
|
||||
.expect_err("wrong passphrase");
|
||||
assert!(matches!(error, ProtonError::KeysLocked), "{error:?}");
|
||||
assert!(!error.is_retriable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_treats_a_missing_passphrase_as_keys_locked() {
|
||||
let key = crate::crypto::generate_node_key().unwrap();
|
||||
let (session, _) = session_serving(user_with_key(&key.locked_armored, 1)).await;
|
||||
|
||||
let error = AccountClient::with_key_passphrases(&session, KeyPassphrases::new())
|
||||
.unlock()
|
||||
.await
|
||||
.expect_err("no passphrase");
|
||||
assert!(matches!(error, ProtonError::KeysLocked), "{error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_without_an_active_key_is_not_a_password_problem() {
|
||||
let key = crate::crypto::generate_node_key().unwrap();
|
||||
let (session, _) = session_serving(user_with_key(&key.locked_armored, 0)).await;
|
||||
let passphrases = KeyPassphrases::from_iter([("key-1", key.passphrase.clone())]);
|
||||
|
||||
let error = AccountClient::with_key_passphrases(&session, passphrases)
|
||||
.unlock()
|
||||
.await
|
||||
.expect_err("no active key");
|
||||
assert!(
|
||||
matches!(error, ProtonError::InvalidOperation(_)),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The derived key passphrases, held as the secrets they are.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Key id → the passphrase that unlocks that key.
|
||||
///
|
||||
/// Each passphrase is `bcrypt(mailbox password, key salt)`, so it unlocks its
|
||||
/// key exactly as the password would, and is as secret. It is also all a client
|
||||
/// needs to *resume*: store these instead of the mailbox password and the
|
||||
/// password itself (which on a single-password account is the login password)
|
||||
/// never has to be kept.
|
||||
///
|
||||
/// The bytes are wiped when the value drops, and [`Debug`](fmt::Debug) prints
|
||||
/// only how many there are, so a stray `{:?}` cannot put them in a log. Cloning
|
||||
/// copies the secrets, and each copy is wiped on its own.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct KeyPassphrases(HashMap<String, Zeroizing<Vec<u8>>>);
|
||||
|
||||
impl KeyPassphrases {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add (or replace) the passphrase for `key_id`.
|
||||
pub fn insert(&mut self, key_id: impl Into<String>, passphrase: impl Into<Vec<u8>>) {
|
||||
self.0
|
||||
.insert(key_id.into(), Zeroizing::new(passphrase.into()));
|
||||
}
|
||||
|
||||
/// The passphrase for `key_id`, if there is one.
|
||||
pub fn get(&self, key_id: &str) -> Option<&[u8]> {
|
||||
self.0.get(key_id).map(|p| p.as_slice())
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Every `(key id, passphrase)`, in no particular order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> {
|
||||
self.0.iter().map(|(id, p)| (id.as_str(), p.as_slice()))
|
||||
}
|
||||
|
||||
/// A 32-byte secret for `purpose`, derived from these passphrases and nothing
|
||||
/// else, for a client that needs a key of its own that only an unlocked
|
||||
/// account can produce (for instance to encrypt a local cache).
|
||||
///
|
||||
/// The same passphrases and `purpose` always give the same secret, and a
|
||||
/// different `purpose` gives an unrelated one, so one secret never doubles as
|
||||
/// another. The result does not depend on insertion order.
|
||||
pub fn derive_secret(&self, purpose: &[u8]) -> Zeroizing<[u8; 32]> {
|
||||
let mut entries: Vec<(&str, &[u8])> = self.iter().collect();
|
||||
entries.sort_unstable_by_key(|(id, _)| *id);
|
||||
|
||||
// Length-prefixed, so ("ab", "c") and ("a", "bc") cannot collide.
|
||||
let mut material = Zeroizing::new(Vec::new());
|
||||
for (id, passphrase) in entries {
|
||||
material.extend_from_slice(&(id.len() as u64).to_be_bytes());
|
||||
material.extend_from_slice(id.as_bytes());
|
||||
material.extend_from_slice(&(passphrase.len() as u64).to_be_bytes());
|
||||
material.extend_from_slice(passphrase);
|
||||
}
|
||||
|
||||
let mut out = Zeroizing::new([0u8; 32]);
|
||||
Hkdf::<Sha256>::new(Some(b"proton-sdk.KeyPassphrases.derive_secret"), &material)
|
||||
.expand(purpose, out.as_mut())
|
||||
.expect("32 bytes is a valid HKDF-SHA256 output length");
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Into<String>, P: Into<Vec<u8>>> FromIterator<(K, P)> for KeyPassphrases {
|
||||
fn from_iter<I: IntoIterator<Item = (K, P)>>(iter: I) -> Self {
|
||||
let mut out = Self::new();
|
||||
for (id, passphrase) in iter {
|
||||
out.insert(id, passphrase);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal when they hold the same passphrases. For tests and change detection,
|
||||
/// not for authenticating anyone, so the comparison is not constant-time.
|
||||
impl PartialEq for KeyPassphrases {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.len() == other.0.len()
|
||||
&& self.0.iter().all(|(id, p)| {
|
||||
other
|
||||
.0
|
||||
.get(id)
|
||||
.is_some_and(|q| p.as_slice() == q.as_slice())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for KeyPassphrases {}
|
||||
|
||||
impl fmt::Debug for KeyPassphrases {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "KeyPassphrases({} redacted)", self.0.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_a_passphrase() {
|
||||
let passphrases = KeyPassphrases::from_iter([("key-1", b"hunter2".to_vec())]);
|
||||
let shown = format!("{passphrases:?}");
|
||||
assert!(!shown.contains("hunter2"));
|
||||
assert!(!shown.contains("key-1"));
|
||||
assert_eq!(shown, "KeyPassphrases(1 redacted)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_ignores_insertion_order() {
|
||||
let a = KeyPassphrases::from_iter([("k1", b"one".to_vec()), ("k2", b"two".to_vec())]);
|
||||
let b = KeyPassphrases::from_iter([("k2", b"two".to_vec()), ("k1", b"one".to_vec())]);
|
||||
assert_eq!(*a.derive_secret(b"cache"), *b.derive_secret(b"cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_depends_on_purpose_and_passphrases() {
|
||||
let a = KeyPassphrases::from_iter([("k1", b"one".to_vec())]);
|
||||
let other = KeyPassphrases::from_iter([("k1", b"two".to_vec())]);
|
||||
assert_ne!(*a.derive_secret(b"cache"), *a.derive_secret(b"other"));
|
||||
assert_ne!(*a.derive_secret(b"cache"), *other.derive_secret(b"cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_cannot_be_forged_by_moving_the_boundary() {
|
||||
let a = KeyPassphrases::from_iter([("ab", b"c".to_vec())]);
|
||||
let b = KeyPassphrases::from_iter([("a", b"bc".to_vec())]);
|
||||
assert_ne!(*a.derive_secret(b"cache"), *b.derive_secret(b"cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_when_they_hold_the_same_passphrases() {
|
||||
let a = KeyPassphrases::from_iter([("k1", b"one".to_vec())]);
|
||||
assert_eq!(a, a.clone());
|
||||
assert_ne!(a, KeyPassphrases::new());
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! in the C# SDK (which calls `SrpClient.HashPassword`).
|
||||
|
||||
use bcrypt::HashParts;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::errors::CryptoError;
|
||||
|
||||
@@ -15,7 +16,10 @@ const BCRYPT_PREFIX_LEN: usize = 29;
|
||||
|
||||
/// Derive the unlocking passphrase for a key from the mailbox password and the
|
||||
/// key's 16-byte salt.
|
||||
pub fn derive_key_passphrase(password: &[u8], salt: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
pub fn derive_key_passphrase(
|
||||
password: &[u8],
|
||||
salt: &[u8],
|
||||
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
|
||||
if salt.len() != 16 {
|
||||
return Err(CryptoError::Unlock(format!(
|
||||
"key salt must be 16 bytes, got {}",
|
||||
@@ -31,12 +35,12 @@ pub fn derive_key_passphrase(password: &[u8], salt: &[u8]) -> Result<Vec<u8>, Cr
|
||||
|
||||
// `$2b$10$<22 char salt><31 char hash>` — drop everything up to and
|
||||
// including the encoded salt to obtain the passphrase bytes.
|
||||
let hash = parts.to_string();
|
||||
let hash = Zeroizing::new(parts.to_string());
|
||||
let passphrase = hash
|
||||
.get(BCRYPT_PREFIX_LEN..)
|
||||
.ok_or_else(|| CryptoError::Unlock("bcrypt hash shorter than expected".into()))?;
|
||||
|
||||
Ok(passphrase.as_bytes().to_vec())
|
||||
Ok(Zeroizing::new(passphrase.as_bytes().to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -23,6 +23,16 @@ pub enum ProtonError {
|
||||
#[error("cryptography error: {0}")]
|
||||
Crypto(#[from] crate::crypto::CryptoError),
|
||||
|
||||
/// The account's keys exist but none could be unlocked with the passphrases
|
||||
/// this client holds: the mailbox password (or the stored passphrases) is
|
||||
/// wrong or stale.
|
||||
///
|
||||
/// Typed apart from [`InvalidOperation`](Self::InvalidOperation) because a
|
||||
/// caller can act on it — ask for the password again — and must not retry
|
||||
/// it: the same secret fails the same way every time.
|
||||
#[error("none of the account's keys could be unlocked with this password")]
|
||||
KeysLocked,
|
||||
|
||||
/// The SDK was used in a way that violates an invariant.
|
||||
#[error("invalid operation: {0}")]
|
||||
InvalidOperation(String),
|
||||
@@ -44,10 +54,14 @@ impl ProtonError {
|
||||
pub fn is_retriable(&self) -> bool {
|
||||
match self {
|
||||
Self::Api(error) => error.is_retriable(),
|
||||
// A serialization or crypto failure is deterministic; an invariant
|
||||
// A serialization or crypto failure is deterministic, a wrong
|
||||
// passphrase fails the same way every time, and an invariant
|
||||
// violation is a bug. Only a transport failure is worth replaying.
|
||||
Self::Transport(_) => true,
|
||||
Self::Serialization(_) | Self::Crypto(_) | Self::InvalidOperation(_) => false,
|
||||
Self::Serialization(_)
|
||||
| Self::Crypto(_)
|
||||
| Self::KeysLocked
|
||||
| Self::InvalidOperation(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user