Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

## [Unreleased] -

### Breaking Changes

- Rust: removed the public `From<&PublicKey> for x25519_dalek::PublicKey` and `From<&PrivateKey> for x25519_dalek::StaticSecret` implementations.

### Added

- Post-quantum hybrid public-key cryptography using X-Wing (ML-KEM-768 + X25519): `KeyVersion::V2` key pairs, `CiphertextVersion::V3` asymmetric ciphertexts, and a new `key_encapsulation` module (`encapsulate` / `decapsulate`, `DataType::EncapsulatedKey`). This is opt-in: `Latest` still selects the existing versions.

## [0.10.3] - 2026-10-26

### Breaking Changes
Expand Down
106 changes: 104 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ name = "devolutions-crypto"
version.workspace = true
authors = ["Mathieu Morrissette <mmorrissette@devolutions.net>", "Sebastien Duquette <sduquette@devolutions.net>"]
edition = "2024"
rust-version = "1.85"
readme = "README_RUST.md"
license = "MIT OR Apache-2.0"
repository = "https://github.com/Devolutions/devolutions-crypto"
Expand Down Expand Up @@ -57,6 +58,10 @@ rust-argon2 = { workspace = true }

ed25519-dalek = { version = "3", features = [ "rand_core" ] }
x25519-dalek = { version = "3", features = [ "static_secrets" ] }
# Do not enable the `getrandom` feature:
# randomness is supplied explicitly from the OS RNG.
x-wing = { version = "0.1.1", default-features = false, features = ["zeroize"] }
hkdf = "0.13"

# used for fuzzing
arbitrary = { version = "1", features = ["derive"], optional = true }
Expand Down
24 changes: 22 additions & 2 deletions src/ciphertext/ciphertext_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ impl CiphertextV2Symmetric {
// Derive key
let key = Zeroizing::new(CiphertextV2Symmetric::derive_key(key));

Self::encrypt_with_derived_key(data, &key, aad, header)
}

/// Encrypts with an already-derived 32-byte key, skipping the SHA-256 key derivation of `encrypt`.
pub(crate) fn encrypt_with_derived_key(
data: &[u8],
key: &[u8; 32],
aad: &[u8],
header: &Header<Ciphertext>,
) -> Result<Self> {
// Generate nonce
let mut nonce_bytes = [0u8; 24];

Expand Down Expand Up @@ -125,6 +135,16 @@ impl CiphertextV2Symmetric {
// Derive key
let key = Zeroizing::new(CiphertextV2Symmetric::derive_key(key));

self.decrypt_with_derived_key(&key, aad, header)
}

/// Decrypts with an already-derived 32-byte key, skipping the SHA-256 key derivation of `decrypt`.
pub(crate) fn decrypt_with_derived_key(
&self,
key: &[u8; 32],
aad: &[u8],
header: &Header<Ciphertext>,
) -> Result<Vec<u8>> {
// Authenticate the header
let mut mac_data: Zeroizing<Vec<u8>> = Zeroizing::new(header.into());
mac_data.extend_from_slice(aad);
Expand Down Expand Up @@ -184,7 +204,7 @@ impl CiphertextV2Asymmetric {
aad: &[u8],
header: &Header<Ciphertext>,
) -> Result<Self> {
let public_key = x25519_dalek::PublicKey::from(public_key);
let public_key = public_key.as_x25519()?;

let ephemeral_private_key = StaticSecret::from(*crate::utils::random_bytes::<32>()?);
let ephemeral_public_key = x25519_dalek::PublicKey::from(&ephemeral_private_key);
Expand All @@ -209,7 +229,7 @@ impl CiphertextV2Asymmetric {
aad: &[u8],
header: &Header<Ciphertext>,
) -> Result<Vec<u8>> {
let private_key = StaticSecret::from(private_key);
let private_key = private_key.as_x25519()?;

let key = private_key.diffie_hellman(&self.public_key);

Expand Down
97 changes: 97 additions & 0 deletions src/ciphertext/ciphertext_v3.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! Ciphertext V3: X-Wing (X25519 + ML-KEM-768) + HKDF-SHA-256 + XChaCha20Poly1305
//!
//! Wire format of the payload (after the header):
//! `X-Wing ciphertext (1120 bytes) || nonce (24 bytes) || XChaCha20Poly1305 ciphertext and tag`.
use super::{PrivateKey, PublicKey};

use super::Error;
use super::Header;
use super::Result;

use super::Ciphertext;
use super::ciphertext_v2::CiphertextV2Symmetric;

use crate::key::key_v2::{KEM_CIPHERTEXT_SIZE, KemCiphertext};

use std::convert::TryFrom;

#[cfg(feature = "fuzz")]
use arbitrary::Arbitrary;

/// HKDF-SHA-256 `info` label binding the X-Wing shared secret to asymmetric ciphertext V3.
/// Part of the wire format: changing it breaks decryption of existing data.
const HKDF_INFO: &[u8] = b"devolutions_crypto ciphertext_v3";

#[derive(Clone, Debug)]
pub struct CiphertextV3Asymmetric {
kem_ciphertext: Box<KemCiphertext>,
ciphertext: CiphertextV2Symmetric,
}

#[cfg(feature = "fuzz")]
impl<'a> Arbitrary<'a> for CiphertextV3Asymmetric {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
let kem_ciphertext: [u8; KEM_CIPHERTEXT_SIZE] = Arbitrary::arbitrary(u)?;
let ciphertext = CiphertextV2Symmetric::arbitrary(u)?;
Ok(Self {
kem_ciphertext: Box::new(KemCiphertext::from(kem_ciphertext)),
ciphertext,
})
}
}

impl From<CiphertextV3Asymmetric> for Vec<u8> {
fn from(cipher: CiphertextV3Asymmetric) -> Self {
let mut data = cipher.kem_ciphertext.to_vec();
let mut ciphertext: Vec<u8> = cipher.ciphertext.into();
data.append(&mut ciphertext);
data
}
}

impl TryFrom<&[u8]> for CiphertextV3Asymmetric {
type Error = Error;
fn try_from(data: &[u8]) -> Result<Self> {
if data.len() <= KEM_CIPHERTEXT_SIZE {
return Err(Error::InvalidLength);
};

let kem_ciphertext = KemCiphertext::try_from(&data[..KEM_CIPHERTEXT_SIZE])
.map_err(|_| Error::InvalidLength)?;
let ciphertext = CiphertextV2Symmetric::try_from(&data[KEM_CIPHERTEXT_SIZE..])?;

Ok(Self {
kem_ciphertext: Box::new(kem_ciphertext),
ciphertext,
})
}
}

impl CiphertextV3Asymmetric {
pub fn encrypt(
data: &[u8],
public_key: &PublicKey,
aad: &[u8],
header: &Header<Ciphertext>,
) -> Result<Self> {
let (kem_ciphertext, key) = public_key.x_wing_encapsulate(HKDF_INFO)?;

let ciphertext = CiphertextV2Symmetric::encrypt_with_derived_key(data, &key, aad, header)?;

Ok(Self {
kem_ciphertext,
ciphertext,
})
}

pub fn decrypt(
&self,
private_key: &PrivateKey,
aad: &[u8],
header: &Header<Ciphertext>,
) -> Result<Vec<u8>> {
let key = private_key.x_wing_decapsulate(&self.kem_ciphertext, HKDF_INFO)?;

self.ciphertext.decrypt_with_derived_key(&key, aad, header)
}
}
Loading
Loading