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
Generated
+3
-2
@@ -2060,7 +2060,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -2085,7 +2085,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -2112,6 +2112,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/proton-sdk", "crates/proton-drive-rs"]
|
members = ["crates/proton-sdk", "crates/proton-drive-rs"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-sdk-rs"
|
repository = "https://github.com/narrrl/proton-sdk-rs"
|
||||||
@@ -43,6 +43,7 @@ sha1 = "0.11"
|
|||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
aes-gcm = "0.11"
|
aes-gcm = "0.11"
|
||||||
hkdf = "0.13"
|
hkdf = "0.13"
|
||||||
|
zeroize = "1"
|
||||||
bcrypt = "0.19"
|
bcrypt = "0.19"
|
||||||
pgp = "0.21"
|
pgp = "0.21"
|
||||||
# Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets.
|
# Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets.
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ graph TD
|
|||||||
3. **Share & Node Keys**: Nodes (files and folders) belong to shares. A share's passphrase is decrypted by the address key. The decrypted share passphrase is used to unlock the node's private key (usually X25519/Ed25519 or legacy RSA).
|
3. **Share & Node Keys**: Nodes (files and folders) belong to shares. A share's passphrase is decrypted by the address key. The decrypted share passphrase is used to unlock the node's private key (usually X25519/Ed25519 or legacy RSA).
|
||||||
4. **File Content Keys**: Files contain metadata and data blocks. The file's name and passphrase are encrypted. The symmetric **Content Key** (AES-256) is encapsulated in a PGP Public-Key Encrypted Session-Key (PKESK) packet addressed to the node key. See [content.rs](file:///home/narl/dev/private/proton-sdk-rs/crates/proton-sdk/src/crypto/content.rs).
|
4. **File Content Keys**: Files contain metadata and data blocks. The file's name and passphrase are encrypted. The symmetric **Content Key** (AES-256) is encapsulated in a PGP Public-Key Encrypted Session-Key (PKESK) packet addressed to the node key. See [content.rs](file:///home/narl/dev/private/proton-sdk-rs/crates/proton-sdk/src/crypto/content.rs).
|
||||||
|
|
||||||
|
#### Resuming without the password
|
||||||
|
|
||||||
|
A client that persists a session does not have to keep the mailbox password (on a single-password account, the login password). Capture `AccountClient::key_passphrases()` right after login and resume with `ProtonDriveClient::with_key_passphrases()`. A `KeyPassphrases` is wiped when dropped and prints as `KeyPassphrases(n redacted)`, but each passphrase unlocks its key, so store it as carefully as the password. `KeyPassphrases::derive_secret(purpose)` gives a 32-byte secret only an unlocked account can reproduce, for encrypting local state.
|
||||||
|
|
||||||
|
`AccountClient::unlock()` checks a password or a set of passphrases with one request. A secret that does not fit fails with `ProtonError::KeysLocked`, which a login prompt should answer by asking again, never by retrying.
|
||||||
|
|
||||||
### 2. Upload Pipeline
|
### 2. Upload Pipeline
|
||||||
|
|
||||||
When uploading a file:
|
When uploading a file:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ readme = "../../README.md"
|
|||||||
documentation = "https://docs.rs/proton-drive-rs"
|
documentation = "https://docs.rs/proton-drive-rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
proton-sdk = { path = "../proton-sdk", version = "0.7.4" }
|
proton-sdk = { path = "../proton-sdk", version = "0.8.0" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use lru::LruCache;
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore, TryAcquireError, mpsc};
|
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::api::ResponseCode;
|
||||||
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
|
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
|
||||||
use proton_sdk::crypto::PrivateKey;
|
use proton_sdk::crypto::PrivateKey;
|
||||||
@@ -525,7 +525,7 @@ impl ProtonDriveClient {
|
|||||||
/// password. They unlock the keys all the same, so keep them as secret.
|
/// password. They unlock the keys all the same, so keep them as secret.
|
||||||
pub fn with_key_passphrases(
|
pub fn with_key_passphrases(
|
||||||
session: &ProtonApiSession,
|
session: &ProtonApiSession,
|
||||||
key_passphrases: HashMap<String, Vec<u8>>,
|
key_passphrases: KeyPassphrases,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::with_account(
|
Self::with_account(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ sha2.workspace = true
|
|||||||
aes-gcm.workspace = true
|
aes-gcm.workspace = true
|
||||||
hkdf.workspace = true
|
hkdf.workspace = true
|
||||||
bcrypt.workspace = true
|
bcrypt.workspace = true
|
||||||
|
zeroize.workspace = true
|
||||||
pgp.workspace = true
|
pgp.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -11,11 +11,15 @@
|
|||||||
//! derived in step 1.) Decrypted keys are cached for the lifetime of the client.
|
//! derived in step 1.) Decrypted keys are cached for the lifetime of the client.
|
||||||
|
|
||||||
mod dtos;
|
mod dtos;
|
||||||
|
mod passphrases;
|
||||||
|
|
||||||
|
pub use passphrases::KeyPassphrases;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::crypto::{self, PrivateKey, PublicKey};
|
use crate::crypto::{self, PrivateKey, PublicKey};
|
||||||
use crate::error::{ProtonError, Result};
|
use crate::error::{ProtonError, Result};
|
||||||
@@ -79,7 +83,7 @@ pub struct AccountClient {
|
|||||||
|
|
||||||
struct Inner {
|
struct Inner {
|
||||||
http: ApiHttpClient,
|
http: ApiHttpClient,
|
||||||
mailbox_password: Vec<u8>,
|
mailbox_password: Zeroizing<Vec<u8>>,
|
||||||
cache: Mutex<Cache>,
|
cache: Mutex<Cache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +93,7 @@ struct Cache {
|
|||||||
/// caller (see [`AccountClient::with_key_salts`]).
|
/// caller (see [`AccountClient::with_key_salts`]).
|
||||||
key_salts: Option<Vec<KeySalt>>,
|
key_salts: Option<Vec<KeySalt>>,
|
||||||
/// key id → passphrase derived from the mailbox password and key salts.
|
/// 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>>,
|
user_keys: Option<Vec<PrivateKey>>,
|
||||||
addresses: Option<Vec<Address>>,
|
addresses: Option<Vec<Address>>,
|
||||||
address_keys: HashMap<AddressId, Vec<PrivateKey>>,
|
address_keys: HashMap<AddressId, Vec<PrivateKey>>,
|
||||||
@@ -102,7 +106,7 @@ impl AccountClient {
|
|||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Inner {
|
inner: Arc::new(Inner {
|
||||||
http: session.http().clone(),
|
http: session.http().clone(),
|
||||||
mailbox_password: mailbox_password.into(),
|
mailbox_password: Zeroizing::new(mailbox_password.into()),
|
||||||
cache: Mutex::new(Cache::default()),
|
cache: Mutex::new(Cache::default()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -125,7 +129,7 @@ impl AccountClient {
|
|||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Inner {
|
inner: Arc::new(Inner {
|
||||||
http: session.http().clone(),
|
http: session.http().clone(),
|
||||||
mailbox_password: mailbox_password.into(),
|
mailbox_password: Zeroizing::new(mailbox_password.into()),
|
||||||
cache: Mutex::new(Cache {
|
cache: Mutex::new(Cache {
|
||||||
key_salts: Some(key_salts),
|
key_salts: Some(key_salts),
|
||||||
..Cache::default()
|
..Cache::default()
|
||||||
@@ -142,12 +146,12 @@ impl AccountClient {
|
|||||||
/// missing from the map does not unlock, exactly as a key without a salt.
|
/// missing from the map does not unlock, exactly as a key without a salt.
|
||||||
pub fn with_key_passphrases(
|
pub fn with_key_passphrases(
|
||||||
session: &ProtonApiSession,
|
session: &ProtonApiSession,
|
||||||
key_passphrases: HashMap<String, Vec<u8>>,
|
key_passphrases: KeyPassphrases,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Inner {
|
inner: Arc::new(Inner {
|
||||||
http: session.http().clone(),
|
http: session.http().clone(),
|
||||||
mailbox_password: Vec::new(),
|
mailbox_password: Zeroizing::new(Vec::new()),
|
||||||
cache: Mutex::new(Cache {
|
cache: Mutex::new(Cache {
|
||||||
key_passphrases: Some(key_passphrases),
|
key_passphrases: Some(key_passphrases),
|
||||||
..Cache::default()
|
..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.
|
/// Decrypted user (account) keys.
|
||||||
async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
|
async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
|
||||||
{
|
{
|
||||||
@@ -327,9 +344,14 @@ impl AccountClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if keys.is_empty() {
|
if keys.is_empty() {
|
||||||
return Err(ProtonError::invalid_operation(
|
// Keys the account has, none of which this client's secret opens —
|
||||||
"no active user key could be unlocked",
|
// 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());
|
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
|
/// itself out of storage. Deriving needs the salts, so on a session
|
||||||
/// resumed without them call this right after login, as for
|
/// resumed without them call this right after login, as for
|
||||||
/// [`key_salts`](Self::key_salts).
|
/// [`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;
|
let cache = self.inner.cache.lock().await;
|
||||||
if let Some(passphrases) = &cache.key_passphrases {
|
if let Some(passphrases) = &cache.key_passphrases {
|
||||||
@@ -354,7 +376,7 @@ impl AccountClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let salts = self.key_salts().await?;
|
let salts = self.key_salts().await?;
|
||||||
let mut passphrases = HashMap::new();
|
let mut passphrases = KeyPassphrases::new();
|
||||||
for salt in &salts {
|
for salt in &salts {
|
||||||
let Some(salt_b64) = &salt.value else {
|
let Some(salt_b64) = &salt.value else {
|
||||||
continue;
|
continue;
|
||||||
@@ -365,7 +387,7 @@ impl AccountClient {
|
|||||||
let salt_bytes = decode_base64(salt_b64)?;
|
let salt_bytes = decode_base64(salt_b64)?;
|
||||||
let passphrase =
|
let passphrase =
|
||||||
crypto::derive_key_passphrase(&self.inner.mailbox_password, &salt_bytes)?;
|
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());
|
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) {
|
let passphrase = match (&key_dto.token, &key_dto.signature) {
|
||||||
(Some(token), Some(_signature)) => {
|
(Some(token), Some(_signature)) => {
|
||||||
match crypto::decrypt_armored_with_keys(token, &user_keys) {
|
match crypto::decrypt_armored_with_keys(token, &user_keys) {
|
||||||
Ok(passphrase) => passphrase,
|
Ok(passphrase) => Zeroizing::new(passphrase),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(key_id = %key_dto.id, error = %e, "failed to decrypt address key token");
|
tracing::warn!(key_id = %key_dto.id, error = %e, "failed to decrypt address key token");
|
||||||
continue;
|
continue;
|
||||||
@@ -400,7 +422,7 @@ impl AccountClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => match passphrases.get(key_dto.id.as_str()) {
|
_ => match passphrases.get(key_dto.id.as_str()) {
|
||||||
Some(passphrase) => passphrase.clone(),
|
Some(passphrase) => Zeroizing::new(passphrase.to_vec()),
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(key_id = %key_dto.id, "no passphrase for address key");
|
tracing::warn!(key_id = %key_dto.id, "no passphrase for address key");
|
||||||
continue;
|
continue;
|
||||||
@@ -489,10 +511,129 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn seeded_key_passphrases_are_used_without_deriving_or_a_request() {
|
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());
|
let client = AccountClient::with_key_passphrases(&session(), passphrases.clone());
|
||||||
// No salts were given and the base URL is unreachable from a test, so
|
// No salts were given and the base URL is unreachable from a test, so
|
||||||
// anything but the seeded map would fail here.
|
// anything but the seeded map would fail here.
|
||||||
assert_eq!(client.key_passphrases().await.unwrap(), passphrases);
|
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 base64::Engine;
|
||||||
use hkdf::Hkdf;
|
use hkdf::Hkdf;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::error::{ProtonError, Result};
|
use crate::error::{ProtonError, Result};
|
||||||
|
|
||||||
@@ -289,7 +290,7 @@ impl CacheRepository for InMemoryCacheRepository {
|
|||||||
/// (matching the C# behavior).
|
/// (matching the C# behavior).
|
||||||
pub struct EncryptedCacheRepository {
|
pub struct EncryptedCacheRepository {
|
||||||
inner: Arc<dyn CacheRepository>,
|
inner: Arc<dyn CacheRepository>,
|
||||||
encryption_key: Vec<u8>,
|
encryption_key: Zeroizing<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SALT_LEN: usize = 16;
|
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 {
|
pub fn new(inner: Arc<dyn CacheRepository>, encryption_key: impl Into<Vec<u8>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner,
|
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`).
|
//! in the C# SDK (which calls `SrpClient.HashPassword`).
|
||||||
|
|
||||||
use bcrypt::HashParts;
|
use bcrypt::HashParts;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use super::errors::CryptoError;
|
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
|
/// Derive the unlocking passphrase for a key from the mailbox password and the
|
||||||
/// key's 16-byte salt.
|
/// 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 {
|
if salt.len() != 16 {
|
||||||
return Err(CryptoError::Unlock(format!(
|
return Err(CryptoError::Unlock(format!(
|
||||||
"key salt must be 16 bytes, got {}",
|
"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
|
// `$2b$10$<22 char salt><31 char hash>` — drop everything up to and
|
||||||
// including the encoded salt to obtain the passphrase bytes.
|
// including the encoded salt to obtain the passphrase bytes.
|
||||||
let hash = parts.to_string();
|
let hash = Zeroizing::new(parts.to_string());
|
||||||
let passphrase = hash
|
let passphrase = hash
|
||||||
.get(BCRYPT_PREFIX_LEN..)
|
.get(BCRYPT_PREFIX_LEN..)
|
||||||
.ok_or_else(|| CryptoError::Unlock("bcrypt hash shorter than expected".into()))?;
|
.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ pub enum ProtonError {
|
|||||||
#[error("cryptography error: {0}")]
|
#[error("cryptography error: {0}")]
|
||||||
Crypto(#[from] crate::crypto::CryptoError),
|
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.
|
/// The SDK was used in a way that violates an invariant.
|
||||||
#[error("invalid operation: {0}")]
|
#[error("invalid operation: {0}")]
|
||||||
InvalidOperation(String),
|
InvalidOperation(String),
|
||||||
@@ -44,10 +54,14 @@ impl ProtonError {
|
|||||||
pub fn is_retriable(&self) -> bool {
|
pub fn is_retriable(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Api(error) => error.is_retriable(),
|
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.
|
// violation is a bug. Only a transport failure is worth replaying.
|
||||||
Self::Transport(_) => true,
|
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