mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
feature (devices+sharing) + upgraded rand and aeds
This commit is contained in:
1 parent
78567bf00e
commit
64f977edb3
19 files changed
+2830
-93
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>> {
|
||||
{
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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: &[],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -55,7 +55,7 @@ impl PublicKey {
|
||||
Ok(Self { key })
|
||||
}
|
||||
|
||||
fn signed(&self) -> &SignedPublicKey {
|
||||
pub(crate) fn signed(&self) -> &SignedPublicKey {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user