feature (devices+sharing) + upgraded rand and aeds

This commit is contained in:
nvrl committed 2026-07-15 19:34:40 +02:00
1 parent 78567bf00e
commit 64f977edb3
19 files changed
+2830 -93

No files matched your search

+1
View File
@@ -37,6 +37,7 @@ num-bigint.workspace = true
num-traits.workspace = true
getrandom.workspace = true
rand.workspace = true
rand_08.workspace = true
[dev-dependencies]
# A one-shot loopback HTTP server backs the telemetry-instrumentation test;
+37
View File
@@ -43,6 +43,31 @@ pub struct Address {
pub primary_key_id: AddressKeyId,
}
/// Total account storage usage, in bytes (all Proton products, not Drive-only).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Quota {
/// Total storage available to the account.
pub max_space: i64,
/// Storage currently used.
pub used_space: i64,
}
impl Quota {
/// Bytes still free (never negative).
pub fn available(&self) -> i64 {
(self.max_space - self.used_space).max(0)
}
/// Fraction of the quota in use, `0.0..=1.0` (0 when `max_space` is 0).
pub fn used_fraction(&self) -> f64 {
if self.max_space <= 0 {
0.0
} else {
(self.used_space as f64 / self.max_space as f64).clamp(0.0, 1.0)
}
}
}
/// Resolves account keys needed to decrypt Drive metadata.
///
/// Construction takes the mailbox (data) password because, per Proton's key
@@ -238,6 +263,18 @@ impl AccountClient {
keys
}
/// Total account storage usage (`core/v4/users`).
///
/// Account-wide `MaxSpace`/`UsedSpace` across all Proton products, not the
/// Drive-only quota. Not cached — reflects live usage on each call.
pub async fn quota(&self) -> Result<Quota> {
let response: UserResponse = self.inner.http.get("core/v4/users").await?;
Ok(Quota {
max_space: response.user.max_space,
used_space: response.user.used_space,
})
}
/// Decrypted user (account) keys.
async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
{
+6
View File
@@ -20,6 +20,12 @@ pub(super) struct UserResponse {
pub(super) struct UserDto {
#[serde(rename = "ID")]
pub id: UserId,
/// Total account storage in bytes (all Proton products).
#[serde(rename = "MaxSpace")]
pub max_space: i64,
/// Used account storage in bytes.
#[serde(rename = "UsedSpace")]
pub used_space: i64,
#[serde(rename = "Keys")]
pub keys: Vec<UserKeyDto>,
}
+3 -3
View File
@@ -232,7 +232,7 @@ impl EncryptedCacheRepository {
fn encrypt(&self, entry_key: &str, plaintext: &str) -> Result<String> {
let mut salt = [0u8; SALT_LEN];
getrandom::getrandom(&mut salt)
getrandom::fill(&mut salt)
.map_err(|e| ProtonError::invalid_operation(format!("cache salt: {e}")))?;
let (key, nonce) = self.derive(&salt, entry_key)?;
let cipher = Aes256Gcm::new_from_slice(&key)
@@ -241,7 +241,7 @@ impl EncryptedCacheRepository {
// `ciphertext || tag` — matching the C# `[salt][ciphertext][tag]` layout.
let sealed = cipher
.encrypt(
Nonce::from_slice(&nonce),
&Nonce::from(nonce),
Payload {
msg: plaintext.as_bytes(),
aad: &[],
@@ -268,7 +268,7 @@ impl EncryptedCacheRepository {
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| ProtonError::invalid_operation(format!("cache cipher: {e}")))?;
match cipher.decrypt(
Nonce::from_slice(&nonce),
&Nonce::from(nonce),
Payload {
msg: sealed,
aad: &[],
+5 -3
View File
@@ -17,12 +17,14 @@ mod verify;
pub use content::ContentKey;
pub use derive::derive_key_passphrase;
pub use encrypt::{
GeneratedNodeKey, VolumeCreationMaterial, build_volume_creation_material,
generate_node_hash_key, generate_node_key, generate_node_key_aead,
GeneratedNodeKey, SHARING_EXTERNAL_INVITATION_CONTEXT, SHARING_INVITER_CONTEXT,
SHARING_MEMBER_CONTEXT, StandardShareMaterial, VolumeCreationMaterial, accept_invitation,
build_standard_share_material, build_volume_creation_material, encrypt_external_invitation,
encrypt_invitation, generate_node_hash_key, generate_node_key, generate_node_key_aead,
};
pub use errors::CryptoError;
pub use keys::{PrivateKey, decrypt_armored_with_keys};
pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, generate_proofs};
pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier};
pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached};
/// Result alias for crypto operations.
+287 -12
View File
@@ -15,13 +15,16 @@ use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{
LiteralData, Packet, PacketParser, PacketTrait, PublicKeyEncryptedSessionKey,
SymEncryptedProtectedData,
SymEncryptedProtectedData, SymKeyEncryptedSessionKey,
};
use pgp::types::{
DecryptionKey, EncryptionKey, EskType, Password, PkeskVersion, Seipdv1ReadMode, StringToKey,
};
use pgp::types::{DecryptionKey, EncryptionKey, EskType, PkeskVersion, Seipdv1ReadMode};
use super::encrypt::{RecipientOp, recipient_encryption_key};
use super::encrypt::{RecipientOp, recipient_encryption_key, recipient_encryption_key_public};
use super::errors::CryptoError;
use super::keys::PrivateKey;
use super::verify::PublicKey;
/// The AEAD algorithm Proton uses for SEIPDv2 content blocks: AES-256-GCM.
/// Encrypted v6 session keys carry no algorithm info, so it is fixed here (the
@@ -47,7 +50,7 @@ impl ContentKey {
///
/// Mirrors C# `PgpSessionKey.Generate()`.
pub fn generate() -> Self {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let sym_alg = SymmetricKeyAlgorithm::AES256;
let key = sym_alg.new_session_key(&mut rng);
ContentKey {
@@ -61,7 +64,7 @@ impl ContentKey {
/// content key packet as a v6 PKESK. Mirrors C#
/// `PgpSessionKey.GenerateForAead()`.
pub fn generate_aead() -> Self {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng);
ContentKey {
session_key: PlainSessionKey::V6 { key },
@@ -106,7 +109,7 @@ impl ContentKey {
signer: &PrivateKey,
plaintext: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
// Build a signed (but unencrypted) message: one-pass signature, literal
// data, then the signature packet. `seipd_*` would generate its own
@@ -131,7 +134,7 @@ impl ContentKey {
/// [`encrypt_thumbnail`](Self::encrypt_thumbnail).
fn seal_seipd(&self, inner: &[u8]) -> Result<Vec<u8>, CryptoError> {
let (sym_alg, key) = self.session_parts()?;
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let seipd = if self.is_aead() {
SymEncryptedProtectedData::encrypt_seipdv2(
@@ -175,6 +178,146 @@ impl ContentKey {
Ok(out)
}
/// Encrypt this session key to a recipient **public** key as a PKESK.
///
/// Used for the sharing invitation `KeyPacket`: the share's passphrase
/// session key delivered to an invitee whose public key we resolved via
/// `core/v4/keys/all`. Mirrors JS `encryptSessionKey(shareSessionKey,
/// inviteePublicKey)`.
pub fn to_packet_for_public(&self, pubkey: &PublicKey) -> Result<Vec<u8>, CryptoError> {
let (sym_alg, key) = self.session_parts()?;
let packet = if self.is_aead() {
recipient_encryption_key_public(pubkey.signed(), ContentKeyPacketV6Op { key })?
} else {
recipient_encryption_key_public(pubkey.signed(), ContentKeyPacketOp { sym_alg, key })?
};
let mut out = Vec::new();
packet
.to_writer_with_header(&mut out)
.map_err(|e| CryptoError::Encrypt(format!("serialize session key packet: {e}")))?;
Ok(out)
}
/// Encrypt `data` to one or more recipient keys under this session key,
/// returning an armored PGP message: a PKESK per recipient followed by the
/// SEIPD body. No inline signature — Proton signs share/node passphrases with
/// a *detached* signature carried separately.
///
/// Used to build a standard share's `SharePassphrase`, which must be
/// decryptable by both the node key and the owning address key (JS
/// `generateKey([nodeKey, addressKey], addressKey)`), while keeping this
/// session key so it can later be handed to invitees.
pub fn encrypt_message_to(
&self,
recipients: &[&PrivateKey],
data: &[u8],
) -> Result<String, CryptoError> {
if recipients.is_empty() {
return Err(CryptoError::Encrypt(
"encrypt_message_to needs at least one recipient".into(),
));
}
let mut message = Vec::new();
for recipient in recipients {
message.extend_from_slice(&self.to_packet(recipient)?);
}
message.extend_from_slice(&self.encrypt_block(data)?);
let mut out = Vec::new();
pgp::armor::write(
&RawPackets(&message),
pgp::armor::BlockType::Message,
&mut out,
None,
true,
)
.map_err(|e| CryptoError::Encrypt(format!("armor message: {e}")))?;
String::from_utf8(out)
.map_err(|e| CryptoError::Encrypt(format!("armored message is not utf8: {e}")))
}
/// Encrypt and inline-sign `data` as **text** under *this* session key,
/// wrapped to `recipient`, returning an armored PGP message (PKESK + SEIPD).
///
/// Unlike [`PrivateKey::encrypt_and_sign`](crate::crypto::PrivateKey::encrypt_and_sign),
/// which mints a fresh session key, this reuses the session key already held
/// here. Renaming a device's root folder needs exactly this: Proton's rename
/// endpoint keeps the link's existing name *key packet* and replaces only the
/// *data packet*, so the new name text must be encrypted under the **same**
/// session key as the current name — otherwise the retained key packet can no
/// longer decrypt the data packet (MDC failure). Mirrors C#
/// `DeviceCrypto.GetRenameRequest` /
/// `PgpEncrypter.EncryptAndSignText(name, EncryptionSecrets(shareKey, nameSessionKey), signingKey)`,
/// where `nameSessionKey` is recovered from the current name via
/// [`PrivateKey::recover_message_session_key`](crate::crypto::PrivateKey::recover_message_session_key).
pub fn encrypt_and_sign_text_to(
&self,
recipient: &PrivateKey,
signer: &PrivateKey,
data: &[u8],
) -> Result<String, CryptoError> {
let mut rng = rand_08::thread_rng();
// Build a signed (but unencrypted) *text* message — one-pass signature,
// text literal, then the signature packet — then seal it under this
// session key (as [`encrypt_thumbnail`](Self::encrypt_thumbnail) does).
let mut builder = MessageBuilder::from_bytes(bytes::Bytes::new(), data.to_vec());
builder.sign_text();
builder.sign(
&signer.key().primary_key,
signer.password(),
HashAlgorithm::Sha256,
);
let inner = builder
.to_vec(&mut rng)
.map_err(|e| CryptoError::Encrypt(format!("sign name: {e}")))?;
let mut message = self.to_packet(recipient)?;
message.extend_from_slice(&self.seal_seipd(&inner)?);
let mut out = Vec::new();
pgp::armor::write(
&RawPackets(&message),
pgp::armor::BlockType::Message,
&mut out,
None,
true,
)
.map_err(|e| CryptoError::Encrypt(format!("armor message: {e}")))?;
String::from_utf8(out)
.map_err(|e| CryptoError::Encrypt(format!("armored message is not utf8: {e}")))
}
/// Encrypt this session key **symmetrically** under a password as a v4 SKESK
/// packet, returning the raw packet bytes.
///
/// Used for a public share link's `SharePassphraseKeyPacket`: the share
/// session key wrapped with the SRP-salted passphrase so anyone with the link
/// password can open it. The S2K (salt + iteration count) travels inside the
/// packet, so the recipient re-derives the key from the same password. Mirrors
/// JS `encryptSessionKeyWithPassword`.
pub fn encrypt_with_password(&self, password: &[u8]) -> Result<Vec<u8>, CryptoError> {
let (sym_alg, key) = self.session_parts()?;
let mut rng = rand_08::thread_rng();
let s2k = StringToKey::new_default(&mut rng);
let skesk = SymKeyEncryptedSessionKey::encrypt_v4(
&Password::from(password),
&RawSessionKey::from(key),
s2k,
sym_alg,
)
.map_err(|e| CryptoError::Encrypt(format!("skesk encrypt: {e}")))?;
let mut out = Vec::new();
skesk
.to_writer_with_header(&mut out)
.map_err(|e| CryptoError::Encrypt(format!("serialize skesk: {e}")))?;
Ok(out)
}
/// The raw symmetric key bytes of this content key.
///
/// Mirrors C# `PgpSessionKey.Export()`; used to produce the file's
@@ -350,6 +493,23 @@ impl PrivateKey {
/// parent → new parent), so the locked node key still unlocks and the node's
/// `NodePassphraseSignature` need not be reissued. Mirrors C# move's
/// `destinationKey.EncryptSessionKey(currentKey.DecryptSessionKey(passphrase))`.
/// Recover the session key of an armored PGP message addressed to this key.
///
/// De-armors the message and decrypts its leading PKESK to recover the
/// symmetric session key (the trailing SEIPD is ignored). Used when creating
/// a share to lift a node's passphrase/name session keys so they can be
/// re-encrypted to the share key (JS `getNodePrivateAndSessionKeys`).
pub fn recover_message_session_key(
&self,
armored_message: &str,
) -> Result<ContentKey, CryptoError> {
let mut raw = Vec::new();
let mut dearmor = Dearmor::new(Cursor::new(armored_message.as_bytes()));
std::io::Read::read_to_end(&mut dearmor, &mut raw)
.map_err(|e| CryptoError::Parse(format!("de-armor message: {e}")))?;
self.decrypt_content_key(&raw)
}
pub fn rewrap_message_to(
&self,
armored_message: &str,
@@ -430,7 +590,7 @@ impl RecipientOp for ContentKeyPacketOp<'_> {
type Out = PublicKeyEncryptedSessionKey;
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<Self::Out> {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let session_key = RawSessionKey::from(self.key);
PublicKeyEncryptedSessionKey::from_session_key_v3(
&mut rng,
@@ -451,7 +611,7 @@ impl RecipientOp for ContentKeyPacketV6Op<'_> {
type Out = PublicKeyEncryptedSessionKey;
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<Self::Out> {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let session_key = RawSessionKey::from(self.key);
PublicKeyEncryptedSessionKey::from_session_key_v6(&mut rng, &session_key, pubkey)
}
@@ -468,7 +628,7 @@ mod tests {
/// Generate a passphrase-locked RSA key able to both sign and encrypt — a
/// stand-in for a Proton node key (single primary, no subkey).
fn generate_node_key(passphrase: &str) -> SignedSecretKey {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let params = SecretKeyParamsBuilder::default()
.key_type(KeyType::Rsa(2048))
.can_sign(true)
@@ -483,7 +643,7 @@ mod tests {
#[test]
fn content_key_packet_and_block_round_trip() {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let signed = generate_node_key("pw");
let armored = signed
.to_armored_string(None.into())
@@ -513,6 +673,42 @@ mod tests {
assert_eq!(decrypted, plaintext);
}
#[test]
fn content_key_password_skesk_round_trip() {
// Public-link path: wrap the share session key under a password as a
// SKESK, then re-derive the key from the same password (via the packet's
// own S2K) and recover the session key.
let content_key = ContentKey::generate();
let password = b"the-link-password";
let skesk_bytes = content_key
.encrypt_with_password(password)
.expect("skesk encrypt");
// The bytes are a bare SKESK (no data packet); parse the packet directly.
let mut parser = PacketParser::new(Cursor::new(&skesk_bytes));
let packet = parser.next().expect("a packet").expect("valid packet");
let Packet::SymKeyEncryptedSessionKey(skesk) = packet else {
panic!("expected a SKESK packet");
};
// Re-derive the symmetric key from the password using the packet's S2K,
// then decrypt the wrapped session key.
let s2k = skesk.s2k().expect("skesk has s2k");
let alg = SymmetricKeyAlgorithm::AES256;
let derived = s2k
.derive_key(&password[..], alg.key_size())
.expect("derive key");
let recovered = skesk.decrypt(derived).expect("decrypt skesk");
match &recovered {
PlainSessionKey::V3_4 { key, .. } => {
assert_eq!(key.as_ref(), content_key.export().unwrap());
}
other => panic!("unexpected session key variant: {other:?}"),
}
}
#[test]
fn generated_content_key_round_trip() {
// Upload path: generate a content key, seal it to the node key as a
@@ -559,6 +755,85 @@ mod tests {
assert_eq!(recovered.decrypt_block(&block).expect("decrypt"), plaintext);
}
#[test]
fn rename_reusing_session_key_survives_key_packet_retention() {
// Device rename: Proton keeps the link's existing name *key packet* (PKESK)
// and swaps only its *data packet* (SEIPD). Reproduce that here — encrypt a
// fresh name to the share key, recover its session key, encrypt a new name
// under that same session key, then reassemble the message as the server
// would (original key packet + new data packet) and confirm it decrypts.
let share = super::super::encrypt::generate_node_key().expect("generate share key");
let signer = super::super::encrypt::generate_node_key().expect("generate signer");
// The original name, encrypted to the share key with a fresh session key.
let original = share
.key
.encrypt_and_sign(&signer.key, b"my-device", true, false)
.expect("encrypt original name");
assert_eq!(
share
.key
.decrypt_armored_message(&original)
.expect("decrypt original"),
b"my-device"
);
// Recover that session key and re-encrypt the new name under it.
let name_session_key = share
.key
.recover_message_session_key(&original)
.expect("recover name session key");
let renamed = name_session_key
.encrypt_and_sign_text_to(&share.key, &signer.key, b"my-device-renamed")
.expect("encrypt renamed");
// A naive re-encrypt would round-trip on its own, but the server discards
// our key packet. Reassemble original-PKESK + renamed-SEIPD and decrypt.
let original_pkesk = {
let mut raw = Vec::new();
let mut dearmor = Dearmor::new(Cursor::new(original.as_bytes()));
std::io::Read::read_to_end(&mut dearmor, &mut raw).expect("de-armor original");
let mut parser = PacketParser::new(Cursor::new(&raw));
let packet = parser.next().expect("a packet").expect("valid packet");
let Packet::PublicKeyEncryptedSessionKey(pkesk) = packet else {
panic!("expected a leading PKESK");
};
let mut out = Vec::new();
pkesk
.to_writer_with_header(&mut out)
.expect("serialize pkesk");
out
};
let renamed_seipd = {
let mut raw = Vec::new();
let mut dearmor = Dearmor::new(Cursor::new(renamed.as_bytes()));
std::io::Read::read_to_end(&mut dearmor, &mut raw).expect("de-armor renamed");
serialize_seipd(&raw).expect("serialize renamed seipd")
};
let mut server_stored = original_pkesk;
server_stored.extend_from_slice(&renamed_seipd);
let mut armored = Vec::new();
pgp::armor::write(
&RawPackets(&server_stored),
pgp::armor::BlockType::Message,
&mut armored,
None,
true,
)
.expect("armor server-stored message");
let armored = String::from_utf8(armored).expect("utf8");
assert_eq!(
share
.key
.decrypt_armored_message(&armored)
.expect("decrypt server-reassembled renamed name"),
b"my-device-renamed",
"reusing the recovered session key must keep the retained key packet valid"
);
}
#[test]
fn thumbnail_encrypt_inline_signed_round_trip() {
// A thumbnail block is encrypt-and-inline-signed under the content key.
@@ -698,7 +973,7 @@ mod tests {
#[test]
fn rejects_non_pkesk_content_key_packet() {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let key = PrivateKey::from_armored(
&generate_node_key("pw")
.to_armored_string(None.into())
+393 -9
View File
@@ -11,13 +11,13 @@ use base64::engine::general_purpose::STANDARD as BASE64;
use bytes::Bytes;
use pgp::composed::{
ArmorOptions, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder,
SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder,
SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey, SubkeyParamsBuilder,
};
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::packet::{Notation, SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::types::{CompressionAlgorithm, EncryptionKey, KeyDetails, KeyVersion};
use rand::RngCore;
use rand_08::RngCore;
use super::errors::CryptoError;
use super::keys::PrivateKey;
@@ -64,6 +64,33 @@ pub(crate) fn recipient_encryption_key<Op: RecipientOp>(
))
}
/// Like [`recipient_encryption_key`] but for a recipient we only hold the
/// **public** key of (e.g. a sharing invitee resolved via `core/v4/keys/all`).
///
/// `EncryptionKey for SignedPublicKey` encrypts to the *primary* key, but Proton
/// keys have a signing primary and a separate encryption subkey, so we must pick
/// the encryption-capable (sub)key ourselves — same rule as the secret path.
pub(crate) fn recipient_encryption_key_public<Op: RecipientOp>(
key: &SignedPublicKey,
op: Op,
) -> Result<Op::Out, CryptoError> {
if key.primary_key.algorithm().can_encrypt() {
return op
.run(&key.primary_key)
.map_err(|e| CryptoError::Encrypt(format!("encrypt to primary key: {e}")));
}
for sub in &key.public_subkeys {
if sub.key.algorithm().can_encrypt() {
return op
.run(&sub.key)
.map_err(|e| CryptoError::Encrypt(format!("encrypt to subkey: {e}")));
}
}
Err(CryptoError::Encrypt(
"public key has no encryption-capable (sub)key".into(),
))
}
/// A freshly generated, passphrase-locked node key.
pub struct GeneratedNodeKey {
/// The unlocked node key, ready to sign and decrypt.
@@ -99,7 +126,7 @@ pub fn generate_node_key_aead() -> Result<GeneratedNodeKey, CryptoError> {
/// Generate a node key (Ed25519 primary + X25519 encryption subkey) at the given
/// OpenPGP key version. v4 for legacy files/folders, v6 for AEAD files.
fn generate_node_key_versioned(version: KeyVersion) -> Result<GeneratedNodeKey, CryptoError> {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let passphrase = generate_passphrase();
let pw_string = String::from_utf8(passphrase.clone())
.map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?;
@@ -144,7 +171,7 @@ fn generate_node_key_versioned(version: KeyVersion) -> Result<GeneratedNodeKey,
/// 32 random bytes, base64-encoded (the locking passphrase format).
fn generate_passphrase() -> Vec<u8> {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
rand_08::thread_rng().fill_bytes(&mut bytes);
BASE64.encode(bytes).into_bytes()
}
@@ -158,7 +185,7 @@ fn generate_passphrase() -> Vec<u8> {
/// signer) with `CryptoGenerator.GenerateFolderHashKey` (32 bytes).
pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result<String, CryptoError> {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
rand_08::thread_rng().fill_bytes(&mut bytes);
encrypt_and_sign(node_key.key(), Some(node_key), &bytes, false, false)
}
@@ -216,7 +243,7 @@ pub fn build_volume_creation_material(
let folder_passphrase_signature = address_key.sign_detached(&folder.passphrase)?;
let mut hash_key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut hash_key);
rand_08::thread_rng().fill_bytes(&mut hash_key);
let folder_hash_key = folder
.key
.encrypt_and_sign(address_key, &hash_key, false, false)?;
@@ -257,7 +284,7 @@ pub(crate) fn encrypt_and_sign(
/// Produce an armored detached signature over `data` by `signer`'s primary
/// (signing) key. Mirrors C# `PgpPrivateKey.Sign` / `SignDetached`.
pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String, CryptoError> {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let key = &signer.key().primary_key;
let pw = signer.password();
@@ -293,6 +320,212 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String,
.map_err(|e| CryptoError::Encrypt(format!("armor signature: {e}")))
}
/// The notation name GopenPGP uses to carry a signature *context* for domain
/// separation (`context@proton.ch`, critical). Proton signs sharing invitations,
/// members and external invitations under distinct contexts so a signature made
/// for one purpose cannot be replayed as another.
const SIGNATURE_CONTEXT_NOTATION: &[u8] = b"context@proton.ch";
/// Like [`sign_detached`] but stamps a GopenPGP signature *context* — a critical
/// `context@proton.ch` notation — into the hashed subpackets, and returns the
/// **raw** (binary) detached-signature packet bytes rather than armoring them.
///
/// The sharing endpoints carry these signatures as base64 of the binary packet
/// (JS `signature.toBase64()`), not as an armored block. Mirrors the JS
/// `openPGPCrypto.sign(data, key, { signatureContext: { critical: true, value } })`.
pub(crate) fn sign_detached_binary_with_context(
signer: &PrivateKey,
data: &[u8],
context: &str,
) -> Result<Vec<u8>, CryptoError> {
let mut rng = rand_08::thread_rng();
let key = &signer.key().primary_key;
let pw = signer.password();
let mut config = SignatureConfig::from_key(&mut rng, key, SignatureType::Binary)
.map_err(|e| CryptoError::Encrypt(format!("signature config: {e}")))?;
// Same subpacket stamping as `sign_detached` (issuer fingerprint + creation
// time, else GopenPGP rejects the signature) plus the critical context
// notation the server verifies against.
config.hashed_subpackets = vec![
Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint()))
.map_err(|e| CryptoError::Encrypt(format!("issuer fingerprint subpacket: {e}")))?,
Subpacket::regular(SubpacketData::SignatureCreationTime(
pgp::types::Timestamp::now(),
))
.map_err(|e| CryptoError::Encrypt(format!("creation-time subpacket: {e}")))?,
Subpacket::critical(SubpacketData::Notation(Notation {
readable: true,
name: Bytes::from_static(SIGNATURE_CONTEXT_NOTATION),
value: Bytes::copy_from_slice(context.as_bytes()),
}))
.map_err(|e| CryptoError::Encrypt(format!("context notation subpacket: {e}")))?,
];
if key.version() <= KeyVersion::V4 {
config.unhashed_subpackets = vec![
Subpacket::regular(SubpacketData::IssuerKeyId(key.legacy_key_id()))
.map_err(|e| CryptoError::Encrypt(format!("issuer subpacket: {e}")))?,
];
}
let signature = config
.sign(key, &pw, data)
.map_err(|e| CryptoError::Encrypt(format!("detached sign: {e}")))?;
let mut bytes = Vec::new();
pgp::ser::Serialize::to_writer(&DetachedSignature::new(signature), &mut bytes)
.map_err(|e| CryptoError::Encrypt(format!("serialize signature: {e}")))?;
Ok(bytes)
}
/// GopenPGP signing context for the inviter's signature over a sharing
/// invitation key packet (`drive.share-member.inviter`).
pub const SHARING_INVITER_CONTEXT: &str = "drive.share-member.inviter";
/// GopenPGP signing context for an accepting member's signature over the share
/// session key (`drive.share-member.member`).
pub const SHARING_MEMBER_CONTEXT: &str = "drive.share-member.member";
/// GopenPGP signing context for the inviter's signature over an external
/// (non-Proton) invitation (`drive.share-member.external-invitation`).
pub const SHARING_EXTERNAL_INVITATION_CONTEXT: &str = "drive.share-member.external-invitation";
/// The locked share key plus the crypto payload the `volumes/{vid}/shares`
/// endpoint needs to create a standard (shared) share on a node.
///
/// Mirrors JS `SharingCryptoService.generateShareKeys` output. The `share_key`
/// and `share_session_key` are kept (not sent) so invitations can encrypt the
/// share session key to invitees right after the share is created.
pub struct StandardShareMaterial {
/// Locked share secret key (armored) — `ShareKey`.
pub share_key_armored: String,
/// Share passphrase encrypted to `[node_key, address_key]` — `SharePassphrase`.
pub share_passphrase: String,
/// Detached signature over the share passphrase by the address key.
pub share_passphrase_signature: String,
/// base64 PKESK: the node's passphrase session key re-encrypted to the share
/// key — `PassphraseKeyPacket` (lets the share decrypt the node passphrase).
pub passphrase_key_packet: String,
/// base64 PKESK: the node's name session key re-encrypted to the share key —
/// `NameKeyPacket`.
pub name_key_packet: String,
/// The unlocked share key (not sent; kept for follow-up crypto).
pub share_key: PrivateKey,
/// The share passphrase session key (not sent; handed to invitees).
pub share_session_key: super::content::ContentKey,
}
/// Build the crypto material for a standard share over a node.
///
/// Mirrors JS `generateShareKeys(nodeKeys, addressKey)`:
/// - a fresh share key is generated and locked with a random passphrase;
/// - that passphrase is encrypted (via a fresh session key) to **both** the node
/// key and the address key, and detached-signed by the address key — so any
/// admin holding the node key, and the owner, can unwrap the share;
/// - the node's own passphrase and name session keys are re-encrypted to the new
/// share key, so the share grants access to the node's passphrase and name.
pub fn build_standard_share_material(
node_key: &PrivateKey,
node_passphrase_session_key: &super::content::ContentKey,
node_name_session_key: &super::content::ContentKey,
address_key: &PrivateKey,
) -> Result<StandardShareMaterial, CryptoError> {
let generated = generate_node_key()?;
let share_session_key = super::content::ContentKey::generate();
let share_passphrase =
share_session_key.encrypt_message_to(&[node_key, address_key], &generated.passphrase)?;
let share_passphrase_signature = address_key.sign_detached(&generated.passphrase)?;
let passphrase_key_packet =
BASE64.encode(node_passphrase_session_key.to_packet(&generated.key)?);
let name_key_packet = BASE64.encode(node_name_session_key.to_packet(&generated.key)?);
Ok(StandardShareMaterial {
share_key_armored: generated.locked_armored,
share_passphrase,
share_passphrase_signature,
passphrase_key_packet,
name_key_packet,
share_key: generated.key,
share_session_key,
})
}
/// Encrypt a sharing invitation for a Proton user.
///
/// The share's passphrase session key is encrypted to the invitee's public key
/// (`KeyPacket`) and the packet bytes are detached-signed by the inviter's
/// address key under the `drive.share-member.inviter` context
/// (`KeyPacketSignature`). Both are returned base64-encoded. Mirrors JS
/// `encryptInvitation`.
pub fn encrypt_invitation(
share_session_key: &super::content::ContentKey,
inviter_key: &PrivateKey,
invitee_public_key: &super::verify::PublicKey,
) -> Result<(String, String), CryptoError> {
let key_packet = share_session_key.to_packet_for_public(invitee_public_key)?;
let signature =
sign_detached_binary_with_context(inviter_key, &key_packet, SHARING_INVITER_CONTEXT)?;
Ok((BASE64.encode(&key_packet), BASE64.encode(&signature)))
}
/// Sign an accepted invitation's share session key.
///
/// The invitation's `KeyPacket` (base64 PKESK addressed to the invitee) is
/// decrypted with the invitee's address keys to recover the share session key,
/// whose raw bytes are detached-signed under the `drive.share-member.member`
/// context by the invitee's key. Returns the base64 signature the
/// `.../accept` endpoint expects as `SessionKeySignature`. Mirrors JS
/// `acceptInvitation`.
pub fn accept_invitation(
base64_key_packet: &str,
invitee_keys: &[PrivateKey],
signing_key: &PrivateKey,
) -> Result<String, CryptoError> {
let key_packet = BASE64
.decode(base64_key_packet)
.map_err(|e| CryptoError::Parse(format!("invitation key packet base64: {e}")))?;
let session_key = invitee_keys
.iter()
.find_map(|k| k.decrypt_content_key(&key_packet).ok())
.ok_or_else(|| {
CryptoError::Decrypt("no invitee key could decrypt the invitation key packet".into())
})?;
let signature = sign_detached_binary_with_context(
signing_key,
&session_key.export()?,
SHARING_MEMBER_CONTEXT,
)?;
Ok(BASE64.encode(&signature))
}
/// Sign an external (non-Proton) invitation.
///
/// The invitee has no Proton key to encrypt the share session key to, so instead
/// the inviter signs the payload `"{invitee_email}|{base64(share session key)}"`
/// under the `drive.share-member.external-invitation` context. When the invitee
/// later signs up, the server can bind the pending invitation to their new key.
/// Returns the base64 signature the external-invitations endpoint expects.
/// Mirrors JS `encryptExternalInvitation`.
pub fn encrypt_external_invitation(
share_session_key: &super::content::ContentKey,
inviter_key: &PrivateKey,
invitee_email: &str,
) -> Result<String, CryptoError> {
let payload = format!(
"{invitee_email}|{}",
BASE64.encode(share_session_key.export()?)
);
let signature = sign_detached_binary_with_context(
inviter_key,
payload.as_bytes(),
SHARING_EXTERNAL_INVITATION_CONTEXT,
)?;
Ok(BASE64.encode(&signature))
}
/// Inline encrypt-and-sign against a selected recipient public key.
struct EncryptSignOp<'a> {
data: Vec<u8>,
@@ -305,7 +538,7 @@ impl RecipientOp for EncryptSignOp<'_> {
type Out = String;
fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result<String> {
let mut rng = rand::thread_rng();
let mut rng = rand_08::thread_rng();
let mut builder = MessageBuilder::from_bytes(Bytes::new(), self.data)
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
@@ -439,4 +672,155 @@ mod tests {
.expect("decrypt hash key");
assert_eq!(hash_key.len(), 32);
}
#[test]
fn standard_share_material_round_trips() {
use super::super::content::ContentKey;
// Stand-ins for the node key, the owning address key, and the node's own
// passphrase + name session keys.
let node = generate_node_key().expect("generate node key");
let address = generate_node_key().expect("generate address key");
let node_passphrase_sk = ContentKey::generate();
let node_name_sk = ContentKey::generate();
let material = build_standard_share_material(
&node.key,
&node_passphrase_sk,
&node_name_sk,
&address.key,
)
.expect("build share material");
// The share passphrase must be decryptable by BOTH the node key and the
// address key, and its detached signature verify against the address key.
let via_node = node
.key
.decrypt_armored_message(&material.share_passphrase)
.expect("decrypt share passphrase via node key");
let via_address = address
.key
.decrypt_armored_message(&material.share_passphrase)
.expect("decrypt share passphrase via address key");
assert_eq!(via_node, via_address);
address
.key
.verify_detached_signature(&material.share_passphrase_signature, &via_node)
.expect("verify share passphrase signature");
// That passphrase unlocks the share key.
let share_key =
PrivateKey::from_armored(&material.share_key_armored, &via_node).expect("unlock share");
// The passphrase/name key packets re-deliver the node's session keys to
// the share key: decrypting them with the share key yields the originals.
let recovered_pp = share_key
.decrypt_content_key(&BASE64.decode(&material.passphrase_key_packet).unwrap())
.expect("recover passphrase session key");
assert_eq!(
recovered_pp.export().unwrap(),
node_passphrase_sk.export().unwrap()
);
let recovered_name = share_key
.decrypt_content_key(&BASE64.decode(&material.name_key_packet).unwrap())
.expect("recover name session key");
assert_eq!(
recovered_name.export().unwrap(),
node_name_sk.export().unwrap()
);
}
#[test]
fn invitation_key_packet_round_trips_to_invitee() {
use super::super::content::ContentKey;
use super::super::verify::PublicKey;
use pgp::composed::Deserializable as _;
let node = generate_node_key().expect("generate node key");
let address = generate_node_key().expect("generate address key");
let material = build_standard_share_material(
&node.key,
&ContentKey::generate(),
&ContentKey::generate(),
&address.key,
)
.expect("build share material");
// The invitee is any Proton-shaped key; we hold only its public half.
let invitee = generate_node_key().expect("generate invitee key");
let invitee_pub_armored = invitee
.key
.signed_public_key()
.to_armored_string(Default::default())
.expect("armor invitee public key");
let invitee_pub = PublicKey::from_armored(&invitee_pub_armored).expect("parse invitee pub");
let (key_packet_b64, signature_b64) =
encrypt_invitation(&material.share_session_key, &address.key, &invitee_pub)
.expect("encrypt invitation");
// The invitee recovers the share session key from the key packet.
let key_packet = BASE64.decode(&key_packet_b64).expect("decode key packet");
let recovered = invitee
.key
.decrypt_content_key(&key_packet)
.expect("invitee decrypts key packet");
assert_eq!(
recovered.export().unwrap(),
material.share_session_key.export().unwrap()
);
// The inviter's context signature over the key packet parses and verifies
// against the inviter (address) key.
let sig_bytes = BASE64.decode(&signature_b64).expect("decode signature");
let sig = DetachedSignature::from_bytes(&sig_bytes[..]).expect("parse signature");
sig.signature
.verify(&address.key.signed_public_key(), &key_packet[..])
.expect("verify inviter context signature");
}
#[test]
fn signing_with_a_clone_does_not_corrupt_the_original_for_decryption() {
// Regression for the live device-rename failure: the account caches
// unlocked address keys and hands out `PrivateKey` *clones*. If signing
// with a clone mutated shared secret-key state (pgp interior mutability),
// a later decryption with the cached original would fail with an MDC
// error. Prove clone + sign leaves the original intact for decryption.
let address = generate_node_key().expect("generate address key");
// Something encrypted to the address key (stands in for a share passphrase).
let secret = b"share passphrase bytes".to_vec();
let armored = address
.key
.encrypt(&secret)
.expect("encrypt to address key");
// First decryption works.
assert_eq!(
address
.key
.decrypt_armored_message(&armored)
.expect("decrypt #1"),
secret
);
// Sign with a *clone* of the cached key (what rename_device does).
let clone = address.key.clone();
let _sig = clone
.sign_detached(b"device name")
.expect("sign with clone");
let _enc = clone
.encrypt_and_sign(&clone, b"device name", true, false)
.expect("encrypt+sign with clone");
// The original must still decrypt afterwards.
assert_eq!(
address
.key
.decrypt_armored_message(&armored)
.expect("decrypt #2 after signing with clone"),
secret,
"signing with a clone must not corrupt the original key"
);
}
}
+40 -1
View File
@@ -47,6 +47,45 @@ pub struct SrpProofs {
pub expected_server_proof: Vec<u8>,
}
/// An SRP verifier for a password, as sent when creating a Proton public share
/// link. Mirrors go-srp `GenerateVerifier` / GopenPGP `getSrpVerifier`.
pub struct SrpVerifier {
/// base64 of the random 10-byte SRP salt (`UrlPasswordSalt`).
pub salt: String,
/// base64 of the little-endian verifier bytes (`SRPVerifier`).
pub verifier: String,
}
/// Generate an SRP verifier `v = g^x mod N` for `password`, where `g = 2`, `N`
/// is Proton's signed modulus, and `x` is the Proton password hash over a fresh
/// random salt. Used to secure a public share link with a password.
///
/// Mirrors go-srp `GenerateVerifier`: the salt is 10 random bytes and the
/// verifier is emitted little-endian, both base64-encoded.
pub fn generate_verifier(
password: &[u8],
signed_modulus: &str,
bit_length: usize,
) -> Result<SrpVerifier, CryptoError> {
let byte_len = bit_length / 8;
let modulus = verify_and_decode_modulus(signed_modulus)?;
let mut salt = [0u8; 10];
getrandom::fill(&mut salt).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?;
let hashed_password = hash_password_v3(password, &salt, &modulus)?;
let n = from_le(&modulus);
let x = from_le(&hashed_password);
let g = BigUint::from(2u32);
let verifier = g.modpow(&x, &n);
let verifier_le = to_le_fixed(&verifier, byte_len)?;
Ok(SrpVerifier {
salt: BASE64.encode(salt),
verifier: BASE64.encode(verifier_le),
})
}
/// Verify the cleartext-signed modulus against Proton's key and return the raw
/// (base64-decoded) modulus bytes. Mirrors go-srp `readClearSignedMessage`.
fn verify_and_decode_modulus(signed_modulus: &str) -> Result<Vec<u8>, CryptoError> {
@@ -174,7 +213,7 @@ fn generate_ephemeral(
loop {
let mut buf = vec![0u8; byte_len];
getrandom::getrandom(&mut buf).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?;
getrandom::fill(&mut buf).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?;
let secret = from_le(&buf) % n_minus_1;
if secret <= lower_bound || secret >= *n_minus_1 {
continue;
+1 -1
View File
@@ -55,7 +55,7 @@ impl PublicKey {
Ok(Self { key })
}
fn signed(&self) -> &SignedPublicKey {
pub(crate) fn signed(&self) -> &SignedPublicKey {
&self.key
}
}
+2 -2
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use std::time::Duration;
use rand::Rng;
use rand::RngExt;
use reqwest::{Method, StatusCode};
use serde::Serialize;
use serde::de::DeserializeOwned;
@@ -573,7 +573,7 @@ fn backoff(policy: &RetryPolicy, attempt: u32) -> Duration {
if ceiling_ms == 0 {
return Duration::ZERO;
}
Duration::from_millis(rand::thread_rng().gen_range(0..=ceiling_ms))
Duration::from_millis(rand::rng().random_range(0..=ceiling_ms))
}
fn ensure_trailing_slash(url: &str) -> String {