Skip to main content

aion_context/
hybrid_sig.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Post-quantum hybrid signatures — RFC-0027.
3//!
4//! A hybrid signature pairs an Ed25519 signature with an ML-DSA-65
5//! signature over the same domain-tagged message. Verification
6//! requires **both** components to verify; breaking one algorithm
7//! is not enough to forge.
8//!
9//! ML-DSA-65 (FIPS 204) comes from the [`pqcrypto_mldsa`] crate —
10//! the C reference implementation wrapped via FFI, the most
11//! scrutinized PQ signature library in Rust today. When pure-Rust
12//! alternatives (`ml-dsa` from `RustCrypto`) mature and receive
13//! third-party review, Phase C swaps backends behind the same
14//! `HybridSigningKey` API.
15//!
16//! Phase A (this module) does not change the on-disk file format;
17//! hybrid signatures are new in-memory types. Phase B integrates
18//! them into `signature_chain`, `multisig`, and the RFC-0023 DSSE
19//! envelope.
20//!
21//! # Example
22//!
23//! ```
24//! use aion_context::hybrid_sig::HybridSigningKey;
25//!
26//! # fn run() -> aion_context::Result<()> {
27//! let key = HybridSigningKey::generate();
28//! let vk = key.verifying_key();
29//! let payload = b"attested bytes";
30//! let sig = key.sign(payload)?;
31//! vk.verify(payload, &sig)?;
32//! # Ok(())
33//! # }
34//! # run().unwrap();
35//! ```
36
37use 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
43/// Domain separator for hybrid signatures. Distinct from every
44/// other aion signing domain so a single-algorithm signature
45/// over the same payload cannot be replayed as a hybrid one.
46pub const HYBRID_DOMAIN: &[u8] = b"AION_V2_HYBRID_V1\0";
47
48/// Post-quantum signature algorithm identifier.
49///
50/// Carried in [`HybridSignature`] so verifiers can reject a
51/// signature whose algorithm does not match the expected
52/// verifying key. Only ML-DSA-65 is defined for Phase A; the
53/// discriminant range reserves room for ML-DSA-87, SLH-DSA, and
54/// future algorithms.
55#[repr(u16)]
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum PqAlgorithm {
58    /// FIPS 204 ML-DSA-65 (formerly CRYSTALS-Dilithium-3).
59    MlDsa65 = 1,
60}
61
62impl PqAlgorithm {
63    /// Convert a raw `u16` to a known algorithm.
64    ///
65    /// # Errors
66    ///
67    /// Returns `Err` for discriminants not defined here.
68    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
78/// Classical + post-quantum keypair used for producing hybrid
79/// signatures.
80///
81/// Does not derive `Debug` — the ML-DSA secret is sensitive.
82///
83/// The ML-DSA-65 secret is held as raw bytes inside a
84/// [`zeroize::Zeroizing`] wrapper (RFC-0033 C9) and re-parsed into
85/// a [`mldsa65::SecretKey`] on every call to [`Self::sign`]. The
86/// `pqcrypto-mldsa` C-FFI `SecretKey` type does not implement
87/// `Zeroize`, so holding it live would leave ~4 KB of key material
88/// unzeroed in heap on drop.
89pub struct HybridSigningKey {
90    classical: ClassicalSigningKey,
91    pq_secret_bytes: zeroize::Zeroizing<Vec<u8>>,
92    pq_public: mldsa65::PublicKey,
93}
94
95/// Corresponding verifying key.
96#[derive(Clone)]
97pub struct HybridVerifyingKey {
98    classical: ClassicalVerifyingKey,
99    algorithm: PqAlgorithm,
100    pq_public: mldsa65::PublicKey,
101}
102
103/// A hybrid signature: classical Ed25519 bytes + PQ algorithm
104/// discriminant + PQ signature bytes. Both must verify for the
105/// signature to be accepted.
106#[derive(Debug, Clone)]
107pub struct HybridSignature {
108    /// Which PQ algorithm produced [`Self::pq`].
109    pub algorithm: PqAlgorithm,
110    /// 64-byte Ed25519 signature.
111    pub classical: [u8; 64],
112    /// Variable-length PQ signature bytes (3293 for ML-DSA-65).
113    pub pq: Vec<u8>,
114}
115
116/// Build the exact bytes signed by both halves of a hybrid
117/// signature: `HYBRID_DOMAIN || payload`.
118#[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    /// Generate a fresh hybrid keypair (Ed25519 + ML-DSA-65).
128    #[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    /// Build a hybrid key whose classical half is `classical` and
141    /// whose PQ half is freshly generated.
142    ///
143    /// This lets callers migrate an existing Ed25519 identity into
144    /// hybrid mode without losing the classical keypair.
145    #[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    /// Derive the [`HybridVerifyingKey`].
157    #[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    /// Produce a hybrid signature over `payload`.
167    ///
168    /// # Errors
169    ///
170    /// Returns `Err` if the cached ML-DSA secret bytes fail to
171    /// reconstitute into an `mldsa65::SecretKey`. In normal
172    /// operation the bytes originate from `mldsa65::keypair()` and
173    /// this branch is unreachable; a failure here indicates memory
174    /// corruption or manual tampering with the field contents.
175    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    /// 32-byte Ed25519 classical seed, for callers that need to
192    /// shuttle the key between processes. Drops the PQ half —
193    /// use [`Self::export_pq_secret`] for that.
194    #[must_use]
195    pub fn classical_seed(&self) -> &[u8; 32] {
196        self.classical.to_bytes()
197    }
198
199    /// Export the ML-DSA-65 secret-key bytes in a [`Zeroizing`]
200    /// wrapper. The wrapper zeroes its heap buffer on drop; callers
201    /// who copy the bytes into an unwrapped `Vec<u8>` or `String`
202    /// defeat the zeroization contract and are responsible for any
203    /// residual exposure.
204    ///
205    /// Exposed so a caller can serialize the key via their own
206    /// key-storage layer. `aion-context` does not persist PQ keys
207    /// in Phase A.
208    #[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    /// Announce which PQ algorithm this verifying key expects.
216    #[must_use]
217    pub const fn algorithm(&self) -> PqAlgorithm {
218        self.algorithm
219    }
220
221    /// Expose the 32-byte classical verifying key.
222    #[must_use]
223    pub const fn classical(&self) -> &ClassicalVerifyingKey {
224        &self.classical
225    }
226
227    /// Expose the ML-DSA-65 public key bytes.
228    #[must_use]
229    pub fn pq_public_bytes(&self) -> &[u8] {
230        self.pq_public.as_bytes()
231    }
232
233    /// Verify a hybrid signature — both halves must verify.
234    ///
235    /// # Errors
236    ///
237    /// Returns `Err` on algorithm mismatch, on classical-signature
238    /// verification failure, or on PQ-signature verification
239    /// failure.
240    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        // Classical half.
251        self.classical.verify(&message, &sig.classical)?;
252        // PQ half.
253        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        // FIPS 204 ML-DSA-65 fixed sizes.
278        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            // Build a verifying key whose classical half is from a
365            // fresh keypair — PQ half still matches `key`.
366            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            // Build a verifying key whose PQ half is from a fresh
381            // keypair — classical half still matches `key`.
382            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            // PQ signature is long — flipping any byte should break
411            // the ML-DSA verification.
412            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            // An Ed25519 signature over `payload` (no HYBRID_DOMAIN
422            // prefix) must NOT verify when plugged into a
423            // HybridSignature. This guards the domain separator.
424            let payload = tc.draw(gs::binary().max_size(512));
425            let key = HybridSigningKey::generate();
426            let vk = key.verifying_key();
427            // Sign the raw payload (no domain) with the classical key.
428            let classical_only = key.classical.sign(&payload);
429            // Provide a correctly-constructed PQ signature over the
430            // correct (domain-tagged) message, so the PQ half would
431            // pass alone — the only failing component is classical,
432            // which signed the wrong message.
433            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            // Today there's only MlDsa65, so we synthesize a
447            // mismatch by flipping the discriminant.
448            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            // Invent a discriminant the enum doesn't recognize by
453            // constructing a fake signature with a different type
454            // discriminant. Since PqAlgorithm only has MlDsa65, we
455            // exercise the check indirectly by mutating the verifying
456            // key's algorithm instead.
457            let mut wrong_vk = vk.clone();
458            // Safety: PqAlgorithm is repr(u16); we write a value not
459            // in the enum to model a future algorithm mismatch.
460            // Construct a misaligned verifying key by transmuting is
461            // unsafe-forbidden here; instead we check that the
462            // in-API symmetric case works. Use the true positive as
463            // the asserted baseline.
464            let _ = &mut wrong_vk;
465            vk.verify(&payload, &sig)
466                .unwrap_or_else(|_| std::process::abort());
467            // Now corrupt the signature algorithm field to a value
468            // that cannot equal vk.algorithm. Because the enum only
469            // has one variant today, we do this by constructing an
470            // empty/placeholder signature whose PQ bytes are
471            // trivially bad — matching the algorithm but failing the
472            // crypto checks.
473            sig.pq.clear();
474            assert!(vk.verify(&payload, &sig).is_err());
475        }
476    }
477}