mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
718 lines
30 KiB
Rust
718 lines
30 KiB
Rust
//! File content-key decryption and block (session-key) decryption.
|
|
//!
|
|
//! Proton Drive encrypts every block of a file with a single symmetric *content
|
|
//! key*. The content key itself is delivered as a PGP public-key-encrypted
|
|
//! session-key (PKESK) packet addressed to the node key. Mirrors the C#
|
|
//! `NodeCrypto.DecryptContentKey` (`nodeKey.DecryptSessionKey(contentKeyPacket)`)
|
|
//! followed by `PgpSessionKey.OpenDecryptingStream` for each block.
|
|
|
|
use std::io::Cursor;
|
|
|
|
use pgp::armor::Dearmor;
|
|
use pgp::composed::{MessageBuilder, PlainSessionKey, RawSessionKey};
|
|
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
|
|
use pgp::crypto::hash::HashAlgorithm;
|
|
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
|
use pgp::packet::{
|
|
LiteralData, Packet, PacketParser, PacketTrait, PublicKeyEncryptedSessionKey,
|
|
SymEncryptedProtectedData,
|
|
};
|
|
use pgp::types::{DecryptionKey, EncryptionKey, EskType, PkeskVersion, Seipdv1ReadMode};
|
|
|
|
use super::encrypt::{RecipientOp, recipient_encryption_key};
|
|
use super::errors::CryptoError;
|
|
use super::keys::PrivateKey;
|
|
|
|
/// 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
|
|
/// official clients hardcode `aes256` + `gcm` likewise).
|
|
const AEAD_ALGO: AeadAlgorithm = AeadAlgorithm::Gcm;
|
|
/// AEAD streaming chunk size: 128 KiB (C# `PgpAeadStreamingChunkLength`).
|
|
const AEAD_CHUNK_SIZE: ChunkSize = ChunkSize::C128KiB;
|
|
|
|
/// A decrypted file content key (a symmetric PGP session key).
|
|
///
|
|
/// Decrypts the file's data blocks; each block is a PGP message encrypted to
|
|
/// this session key, carrying no ESK of its own (the session key is supplied
|
|
/// externally, here). A key may be *legacy* (a V3/V4 session key sealing SEIPDv1
|
|
/// blocks) or *AEAD* (a V6 session key sealing SEIPDv2 / AES-256-GCM blocks); the
|
|
/// underlying [`PlainSessionKey`] variant records which.
|
|
#[derive(Clone)]
|
|
pub struct ContentKey {
|
|
session_key: PlainSessionKey,
|
|
}
|
|
|
|
impl ContentKey {
|
|
/// Generate a fresh AES-256 content key for a new file (legacy SEIPDv1 path).
|
|
///
|
|
/// Mirrors C# `PgpSessionKey.Generate()`.
|
|
pub fn generate() -> Self {
|
|
let mut rng = rand::thread_rng();
|
|
let sym_alg = SymmetricKeyAlgorithm::AES256;
|
|
let key = sym_alg.new_session_key(&mut rng);
|
|
ContentKey {
|
|
session_key: PlainSessionKey::V3_4 { sym_alg, key },
|
|
}
|
|
}
|
|
|
|
/// Generate a fresh AES-256 content key for the AEAD path (SEIPDv2 blocks).
|
|
///
|
|
/// The key is held as a V6 session key, which is delivered in the file's
|
|
/// content key packet as a v6 PKESK. Mirrors C#
|
|
/// `PgpSessionKey.GenerateForAead()`.
|
|
pub fn generate_aead() -> Self {
|
|
let mut rng = rand::thread_rng();
|
|
let key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng);
|
|
ContentKey {
|
|
session_key: PlainSessionKey::V6 { key },
|
|
}
|
|
}
|
|
|
|
/// Whether this content key seals blocks with AEAD (SEIPDv2) rather than the
|
|
/// legacy SEIPDv1 path. Determined by the session-key version (a V6 session
|
|
/// key is the AEAD case). Mirrors C# `PgpSessionKey.IsAead()`.
|
|
pub fn is_aead(&self) -> bool {
|
|
matches!(self.session_key, PlainSessionKey::V6 { .. })
|
|
}
|
|
|
|
/// Encrypt one file block: wrap `plaintext` in a literal-data packet and seal
|
|
/// it under this content key — as a SEIPDv1 (AES-256-CFB + MDC) packet for a
|
|
/// legacy key, or a SEIPDv2 (AES-256-GCM, 128 KiB chunks) packet for an AEAD
|
|
/// key. The result is a bare data packet with no ESK, matching Proton's block
|
|
/// format (the session key travels in the file's content key packet).
|
|
pub fn encrypt_block(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
|
let literal = LiteralData::from_bytes(bytes::Bytes::new(), plaintext.to_vec().into())
|
|
.map_err(|e| CryptoError::Encrypt(format!("literal packet: {e}")))?;
|
|
let mut inner = Vec::new();
|
|
literal
|
|
.to_writer_with_header(&mut inner)
|
|
.map_err(|e| CryptoError::Encrypt(format!("serialize literal: {e}")))?;
|
|
|
|
self.seal_seipd(&inner)
|
|
}
|
|
|
|
/// Encrypt and inline-sign a thumbnail block under this content key.
|
|
///
|
|
/// Unlike a content block (which carries a *detached* signature uploaded
|
|
/// separately), a thumbnail block is encrypt-and-signed in one pass: the
|
|
/// signature is embedded in the SEIPD payload. Mirrors C#
|
|
/// `BlockUploader.UploadThumbnailAsync`
|
|
/// (`ContentKey.OpenEncryptingAndSigningReadStream` with no detached
|
|
/// signature stream). Like [`encrypt_block`](Self::encrypt_block) the result
|
|
/// is a bare SEIPDv1 packet with no ESK — the session key travels in the
|
|
/// file's content key packet.
|
|
pub fn encrypt_thumbnail(
|
|
&self,
|
|
signer: &PrivateKey,
|
|
plaintext: &[u8],
|
|
) -> Result<Vec<u8>, CryptoError> {
|
|
let mut rng = rand::thread_rng();
|
|
|
|
// Build a signed (but unencrypted) message: one-pass signature, literal
|
|
// data, then the signature packet. `seipd_*` would generate its own
|
|
// session key, so we leave the builder unencrypted and seal the bytes
|
|
// ourselves with our content key below.
|
|
let mut builder = MessageBuilder::from_bytes(bytes::Bytes::new(), plaintext.to_vec());
|
|
builder.sign(
|
|
&signer.key().primary_key,
|
|
signer.password(),
|
|
HashAlgorithm::Sha256,
|
|
);
|
|
let inner = builder
|
|
.to_vec(&mut rng)
|
|
.map_err(|e| CryptoError::Encrypt(format!("sign thumbnail: {e}")))?;
|
|
|
|
self.seal_seipd(&inner)
|
|
}
|
|
|
|
/// Seal an already-serialized inner payload as a bare SEIPD packet under this
|
|
/// content key: SEIPDv1 for a legacy key, SEIPDv2 (AES-256-GCM) for an AEAD
|
|
/// key. Shared by [`encrypt_block`](Self::encrypt_block) and
|
|
/// [`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 seipd = if self.is_aead() {
|
|
SymEncryptedProtectedData::encrypt_seipdv2(
|
|
&mut rng,
|
|
sym_alg,
|
|
AEAD_ALGO,
|
|
AEAD_CHUNK_SIZE,
|
|
key,
|
|
inner,
|
|
)
|
|
.map_err(|e| CryptoError::Encrypt(format!("encrypt seipdv2: {e}")))?
|
|
} else {
|
|
SymEncryptedProtectedData::encrypt_seipdv1(&mut rng, sym_alg, key, inner)
|
|
.map_err(|e| CryptoError::Encrypt(format!("encrypt seipdv1: {e}")))?
|
|
};
|
|
|
|
let mut out = Vec::new();
|
|
seipd
|
|
.to_writer_with_header(&mut out)
|
|
.map_err(|e| CryptoError::Encrypt(format!("serialize seipd: {e}")))?;
|
|
Ok(out)
|
|
}
|
|
|
|
/// Encrypt this content key to `node_key` as a v3 PKESK packet — the file's
|
|
/// `ContentKeyPacket`. Mirrors C# `nodeKey.EncryptSessionKey(contentKey)`.
|
|
pub fn to_packet(&self, node_key: &PrivateKey) -> Result<Vec<u8>, CryptoError> {
|
|
let (sym_alg, key) = self.session_parts()?;
|
|
|
|
// An AEAD content key is a v6 session key (no algorithm byte); a legacy
|
|
// key is a v3 PKESK carrying the symmetric algorithm.
|
|
let packet = if self.is_aead() {
|
|
recipient_encryption_key(node_key.key(), ContentKeyPacketV6Op { key })?
|
|
} else {
|
|
recipient_encryption_key(node_key.key(), ContentKeyPacketOp { sym_alg, key })?
|
|
};
|
|
|
|
let mut out = Vec::new();
|
|
packet
|
|
.to_writer_with_header(&mut out)
|
|
.map_err(|e| CryptoError::Encrypt(format!("serialize content key packet: {e}")))?;
|
|
Ok(out)
|
|
}
|
|
|
|
/// The raw symmetric key bytes of this content key.
|
|
///
|
|
/// Mirrors C# `PgpSessionKey.Export()`; used to produce the file's
|
|
/// `ContentKeyPacketSignature` (the node key signs these bytes).
|
|
pub fn export(&self) -> Result<Vec<u8>, CryptoError> {
|
|
let (_sym_alg, key) = self.session_parts()?;
|
|
Ok(key.to_vec())
|
|
}
|
|
|
|
/// Symmetric algorithm and raw key material for this content key. A V6 (AEAD)
|
|
/// session key carries no algorithm, so AES-256 is assumed — matching the
|
|
/// official clients, which hardcode it for v6 session keys.
|
|
fn session_parts(&self) -> Result<(SymmetricKeyAlgorithm, &[u8]), CryptoError> {
|
|
match &self.session_key {
|
|
PlainSessionKey::V3_4 { sym_alg, key } => Ok((*sym_alg, key.as_ref())),
|
|
PlainSessionKey::V6 { key } => Ok((SymmetricKeyAlgorithm::AES256, key.as_ref())),
|
|
_ => Err(CryptoError::Encrypt(
|
|
"content key is not a V3/V4 or V6 session key".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Decrypt a single file block with this content key.
|
|
///
|
|
/// A block body is a bare SEIPD packet with no ESK of its own (the session
|
|
/// key arrives via the file's content key packet). rPGP's `Message` parser
|
|
/// rejects a message that *starts* with a SEIPD packet, so we parse packets
|
|
/// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full
|
|
/// round-trip message), and decrypt it with the supplied session key.
|
|
pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
|
let inner = self.decrypt_seipd(ciphertext)?;
|
|
|
|
// A content block's payload is a single literal-data packet.
|
|
let mut inner_parser = PacketParser::new(Cursor::new(&inner));
|
|
let literal = inner_parser
|
|
.next()
|
|
.ok_or_else(|| CryptoError::Parse("block payload is empty".into()))?
|
|
.map_err(|e| CryptoError::Parse(format!("block payload packet: {e}")))?;
|
|
match literal {
|
|
Packet::LiteralData(data) => Ok(data.data().to_vec()),
|
|
other => Err(CryptoError::Parse(format!(
|
|
"block payload is not literal data: {:?}",
|
|
other.tag()
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Decrypt an inline-signed thumbnail block under this content key.
|
|
///
|
|
/// Unlike a content block, a thumbnail payload is encrypt-**and-inline-sign**
|
|
/// (`encrypt_thumbnail`): the SEIPD payload is a signed message
|
|
/// (`OnePassSignature`, `LiteralData`, `Signature`), so the literal data is
|
|
/// not the first packet. The signature is metadata; we scan past the
|
|
/// one-pass-signature / signature packets and return the literal bytes.
|
|
/// Mirrors C# `FileOperations` reading the thumbnail through a decrypt+verify
|
|
/// stream and keeping the plaintext regardless of verification outcome.
|
|
pub fn decrypt_thumbnail(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
|
let inner = self.decrypt_seipd(ciphertext)?;
|
|
|
|
let parser = PacketParser::new(Cursor::new(&inner));
|
|
for packet in parser {
|
|
let packet =
|
|
packet.map_err(|e| CryptoError::Parse(format!("thumbnail payload packet: {e}")))?;
|
|
match packet {
|
|
Packet::LiteralData(data) => return Ok(data.data().to_vec()),
|
|
// Inline-signature framing around the literal data.
|
|
Packet::OnePassSignature(_) | Packet::Signature(_) => continue,
|
|
other => {
|
|
return Err(CryptoError::Parse(format!(
|
|
"unexpected thumbnail payload packet: {:?}",
|
|
other.tag()
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Err(CryptoError::Parse(
|
|
"thumbnail payload had no literal data".into(),
|
|
))
|
|
}
|
|
|
|
/// Locate the SEIPD packet (tolerating a leading content-key PKESK) and
|
|
/// decrypt it under this content key, returning the serialized inner payload.
|
|
/// rPGP's `Message` parser rejects a message that *starts* with a SEIPD
|
|
/// packet, so we parse packets directly.
|
|
fn decrypt_seipd(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
|
let (sym_alg, key) = self.session_parts()?;
|
|
|
|
let mut parser = PacketParser::new(Cursor::new(ciphertext));
|
|
let seipd = loop {
|
|
let packet = parser
|
|
.next()
|
|
.ok_or_else(|| CryptoError::Parse("block has no SEIPD packet".into()))?
|
|
.map_err(|e| CryptoError::Parse(format!("block packet: {e}")))?;
|
|
match packet {
|
|
Packet::SymEncryptedProtectedData(data) => break data,
|
|
// Skip a content-key PKESK if the caller passed a full message.
|
|
Packet::PublicKeyEncryptedSessionKey(_) => continue,
|
|
other => {
|
|
return Err(CryptoError::Parse(format!(
|
|
"unexpected block packet: {:?}",
|
|
other.tag()
|
|
)));
|
|
}
|
|
}
|
|
};
|
|
|
|
seipd
|
|
.decrypt(key, Some(sym_alg), Seipdv1ReadMode::default())
|
|
.map_err(|e| CryptoError::Decrypt(format!("block: {e}")))
|
|
}
|
|
}
|
|
|
|
impl PrivateKey {
|
|
/// Decrypt a content-key packet — a bare PKESK addressed to this node key.
|
|
///
|
|
/// Mirrors C# `nodeKey.DecryptSessionKey(contentKeyPacket.Span)`. The PKESK
|
|
/// may target either the node's primary key or an encryption subkey, so both
|
|
/// are tried (the underlying NativeAOT lib does this matching for us).
|
|
pub fn decrypt_content_key(&self, packet_bytes: &[u8]) -> Result<ContentKey, CryptoError> {
|
|
let mut parser = PacketParser::new(Cursor::new(packet_bytes));
|
|
let packet = parser
|
|
.next()
|
|
.ok_or_else(|| CryptoError::Parse("empty content key packet".into()))?
|
|
.map_err(|e| CryptoError::Parse(format!("content key packet: {e}")))?;
|
|
|
|
let Packet::PublicKeyEncryptedSessionKey(pkesk) = packet else {
|
|
return Err(CryptoError::Parse(
|
|
"content key packet is not a PKESK".into(),
|
|
));
|
|
};
|
|
|
|
let values = pkesk
|
|
.values()
|
|
.map_err(|e| CryptoError::Parse(format!("content key PKESK values: {e}")))?;
|
|
let esk_type = match pkesk.version() {
|
|
PkeskVersion::V6 => EskType::V6,
|
|
_ => EskType::V3_4,
|
|
};
|
|
|
|
// `decrypt_session_key` returns `Result<Result<_>>`: outer = passphrase
|
|
// fit, inner = the decryption result. Try the primary key, then any
|
|
// encryption subkeys (Proton node keys may carry one).
|
|
let pw = self.password();
|
|
let key = self.key();
|
|
|
|
// pgp 0.20: `DecryptionKey::decrypt` is implemented on the packet secret
|
|
// (sub)keys (`SecretKey` / `SecretSubkey`), not on `SignedSecretKey`, so
|
|
// reach through `primary_key` and each subkey's `.key`.
|
|
let mut last_err: CryptoError;
|
|
match key.primary_key.decrypt(&pw, values, esk_type) {
|
|
Ok(Ok(session_key)) => return Ok(ContentKey { session_key }),
|
|
Ok(Err(e)) | Err(e) => last_err = CryptoError::Decrypt(format!("content key: {e}")),
|
|
}
|
|
for subkey in &key.secret_subkeys {
|
|
match subkey.key.decrypt(&pw, values, esk_type) {
|
|
Ok(Ok(session_key)) => return Ok(ContentKey { session_key }),
|
|
Ok(Err(e)) | Err(e) => {
|
|
last_err = CryptoError::Decrypt(format!("content key (subkey): {e}"))
|
|
}
|
|
}
|
|
}
|
|
Err(last_err)
|
|
}
|
|
}
|
|
|
|
impl PrivateKey {
|
|
/// Re-encrypt the key packet of `armored_message` — a PGP message currently
|
|
/// addressed to this key — to `destination`, preserving the encrypted data
|
|
/// packet (and thus the plaintext and any *detached* signature over it).
|
|
///
|
|
/// This is the session-key rewrap a node **move** performs on a node
|
|
/// passphrase: the secret stays identical, only its recipient changes (old
|
|
/// 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))`.
|
|
pub fn rewrap_message_to(
|
|
&self,
|
|
armored_message: &str,
|
|
destination: &PrivateKey,
|
|
) -> Result<String, CryptoError> {
|
|
// De-armor the message to raw packet bytes (PKESK followed by SEIPD).
|
|
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}")))?;
|
|
|
|
// Recover the session key from the leading PKESK (decrypt_content_key
|
|
// reads only that packet) and serialize the original SEIPD body to
|
|
// preserve the ciphertext — and thus the plaintext and its signature.
|
|
let session = self.decrypt_content_key(&raw)?;
|
|
let body = serialize_seipd(&raw)?;
|
|
|
|
// Re-encrypt the session key to the destination and reattach the body.
|
|
let mut message = session.to_packet(destination)?;
|
|
message.extend_from_slice(&body);
|
|
|
|
// Re-armor as a PGP MESSAGE block.
|
|
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}")))
|
|
}
|
|
}
|
|
|
|
/// Serialize the SEIPD (data) packet of a de-armored message, header included.
|
|
/// Used by [`PrivateKey::rewrap_message_to`] to carry the original ciphertext
|
|
/// over to the re-encrypted message unchanged.
|
|
fn serialize_seipd(raw: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
|
let mut parser = PacketParser::new(Cursor::new(raw));
|
|
loop {
|
|
let packet = parser
|
|
.next()
|
|
.ok_or_else(|| CryptoError::Parse("message has no SEIPD packet".into()))?
|
|
.map_err(|e| CryptoError::Parse(format!("message packet: {e}")))?;
|
|
if let Packet::SymEncryptedProtectedData(data) = packet {
|
|
let mut out = Vec::new();
|
|
data.to_writer_with_header(&mut out)
|
|
.map_err(|e| CryptoError::Encrypt(format!("serialize seipd: {e}")))?;
|
|
return Ok(out);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `Serialize` wrapper over already-serialized PGP packet bytes, so they can
|
|
/// be re-armored via [`pgp::armor::write`] without rebuilding a `Message`.
|
|
struct RawPackets<'a>(&'a [u8]);
|
|
|
|
impl pgp::ser::Serialize for RawPackets<'_> {
|
|
fn to_writer<W: std::io::Write>(&self, writer: &mut W) -> pgp::errors::Result<()> {
|
|
writer.write_all(self.0)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
}
|
|
|
|
/// Encrypt a content key to a recipient as a v3 PKESK (`from_session_key_v3`).
|
|
struct ContentKeyPacketOp<'a> {
|
|
sym_alg: SymmetricKeyAlgorithm,
|
|
key: &'a [u8],
|
|
}
|
|
|
|
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 session_key = RawSessionKey::from(self.key);
|
|
PublicKeyEncryptedSessionKey::from_session_key_v3(
|
|
&mut rng,
|
|
&session_key,
|
|
self.sym_alg,
|
|
pubkey,
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Encrypt an AEAD content key to a recipient as a v6 PKESK
|
|
/// (`from_session_key_v6`). A v6 session key carries no algorithm byte.
|
|
struct ContentKeyPacketV6Op<'a> {
|
|
key: &'a [u8],
|
|
}
|
|
|
|
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 session_key = RawSessionKey::from(self.key);
|
|
PublicKeyEncryptedSessionKey::from_session_key_v6(&mut rng, &session_key, pubkey)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pgp::composed::{
|
|
EncryptionCaps, KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey,
|
|
};
|
|
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
|
|
|
/// 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 params = SecretKeyParamsBuilder::default()
|
|
.key_type(KeyType::Rsa(2048))
|
|
.can_sign(true)
|
|
.can_certify(true)
|
|
.can_encrypt(EncryptionCaps::All)
|
|
.primary_user_id("node <node@proton.test>".into())
|
|
.passphrase(Some(passphrase.to_owned()))
|
|
.build()
|
|
.expect("key params");
|
|
params.generate(&mut rng).expect("generate")
|
|
}
|
|
|
|
#[test]
|
|
fn content_key_packet_and_block_round_trip() {
|
|
let mut rng = rand::thread_rng();
|
|
let signed = generate_node_key("pw");
|
|
let armored = signed
|
|
.to_armored_string(None.into())
|
|
.expect("armor secret key");
|
|
|
|
// Encrypt a "block" to the node key: the resulting message carries a
|
|
// PKESK (the content key packet) followed by the SEIPD (the block body).
|
|
let plaintext = b"the quick brown fox jumps over 13 lazy dogs".to_vec();
|
|
let mut builder = MessageBuilder::from_bytes(String::new(), plaintext.clone())
|
|
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
|
builder
|
|
.encrypt_to_key(&mut rng, &signed.primary_key.public_key())
|
|
.expect("encrypt to key");
|
|
let message_bytes = builder.to_vec(&mut rng).expect("serialize message");
|
|
|
|
let key = PrivateKey::from_armored(&armored, b"pw").expect("parse node key");
|
|
|
|
// `decrypt_content_key` consumes only the leading PKESK packet; the same
|
|
// bytes then decrypt as a block once the content key is recovered.
|
|
let content_key = key
|
|
.decrypt_content_key(&message_bytes)
|
|
.expect("decrypt content key");
|
|
let decrypted = content_key
|
|
.decrypt_block(&message_bytes)
|
|
.expect("decrypt block");
|
|
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_content_key_round_trip() {
|
|
// Upload path: generate a content key, seal it to the node key as a
|
|
// content-key packet, then recover it and round-trip a block.
|
|
let signed = generate_node_key("pw");
|
|
let armored = signed.to_armored_string(None.into()).expect("armor");
|
|
let node_key = PrivateKey::from_armored(&armored, b"pw").expect("parse node key");
|
|
|
|
let content_key = ContentKey::generate();
|
|
let packet = content_key
|
|
.to_packet(&node_key)
|
|
.expect("content key packet");
|
|
|
|
let recovered = node_key
|
|
.decrypt_content_key(&packet)
|
|
.expect("decrypt content key");
|
|
|
|
let plaintext = b"upload round trip block".to_vec();
|
|
let block = content_key
|
|
.encrypt_block(&plaintext)
|
|
.expect("encrypt block");
|
|
let decrypted = recovered.decrypt_block(&block).expect("decrypt block");
|
|
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn content_key_round_trip_through_x25519_node_key() {
|
|
// The real Proton case: an Ed25519 signing primary with an X25519
|
|
// encryption subkey. `to_packet` must target the subkey and
|
|
// `decrypt_content_key` must recover it.
|
|
let node = super::super::encrypt::generate_node_key().expect("generate node key");
|
|
let content_key = ContentKey::generate();
|
|
|
|
let packet = content_key
|
|
.to_packet(&node.key)
|
|
.expect("content key packet");
|
|
let recovered = node.key.decrypt_content_key(&packet).expect("decrypt");
|
|
|
|
let plaintext = b"x25519 block".to_vec();
|
|
let block = content_key
|
|
.encrypt_block(&plaintext)
|
|
.expect("encrypt block");
|
|
assert_eq!(recovered.decrypt_block(&block).expect("decrypt"), plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn thumbnail_encrypt_inline_signed_round_trip() {
|
|
// A thumbnail block is encrypt-and-inline-signed under the content key.
|
|
// It must decrypt back to the plaintext via the same session key, and —
|
|
// unlike a content block — carry its signature inside the SEIPD payload.
|
|
let signer = super::super::encrypt::generate_node_key().expect("generate signer");
|
|
let content_key = ContentKey::generate();
|
|
|
|
let plaintext = b"thumbnail image bytes".to_vec();
|
|
let block = content_key
|
|
.encrypt_thumbnail(&signer.key, &plaintext)
|
|
.expect("encrypt thumbnail");
|
|
|
|
// The bare SEIPD decrypts under the content key; its payload begins with
|
|
// a one-pass signature packet (not bare literal data), so `decrypt_block`
|
|
// would reject it — confirm the inline-signed structure directly.
|
|
let (sym_alg, key) = content_key.session_parts().expect("session parts");
|
|
let mut parser = PacketParser::new(Cursor::new(&block));
|
|
let seipd = match parser.next().expect("packet").expect("seipd") {
|
|
Packet::SymEncryptedProtectedData(d) => d,
|
|
other => panic!("expected SEIPD, got {:?}", other.tag()),
|
|
};
|
|
let inner = seipd
|
|
.decrypt(key, Some(sym_alg), Seipdv1ReadMode::default())
|
|
.expect("decrypt thumbnail");
|
|
|
|
let mut inner_parser = PacketParser::new(Cursor::new(&inner));
|
|
let first = inner_parser.next().expect("inner packet").expect("inner");
|
|
assert!(
|
|
matches!(first, Packet::OnePassSignature(_)),
|
|
"thumbnail payload should be inline-signed, got {:?}",
|
|
first.tag()
|
|
);
|
|
// The literal data follows the one-pass signature.
|
|
let literal = inner_parser
|
|
.next()
|
|
.expect("literal packet")
|
|
.expect("literal");
|
|
match literal {
|
|
Packet::LiteralData(data) => assert_eq!(data.data(), plaintext.as_slice()),
|
|
other => panic!("expected literal data, got {:?}", other.tag()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decrypt_thumbnail_reads_inline_signed_payload() {
|
|
// The download path must read an inline-signed thumbnail back to its
|
|
// plaintext: `decrypt_block` rejects the leading one-pass-signature, so
|
|
// `decrypt_thumbnail` scans past the signature framing. Covers both
|
|
// content-key modes (legacy SEIPDv1 + AEAD SEIPDv2).
|
|
for content_key in [ContentKey::generate(), ContentKey::generate_aead()] {
|
|
let signer = super::super::encrypt::generate_node_key().expect("generate signer");
|
|
let plaintext = b"thumbnail image bytes for download".to_vec();
|
|
let block = content_key
|
|
.encrypt_thumbnail(&signer.key, &plaintext)
|
|
.expect("encrypt thumbnail");
|
|
assert_eq!(
|
|
content_key.decrypt_thumbnail(&block).expect("decrypt"),
|
|
plaintext
|
|
);
|
|
// A content block reader must still reject the signed payload.
|
|
assert!(content_key.decrypt_block(&block).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn aead_content_key_round_trip_through_x25519_node_key() {
|
|
// AEAD upload path: generate an AEAD content key (V6 session key), seal
|
|
// it to the node key as a v6 PKESK, recover it, and round-trip a SEIPDv2
|
|
// block. The recovered key must still report as AEAD.
|
|
//
|
|
// The node key must be **v6** here (C# `PgpProfile.ProtonAead`): a v6
|
|
// PKESK addressed to a v4 key round-trips in rPGP but the Proton server
|
|
// rejects the draft (the recipient fingerprint can't match a v4 key), so
|
|
// use `generate_node_key_aead` to mirror the real upload path.
|
|
let node = super::super::encrypt::generate_node_key_aead().expect("generate node key");
|
|
let content_key = ContentKey::generate_aead();
|
|
assert!(content_key.is_aead());
|
|
|
|
let packet = content_key
|
|
.to_packet(&node.key)
|
|
.expect("content key packet");
|
|
let recovered = node.key.decrypt_content_key(&packet).expect("decrypt");
|
|
assert!(recovered.is_aead(), "recovered key should be AEAD");
|
|
|
|
// A block larger than one AEAD chunk (128 KiB) exercises chunked GCM.
|
|
let plaintext = vec![0xABu8; (1 << 17) + 1234];
|
|
let block = content_key
|
|
.encrypt_block(&plaintext)
|
|
.expect("encrypt block");
|
|
assert_eq!(
|
|
recovered.decrypt_block(&block).expect("decrypt block"),
|
|
plaintext
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrap_message_to_moves_passphrase_between_parents() {
|
|
// Move: a node passphrase encrypted (+signed) to the old parent must be
|
|
// rewrapped to the new parent without changing the plaintext, so the new
|
|
// parent recovers the exact same secret and the detached signature over
|
|
// it still verifies.
|
|
let old_parent = super::super::encrypt::generate_node_key().expect("old parent");
|
|
let new_parent = super::super::encrypt::generate_node_key().expect("new parent");
|
|
let signer = super::super::encrypt::generate_node_key().expect("signer");
|
|
|
|
let passphrase = b"node-key locking passphrase bytes".to_vec();
|
|
let armored = old_parent
|
|
.key
|
|
.encrypt_and_sign(&signer.key, &passphrase, false, false)
|
|
.expect("encrypt passphrase to old parent");
|
|
// The move keeps the detached NodePassphraseSignature, so sign separately.
|
|
let signature = signer
|
|
.key
|
|
.sign_detached(&passphrase)
|
|
.expect("sign passphrase");
|
|
|
|
// The new parent cannot read the old-parent message yet.
|
|
assert!(new_parent.key.decrypt_armored_message(&armored).is_err());
|
|
|
|
let rewrapped = old_parent
|
|
.key
|
|
.rewrap_message_to(&armored, &new_parent.key)
|
|
.expect("rewrap to new parent");
|
|
|
|
// New parent recovers the identical plaintext; old signature still holds.
|
|
let recovered = new_parent
|
|
.key
|
|
.decrypt_armored_message(&rewrapped)
|
|
.expect("new parent decrypt");
|
|
assert_eq!(recovered, passphrase);
|
|
signer
|
|
.key
|
|
.verify_detached_signature(&signature, &recovered)
|
|
.expect("passphrase signature still verifies");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_pkesk_content_key_packet() {
|
|
let mut rng = rand::thread_rng();
|
|
let key = PrivateKey::from_armored(
|
|
&generate_node_key("pw")
|
|
.to_armored_string(None.into())
|
|
.unwrap(),
|
|
b"pw",
|
|
)
|
|
.unwrap();
|
|
|
|
// An unencrypted message begins with a literal-data packet, not a PKESK.
|
|
let bytes = MessageBuilder::from_bytes(String::new(), b"hi".to_vec())
|
|
.to_vec(&mut rng)
|
|
.unwrap();
|
|
|
|
assert!(key.decrypt_content_key(&bytes).is_err());
|
|
}
|
|
}
|