Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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