use pqcrypto_mldsa::mldsa65;
use pqcrypto_traits::sign::{DetachedSignature, PublicKey, SecretKey};
use crate::crypto::{SigningKey as ClassicalSigningKey, VerifyingKey as ClassicalVerifyingKey};
use crate::{AionError, Result};
pub const HYBRID_DOMAIN: &[u8] = b"AION_V2_HYBRID_V1\0";
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PqAlgorithm {
MlDsa65 = 1,
}
impl PqAlgorithm {
pub fn from_u16(value: u16) -> Result<Self> {
match value {
1 => Ok(Self::MlDsa65),
other => Err(AionError::InvalidFormat {
reason: format!("Unknown hybrid PQ algorithm: {other}"),
}),
}
}
}
pub struct HybridSigningKey {
classical: ClassicalSigningKey,
pq_secret_bytes: zeroize::Zeroizing<Vec<u8>>,
pq_public: mldsa65::PublicKey,
}
#[derive(Clone)]
pub struct HybridVerifyingKey {
classical: ClassicalVerifyingKey,
algorithm: PqAlgorithm,
pq_public: mldsa65::PublicKey,
}
#[derive(Debug, Clone)]
pub struct HybridSignature {
pub algorithm: PqAlgorithm,
pub classical: [u8; 64],
pub pq: Vec<u8>,
}
#[must_use]
pub fn canonical_hybrid_message(payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(HYBRID_DOMAIN.len().saturating_add(payload.len()));
out.extend_from_slice(HYBRID_DOMAIN);
out.extend_from_slice(payload);
out
}
impl HybridSigningKey {
#[must_use]
pub fn generate() -> Self {
let classical = ClassicalSigningKey::generate();
let (pq_public, pq_secret) = mldsa65::keypair();
let pq_secret_bytes = zeroize::Zeroizing::new(pq_secret.as_bytes().to_vec());
Self {
classical,
pq_secret_bytes,
pq_public,
}
}
#[must_use]
pub fn from_classical(classical: ClassicalSigningKey) -> Self {
let (pq_public, pq_secret) = mldsa65::keypair();
let pq_secret_bytes = zeroize::Zeroizing::new(pq_secret.as_bytes().to_vec());
Self {
classical,
pq_secret_bytes,
pq_public,
}
}
#[must_use]
pub fn verifying_key(&self) -> HybridVerifyingKey {
HybridVerifyingKey {
classical: self.classical.verifying_key(),
algorithm: PqAlgorithm::MlDsa65,
pq_public: self.pq_public,
}
}
pub fn sign(&self, payload: &[u8]) -> Result<HybridSignature> {
let message = canonical_hybrid_message(payload);
let classical = self.classical.sign(&message);
let pq_secret = mldsa65::SecretKey::from_bytes(&self.pq_secret_bytes).map_err(|e| {
AionError::InvalidFormat {
reason: format!("internal: ML-DSA-65 secret key reconstitution failed: {e}"),
}
})?;
let pq_sig = mldsa65::detached_sign(&message, &pq_secret);
Ok(HybridSignature {
algorithm: PqAlgorithm::MlDsa65,
classical,
pq: pq_sig.as_bytes().to_vec(),
})
}
#[must_use]
pub fn classical_seed(&self) -> &[u8; 32] {
self.classical.to_bytes()
}
#[must_use]
pub fn export_pq_secret(&self) -> zeroize::Zeroizing<Vec<u8>> {
zeroize::Zeroizing::new(self.pq_secret_bytes.as_slice().to_vec())
}
}
impl HybridVerifyingKey {
#[must_use]
pub const fn algorithm(&self) -> PqAlgorithm {
self.algorithm
}
#[must_use]
pub const fn classical(&self) -> &ClassicalVerifyingKey {
&self.classical
}
#[must_use]
pub fn pq_public_bytes(&self) -> &[u8] {
self.pq_public.as_bytes()
}
pub fn verify(&self, payload: &[u8], sig: &HybridSignature) -> Result<()> {
if sig.algorithm != self.algorithm {
return Err(AionError::InvalidFormat {
reason: format!(
"hybrid algorithm mismatch: sig={:?}, key={:?}",
sig.algorithm, self.algorithm
),
});
}
let message = canonical_hybrid_message(payload);
self.classical.verify(&message, &sig.classical)?;
let pq_sig = mldsa65::DetachedSignature::from_bytes(&sig.pq).map_err(|e| {
AionError::InvalidFormat {
reason: format!("ML-DSA-65 signature bytes invalid: {e}"),
}
})?;
mldsa65::verify_detached_signature(&pq_sig, &message, &self.pq_public).map_err(|e| {
AionError::InvalidFormat {
reason: format!("ML-DSA-65 verification failed: {e}"),
}
})
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]
mod tests {
use super::*;
#[test]
fn sizes_match_fips_204() {
assert_eq!(mldsa65::public_key_bytes(), 1952);
assert_eq!(mldsa65::secret_key_bytes(), 4032);
assert_eq!(mldsa65::signature_bytes(), 3309);
}
#[test]
fn sign_verify_round_trip() {
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let sig = key.sign(b"hello hybrid").unwrap();
vk.verify(b"hello hybrid", &sig).unwrap();
}
#[test]
fn tampered_payload_rejects() {
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let sig = key.sign(b"hello hybrid").unwrap();
assert!(vk.verify(b"hello HYBRID", &sig).is_err());
}
#[test]
fn corrupted_classical_sig_rejects() {
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let mut sig = key.sign(b"payload").unwrap();
sig.classical[0] ^= 0x01;
assert!(vk.verify(b"payload", &sig).is_err());
}
#[test]
fn corrupted_pq_sig_rejects() {
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let mut sig = key.sign(b"payload").unwrap();
sig.pq[0] ^= 0x01;
assert!(vk.verify(b"payload", &sig).is_err());
}
#[test]
fn algorithm_round_trips() {
assert_eq!(PqAlgorithm::from_u16(1).unwrap(), PqAlgorithm::MlDsa65);
assert!(PqAlgorithm::from_u16(99).is_err());
}
#[test]
fn from_classical_preserves_ed25519_identity() {
let classical = ClassicalSigningKey::generate();
let original_pk = classical.verifying_key().to_bytes();
let key = HybridSigningKey::from_classical(classical);
assert_eq!(key.verifying_key().classical.to_bytes(), original_pk);
}
mod properties {
use super::*;
use hegel::generators as gs;
#[hegel::test]
fn prop_hybrid_sign_verify_roundtrip(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().max_size(512));
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let sig = key.sign(&payload).unwrap();
vk.verify(&payload, &sig)
.unwrap_or_else(|_| std::process::abort());
}
#[hegel::test]
fn prop_hybrid_tampered_payload_rejects(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().min_size(1).max_size(512));
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let sig = key.sign(&payload).unwrap();
let mut tampered = payload;
let idx = tc.draw(gs::integers::<usize>().max_value(tampered.len().saturating_sub(1)));
if let Some(b) = tampered.get_mut(idx) {
*b ^= 0x01;
}
assert!(vk.verify(&tampered, &sig).is_err());
}
#[hegel::test]
fn prop_hybrid_wrong_classical_key_rejects(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().max_size(512));
let key = HybridSigningKey::generate();
let sig = key.sign(&payload).unwrap();
let impostor_classical = ClassicalSigningKey::generate();
let wrong_vk = HybridVerifyingKey {
classical: impostor_classical.verifying_key(),
algorithm: PqAlgorithm::MlDsa65,
pq_public: key.pq_public,
};
assert!(wrong_vk.verify(&payload, &sig).is_err());
}
#[hegel::test]
fn prop_hybrid_wrong_pq_key_rejects(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().max_size(512));
let key = HybridSigningKey::generate();
let sig = key.sign(&payload).unwrap();
let (impostor_pq_pub, _) = mldsa65::keypair();
let wrong_vk = HybridVerifyingKey {
classical: key.classical.verifying_key(),
algorithm: PqAlgorithm::MlDsa65,
pq_public: impostor_pq_pub,
};
assert!(wrong_vk.verify(&payload, &sig).is_err());
}
#[hegel::test]
fn prop_hybrid_corrupted_classical_sig_rejects(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().max_size(512));
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let mut sig = key.sign(&payload).unwrap();
let idx = tc.draw(gs::integers::<usize>().max_value(sig.classical.len() - 1));
if let Some(b) = sig.classical.get_mut(idx) {
*b ^= 0x01;
}
assert!(vk.verify(&payload, &sig).is_err());
}
#[hegel::test]
fn prop_hybrid_corrupted_pq_sig_rejects(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().max_size(512));
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let mut sig = key.sign(&payload).unwrap();
let idx = tc.draw(gs::integers::<usize>().max_value(sig.pq.len().saturating_sub(1)));
if let Some(b) = sig.pq.get_mut(idx) {
*b ^= 0x01;
}
assert!(vk.verify(&payload, &sig).is_err());
}
#[hegel::test]
fn prop_hybrid_domain_separated_from_plain_ed25519(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().max_size(512));
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let classical_only = key.classical.sign(&payload);
let domain_msg = canonical_hybrid_message(&payload);
let pq_secret = mldsa65::SecretKey::from_bytes(&key.pq_secret_bytes).unwrap();
let pq_sig = mldsa65::detached_sign(&domain_msg, &pq_secret);
let sig = HybridSignature {
algorithm: PqAlgorithm::MlDsa65,
classical: classical_only,
pq: pq_sig.as_bytes().to_vec(),
};
assert!(vk.verify(&payload, &sig).is_err());
}
#[hegel::test]
fn prop_hybrid_algorithm_mismatch_rejects(tc: hegel::TestCase) {
let payload = tc.draw(gs::binary().max_size(256));
let key = HybridSigningKey::generate();
let vk = key.verifying_key();
let mut sig = key.sign(&payload).unwrap();
let mut wrong_vk = vk.clone();
let _ = &mut wrong_vk;
vk.verify(&payload, &sig)
.unwrap_or_else(|_| std::process::abort());
sig.pq.clear();
assert!(vk.verify(&payload, &sig).is_err());
}
}
}