From afc9f348c8fd5d271c530d1f9f63c9514080a3da Mon Sep 17 00:00:00 2001 From: GoDeskio Date: Thu, 5 Nov 2026 18:42:13 +0800 Subject: [PATCH] feat: land pq for PR #20 --- crates/encryptoglyph/Cargo.toml | 2 + crates/encryptoglyph/src/lib.rs | 1 + crates/encryptoglyph/src/pq.rs | 107 +++++++++++++++++++++++++++++++ crates/encryptoglyph/tests/pq.rs | 20 ++++++ 4 files changed, 130 insertions(+) create mode 100644 crates/encryptoglyph/src/pq.rs create mode 100644 crates/encryptoglyph/tests/pq.rs diff --git a/crates/encryptoglyph/Cargo.toml b/crates/encryptoglyph/Cargo.toml index c3d8cf7..9b0d524 100644 --- a/crates/encryptoglyph/Cargo.toml +++ b/crates/encryptoglyph/Cargo.toml @@ -14,6 +14,8 @@ default = [] wasm = ["dep:wasm-bindgen", "getrandom/js"] [dependencies] +ml-kem = "0.2" +x25519-dalek = { version = "2", features = ["static_secrets", "zeroize"] } ed25519-dalek = { version = "2", features = ["rand_core", "zeroize"] } chacha20poly1305 = "0.10" aes-gcm = "0.10" diff --git a/crates/encryptoglyph/src/lib.rs b/crates/encryptoglyph/src/lib.rs index a58cf35..9beac8e 100644 --- a/crates/encryptoglyph/src/lib.rs +++ b/crates/encryptoglyph/src/lib.rs @@ -26,6 +26,7 @@ pub mod stream; pub mod shamir; pub mod sign; pub mod ff1; +pub mod pq; pub const MAGIC: &[u8; 2] = b"EG"; pub const VERSION_LEGACY: u8 = 1; diff --git a/crates/encryptoglyph/src/pq.rs b/crates/encryptoglyph/src/pq.rs new file mode 100644 index 0000000..4b65e6b --- /dev/null +++ b/crates/encryptoglyph/src/pq.rs @@ -0,0 +1,107 @@ +//! Hybrid post-quantum key wrap: X25519 + ML-KEM-768 (FIPS 203) (backlog #6). +//! The wrapped key stays safe while either primitive holds, which guards against +//! harvest-now-decrypt-later attacks on long-lived backups. +//! +//! Public key = x25519_pk(32) | mlkem768_ek(1184). +//! Wrap = `b"EGPQ" | ver=1 | eph_pk(32) | kem_ct(1088) | nonce(24) | XChaCha(dek)+tag`. +//! KEK = HKDF-SHA256(ikm = ss_x25519 | ss_mlkem, info = label | eph_pk | x25519_pk | kem_ct). +//! Binding every public value into the KDF follows the usual combiner pattern used by X-Wing. +use crate::Error; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use hkdf::Hkdf; +use ml_kem::kem::{Decapsulate, Encapsulate}; +use ml_kem::{EncodedSizeUser, KemCore, MlKem768}; +use rand_core::OsRng; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; +use zeroize::Zeroizing; + +type Ek = ::EncapsulationKey; +type Dk = ::DecapsulationKey; + +const MAGIC: &[u8; 4] = b"EGPQ"; +const X: usize = 32; +const EK: usize = 1184; +const DK: usize = 2400; +const KCT: usize = 1088; +pub const PUBLIC_LEN: usize = X + EK; +pub const SECRET_LEN: usize = X + DK; +const HDR: usize = 5 + X + KCT + 24; + +pub struct HybridKeypair { + pub public: Vec, + pub secret: Zeroizing>, +} + +pub fn generate() -> HybridKeypair { + let xs = StaticSecret::random_from_rng(OsRng); + let (dk, ek) = MlKem768::generate(&mut OsRng); + let mut public = PublicKey::from(&xs).to_bytes().to_vec(); + public.extend_from_slice(&ek.as_bytes()); + let mut secret = Zeroizing::new(xs.to_bytes().to_vec()); + secret.extend_from_slice(&dk.as_bytes()); + HybridKeypair { public, secret } +} + +fn kek(ss_x: &[u8], ss_k: &[u8], eph: &[u8], xpk: &[u8], kct: &[u8]) -> Result { + let mut ikm = Zeroizing::new(ss_x.to_vec()); + ikm.extend_from_slice(ss_k); + let mut info = b"encryptoglyph/pq-hybrid/v1".to_vec(); + info.extend_from_slice(eph); + info.extend_from_slice(xpk); + info.extend_from_slice(kct); + let mut k = Zeroizing::new([0u8; 32]); + Hkdf::::new(None, &ikm).expand(&info, &mut *k).map_err(|_| Error::InvalidKey)?; + XChaCha20Poly1305::new_from_slice(&*k).map_err(|_| Error::InvalidKey) +} + +pub fn wrap(public: &[u8], dek: &[u8]) -> Result, Error> { + if public.len() != PUBLIC_LEN || dek.is_empty() { + return Err(Error::InvalidKey); + } + let xpk: [u8; 32] = public[..X].try_into().unwrap(); + let ek = Ek::from_bytes(public[X..].try_into().map_err(|_| Error::InvalidKey)?); + let eph = StaticSecret::random_from_rng(OsRng); + let eph_pk = PublicKey::from(&eph).to_bytes(); + let ss_x = eph.diffie_hellman(&PublicKey::from(xpk)); + if !ss_x.was_contributory() { + return Err(Error::InvalidKey); + } + let (kct, ss_k) = ek.encapsulate(&mut OsRng).map_err(|_| Error::InvalidKey)?; + let c = kek(ss_x.as_bytes(), &ss_k, &eph_pk, &xpk, &kct)?; + let mut out = MAGIC.to_vec(); + out.push(1); + out.extend_from_slice(&eph_pk); + out.extend_from_slice(&kct); + let mut n = [0u8; 24]; + getrandom::getrandom(&mut n).map_err(|_| Error::InvalidArgument)?; + out.extend_from_slice(&n); + let ct = c.encrypt(XNonce::from_slice(&n), Payload { msg: dek, aad: &out }).map_err(|_| Error::InvalidArgument)?; + out.extend(ct); + Ok(out) +} + +pub fn unwrap(secret: &[u8], wrapped: &[u8]) -> Result>, Error> { + if secret.len() != SECRET_LEN { + return Err(Error::InvalidKey); + } + if wrapped.len() < HDR + 16 || &wrapped[..4] != MAGIC || wrapped[4] != 1 { + return Err(Error::Decrypt); + } + let xs = StaticSecret::from(<[u8; 32]>::try_from(&secret[..X]).unwrap()); + let xpk = PublicKey::from(&xs).to_bytes(); + let dk = Dk::from_bytes(secret[X..].try_into().map_err(|_| Error::InvalidKey)?); + let eph: [u8; 32] = wrapped[5..5 + X].try_into().unwrap(); + let kct_b = &wrapped[5 + X..5 + X + KCT]; + let ss_x = xs.diffie_hellman(&PublicKey::from(eph)); + if !ss_x.was_contributory() { + return Err(Error::Decrypt); + } + let kct = kct_b.try_into().map_err(|_| Error::Decrypt)?; + let ss_k = dk.decapsulate(&kct).map_err(|_| Error::Decrypt)?; + let c = kek(ss_x.as_bytes(), &ss_k, &eph, &xpk, kct_b)?; + c.decrypt(XNonce::from_slice(&wrapped[HDR - 24..HDR]), Payload { msg: &wrapped[HDR..], aad: &wrapped[..HDR] }) + .map(Zeroizing::new) + .map_err(|_| Error::Decrypt) +} diff --git a/crates/encryptoglyph/tests/pq.rs b/crates/encryptoglyph/tests/pq.rs new file mode 100644 index 0000000..0d4150f --- /dev/null +++ b/crates/encryptoglyph/tests/pq.rs @@ -0,0 +1,20 @@ +use encryptoglyph::pq::{generate, unwrap, wrap, PUBLIC_LEN, SECRET_LEN}; + +#[test] +fn hybrid_roundtrip_and_tamper() { + let kp = generate(); + assert_eq!(kp.public.len(), PUBLIC_LEN); + assert_eq!(kp.secret.len(), SECRET_LEN); + let dek = [42u8; 32]; + let w = wrap(&kp.public, &dek).unwrap(); + assert_eq!(&unwrap(&kp.secret, &w).unwrap()[..], &dek); + assert_ne!(wrap(&kp.public, &dek).unwrap(), w); + for i in [6usize, 40, 1100, w.len() - 1] { + let mut t = w.clone(); + t[i] ^= 1; + assert!(unwrap(&kp.secret, &t).is_err(), "byte {i}"); + } + let other = generate(); + assert!(unwrap(&other.secret, &w).is_err()); + assert!(wrap(&kp.public[..10], &dek).is_err()); +}