aion_context/
hybrid_sig.rs1use pqcrypto_mldsa::mldsa65;
38use pqcrypto_traits::sign::{DetachedSignature, PublicKey, SecretKey};
39
40use crate::crypto::{SigningKey as ClassicalSigningKey, VerifyingKey as ClassicalVerifyingKey};
41use crate::{AionError, Result};
42
43pub const HYBRID_DOMAIN: &[u8] = b"AION_V2_HYBRID_V1\0";
47
48#[repr(u16)]
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum PqAlgorithm {
58 MlDsa65 = 1,
60}
61
62impl PqAlgorithm {
63 pub fn from_u16(value: u16) -> Result<Self> {
69 match value {
70 1 => Ok(Self::MlDsa65),
71 other => Err(AionError::InvalidFormat {
72 reason: format!("Unknown hybrid PQ algorithm: {other}"),
73 }),
74 }
75 }
76}
77
78pub struct HybridSigningKey {
90 classical: ClassicalSigningKey,
91 pq_secret_bytes: zeroize::Zeroizing<Vec<u8>>,
92 pq_public: mldsa65::PublicKey,
93}
94
95#[derive(Clone)]
97pub struct HybridVerifyingKey {
98 classical: ClassicalVerifyingKey,
99 algorithm: PqAlgorithm,
100 pq_public: mldsa65::PublicKey,
101}
102
103#[derive(Debug, Clone)]
107pub struct HybridSignature {
108 pub algorithm: PqAlgorithm,
110 pub classical: [u8; 64],
112 pub pq: Vec<u8>,
114}
115
116#[must_use]
119pub fn canonical_hybrid_message(payload: &[u8]) -> Vec<u8> {
120 let mut out = Vec::with_capacity(HYBRID_DOMAIN.len().saturating_add(payload.len()));
121 out.extend_from_slice(HYBRID_DOMAIN);
122 out.extend_from_slice(payload);
123 out
124}
125
126impl HybridSigningKey {
127 #[must_use]
129 pub fn generate() -> Self {
130 let classical = ClassicalSigningKey::generate();
131 let (pq_public, pq_secret) = mldsa65::keypair();
132 let pq_secret_bytes = zeroize::Zeroizing::new(pq_secret.as_bytes().to_vec());
133 Self {
134 classical,
135 pq_secret_bytes,
136 pq_public,
137 }
138 }
139
140 #[must_use]
146 pub fn from_classical(classical: ClassicalSigningKey) -> Self {
147 let (pq_public, pq_secret) = mldsa65::keypair();
148 let pq_secret_bytes = zeroize::Zeroizing::new(pq_secret.as_bytes().to_vec());
149 Self {
150 classical,
151 pq_secret_bytes,
152 pq_public,
153 }
154 }
155
156 #[must_use]
158 pub fn verifying_key(&self) -> HybridVerifyingKey {
159 HybridVerifyingKey {
160 classical: self.classical.verifying_key(),
161 algorithm: PqAlgorithm::MlDsa65,
162 pq_public: self.pq_public,
163 }
164 }
165
166 pub fn sign(&self, payload: &[u8]) -> Result<HybridSignature> {
176 let message = canonical_hybrid_message(payload);
177 let classical = self.classical.sign(&message);
178 let pq_secret = mldsa65::SecretKey::from_bytes(&self.pq_secret_bytes).map_err(|e| {
179 AionError::InvalidFormat {
180 reason: format!("internal: ML-DSA-65 secret key reconstitution failed: {e}"),
181 }
182 })?;
183 let pq_sig = mldsa65::detached_sign(&message, &pq_secret);
184 Ok(HybridSignature {
185 algorithm: PqAlgorithm::MlDsa65,
186 classical,
187 pq: pq_sig.as_bytes().to_vec(),
188 })
189 }
190
191 #[must_use]
195 pub fn classical_seed(&self) -> &[u8; 32] {
196 self.classical.to_bytes()
197 }
198
199 #[must_use]
209 pub fn export_pq_secret(&self) -> zeroize::Zeroizing<Vec<u8>> {
210 zeroize::Zeroizing::new(self.pq_secret_bytes.as_slice().to_vec())
211 }
212}
213
214impl HybridVerifyingKey {
215 #[must_use]
217 pub const fn algorithm(&self) -> PqAlgorithm {
218 self.algorithm
219 }
220
221 #[must_use]
223 pub const fn classical(&self) -> &ClassicalVerifyingKey {
224 &self.classical
225 }
226
227 #[must_use]
229 pub fn pq_public_bytes(&self) -> &[u8] {
230 self.pq_public.as_bytes()
231 }
232
233 pub fn verify(&self, payload: &[u8], sig: &HybridSignature) -> Result<()> {
241 if sig.algorithm != self.algorithm {
242 return Err(AionError::InvalidFormat {
243 reason: format!(
244 "hybrid algorithm mismatch: sig={:?}, key={:?}",
245 sig.algorithm, self.algorithm
246 ),
247 });
248 }
249 let message = canonical_hybrid_message(payload);
250 self.classical.verify(&message, &sig.classical)?;
252 let pq_sig = mldsa65::DetachedSignature::from_bytes(&sig.pq).map_err(|e| {
254 AionError::InvalidFormat {
255 reason: format!("ML-DSA-65 signature bytes invalid: {e}"),
256 }
257 })?;
258 mldsa65::verify_detached_signature(&pq_sig, &message, &self.pq_public).map_err(|e| {
259 AionError::InvalidFormat {
260 reason: format!("ML-DSA-65 verification failed: {e}"),
261 }
262 })
263 }
264}
265
266#[cfg(test)]
267#[allow(
268 clippy::unwrap_used,
269 clippy::indexing_slicing,
270 clippy::arithmetic_side_effects
271)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn sizes_match_fips_204() {
277 assert_eq!(mldsa65::public_key_bytes(), 1952);
279 assert_eq!(mldsa65::secret_key_bytes(), 4032);
280 assert_eq!(mldsa65::signature_bytes(), 3309);
281 }
282
283 #[test]
284 fn sign_verify_round_trip() {
285 let key = HybridSigningKey::generate();
286 let vk = key.verifying_key();
287 let sig = key.sign(b"hello hybrid").unwrap();
288 vk.verify(b"hello hybrid", &sig).unwrap();
289 }
290
291 #[test]
292 fn tampered_payload_rejects() {
293 let key = HybridSigningKey::generate();
294 let vk = key.verifying_key();
295 let sig = key.sign(b"hello hybrid").unwrap();
296 assert!(vk.verify(b"hello HYBRID", &sig).is_err());
297 }
298
299 #[test]
300 fn corrupted_classical_sig_rejects() {
301 let key = HybridSigningKey::generate();
302 let vk = key.verifying_key();
303 let mut sig = key.sign(b"payload").unwrap();
304 sig.classical[0] ^= 0x01;
305 assert!(vk.verify(b"payload", &sig).is_err());
306 }
307
308 #[test]
309 fn corrupted_pq_sig_rejects() {
310 let key = HybridSigningKey::generate();
311 let vk = key.verifying_key();
312 let mut sig = key.sign(b"payload").unwrap();
313 sig.pq[0] ^= 0x01;
314 assert!(vk.verify(b"payload", &sig).is_err());
315 }
316
317 #[test]
318 fn algorithm_round_trips() {
319 assert_eq!(PqAlgorithm::from_u16(1).unwrap(), PqAlgorithm::MlDsa65);
320 assert!(PqAlgorithm::from_u16(99).is_err());
321 }
322
323 #[test]
324 fn from_classical_preserves_ed25519_identity() {
325 let classical = ClassicalSigningKey::generate();
326 let original_pk = classical.verifying_key().to_bytes();
327 let key = HybridSigningKey::from_classical(classical);
328 assert_eq!(key.verifying_key().classical.to_bytes(), original_pk);
329 }
330
331 mod properties {
332 use super::*;
333 use hegel::generators as gs;
334
335 #[hegel::test]
336 fn prop_hybrid_sign_verify_roundtrip(tc: hegel::TestCase) {
337 let payload = tc.draw(gs::binary().max_size(512));
338 let key = HybridSigningKey::generate();
339 let vk = key.verifying_key();
340 let sig = key.sign(&payload).unwrap();
341 vk.verify(&payload, &sig)
342 .unwrap_or_else(|_| std::process::abort());
343 }
344
345 #[hegel::test]
346 fn prop_hybrid_tampered_payload_rejects(tc: hegel::TestCase) {
347 let payload = tc.draw(gs::binary().min_size(1).max_size(512));
348 let key = HybridSigningKey::generate();
349 let vk = key.verifying_key();
350 let sig = key.sign(&payload).unwrap();
351 let mut tampered = payload;
352 let idx = tc.draw(gs::integers::<usize>().max_value(tampered.len().saturating_sub(1)));
353 if let Some(b) = tampered.get_mut(idx) {
354 *b ^= 0x01;
355 }
356 assert!(vk.verify(&tampered, &sig).is_err());
357 }
358
359 #[hegel::test]
360 fn prop_hybrid_wrong_classical_key_rejects(tc: hegel::TestCase) {
361 let payload = tc.draw(gs::binary().max_size(512));
362 let key = HybridSigningKey::generate();
363 let sig = key.sign(&payload).unwrap();
364 let impostor_classical = ClassicalSigningKey::generate();
367 let wrong_vk = HybridVerifyingKey {
368 classical: impostor_classical.verifying_key(),
369 algorithm: PqAlgorithm::MlDsa65,
370 pq_public: key.pq_public,
371 };
372 assert!(wrong_vk.verify(&payload, &sig).is_err());
373 }
374
375 #[hegel::test]
376 fn prop_hybrid_wrong_pq_key_rejects(tc: hegel::TestCase) {
377 let payload = tc.draw(gs::binary().max_size(512));
378 let key = HybridSigningKey::generate();
379 let sig = key.sign(&payload).unwrap();
380 let (impostor_pq_pub, _) = mldsa65::keypair();
383 let wrong_vk = HybridVerifyingKey {
384 classical: key.classical.verifying_key(),
385 algorithm: PqAlgorithm::MlDsa65,
386 pq_public: impostor_pq_pub,
387 };
388 assert!(wrong_vk.verify(&payload, &sig).is_err());
389 }
390
391 #[hegel::test]
392 fn prop_hybrid_corrupted_classical_sig_rejects(tc: hegel::TestCase) {
393 let payload = tc.draw(gs::binary().max_size(512));
394 let key = HybridSigningKey::generate();
395 let vk = key.verifying_key();
396 let mut sig = key.sign(&payload).unwrap();
397 let idx = tc.draw(gs::integers::<usize>().max_value(sig.classical.len() - 1));
398 if let Some(b) = sig.classical.get_mut(idx) {
399 *b ^= 0x01;
400 }
401 assert!(vk.verify(&payload, &sig).is_err());
402 }
403
404 #[hegel::test]
405 fn prop_hybrid_corrupted_pq_sig_rejects(tc: hegel::TestCase) {
406 let payload = tc.draw(gs::binary().max_size(512));
407 let key = HybridSigningKey::generate();
408 let vk = key.verifying_key();
409 let mut sig = key.sign(&payload).unwrap();
410 let idx = tc.draw(gs::integers::<usize>().max_value(sig.pq.len().saturating_sub(1)));
413 if let Some(b) = sig.pq.get_mut(idx) {
414 *b ^= 0x01;
415 }
416 assert!(vk.verify(&payload, &sig).is_err());
417 }
418
419 #[hegel::test]
420 fn prop_hybrid_domain_separated_from_plain_ed25519(tc: hegel::TestCase) {
421 let payload = tc.draw(gs::binary().max_size(512));
425 let key = HybridSigningKey::generate();
426 let vk = key.verifying_key();
427 let classical_only = key.classical.sign(&payload);
429 let domain_msg = canonical_hybrid_message(&payload);
434 let pq_secret = mldsa65::SecretKey::from_bytes(&key.pq_secret_bytes).unwrap();
435 let pq_sig = mldsa65::detached_sign(&domain_msg, &pq_secret);
436 let sig = HybridSignature {
437 algorithm: PqAlgorithm::MlDsa65,
438 classical: classical_only,
439 pq: pq_sig.as_bytes().to_vec(),
440 };
441 assert!(vk.verify(&payload, &sig).is_err());
442 }
443
444 #[hegel::test]
445 fn prop_hybrid_algorithm_mismatch_rejects(tc: hegel::TestCase) {
446 let payload = tc.draw(gs::binary().max_size(256));
449 let key = HybridSigningKey::generate();
450 let vk = key.verifying_key();
451 let mut sig = key.sign(&payload).unwrap();
452 let mut wrong_vk = vk.clone();
458 let _ = &mut wrong_vk;
465 vk.verify(&payload, &sig)
466 .unwrap_or_else(|_| std::process::abort());
467 sig.pq.clear();
474 assert!(vk.verify(&payload, &sig).is_err());
475 }
476 }
477}