Skip to main content

phantom_protocol/crypto/
hybrid_sign.rs

1//! Hybrid Digital Signatures: Ed25519 + ML-DSA-65 (FIPS 204).
2//!
3//! Phase 5.1 — switched the PQ half from `pqcrypto-dilithium`'s C reference
4//! implementation of NIST PQC round-3 Dilithium3 to the RustCrypto pure-Rust
5//! `ml-dsa` crate's FIPS-204 ML-DSA-65. Same algorithm at the math level,
6//! different byte encoding per FIPS 204. Wire-incompatible with the prior
7//! `phantom_protocol` build.
8//!
9//! Both signatures must verify for the hybrid to be considered valid.
10
11use borsh::{BorshDeserialize, BorshSerialize};
12use ed25519_dalek::{
13    Signature as Ed25519Signature, Signer as Ed25519Signer, SigningKey, VerifyingKey,
14};
15use ml_dsa::Signature as MlDsaSignature;
16use ml_dsa::SigningKey as MlDsaSigningKey;
17use ml_dsa::VerifyingKey as MlDsaVerifyingKey;
18use ml_dsa::{
19    EncodedSignature, EncodedVerifyingKey, KeyExport, KeyInit, Keypair, MlDsa65, Signer, Verifier,
20};
21use std::fmt;
22use zeroize::ZeroizeOnDrop;
23
24/// Hybrid signing key — Ed25519 + ML-DSA-65 secret material.
25///
26/// On drop, both halves are zeroed: `ed25519_dalek::SigningKey` carries
27/// its own `Drop` impl (via the `zeroize` feature enabled in Cargo.toml),
28/// and `ml_dsa::SigningKey<P>` implements `ZeroizeOnDrop` natively. The
29/// pre-Phase-5.1 `unsafe` `ptr::write_volatile` wrapper in `crypto::keys`
30/// is no longer needed and was deleted.
31///
32/// `ml_dsa_sk` is `Box`-ed because `SigningKey<MlDsa65>` internally
33/// embeds the ~4 KiB ExpandedSigningKey by value; placing the whole
34/// thing on the stack at every construction would overflow default
35/// tokio test threads (the `from_seed` call alone constructs a large
36/// temporary). On the heap it's a single pointer-sized field at our
37/// level; `Box<T: ZeroizeOnDrop>` correctly forwards `Drop` to T.
38#[derive(ZeroizeOnDrop)]
39pub struct HybridSigningKey {
40    #[zeroize(skip)] // ed25519's SigningKey zeroes via its own Drop
41    pub ed25519_sk: SigningKey,
42    #[zeroize(skip)] // Box's Drop calls T::Drop which zeroes the inner SigningKey
43    pub ml_dsa_sk: Box<MlDsaSigningKey<MlDsa65>>,
44}
45
46impl HybridSigningKey {
47    /// Generate a fresh hybrid keypair using the OS RNG.
48    ///
49    /// Equivalent to `Self::generate_with_provider(&crate::crypto::rng::OsRng)`.
50    /// Preserved as the call-compatible default so the rest of the crate
51    /// (and downstream callers) does not change shape.
52    pub fn generate() -> (Self, HybridVerifyingKey) {
53        Self::generate_with_provider(&crate::crypto::rng::OsRng)
54    }
55
56    /// Generate a fresh hybrid keypair using an arbitrary
57    /// [`RngProvider`](crate::crypto::rng::RngProvider).
58    ///
59    /// Phase 3.8 demonstration of the `RngProvider` indirection: this is
60    /// the canonical "small, well-bounded crypto entry point that needs
61    /// randomness" call site. Embedders that need to drive key generation
62    /// from a hardware TRNG (on no_std) or a FIPS-approved DRBG plug in
63    /// here without disturbing the default surface.
64    ///
65    /// Internally, 32 bytes are drawn from the provider for each algorithm:
66    ///
67    /// - Ed25519: the 32 bytes are the seed for `SigningKey::from_bytes`.
68    /// - ML-DSA-65: the 32 bytes are the FIPS 204 § Algorithm 1 seed `xi`
69    ///   passed to `SigningKey::<MlDsa65>::new(&seed)` (== KeyInit /
70    ///   `from_seed`).
71    pub fn generate_with_provider<R: crate::crypto::rng::RngProvider + ?Sized>(
72        provider: &R,
73    ) -> (Self, HybridVerifyingKey) {
74        // Ed25519 — 32-byte seed.
75        let mut ed_seed = [0u8; 32];
76        provider.fill_bytes(&mut ed_seed);
77        let ed25519_sk = SigningKey::from_bytes(&ed_seed);
78        let ed25519_pk = ed25519_sk.verifying_key();
79
80        // ML-DSA-65 (FIPS 204 § Algorithm 1 ML-DSA.KeyGen). The seed is
81        // the 32-byte `xi`. `KeyInit::new` runs the algorithm
82        // deterministically over it. Box immediately so the ~4 KiB
83        // expanded signing key never lives on the stack (matches the
84        // rationale in `from_bytes`).
85        let mut ml_seed_bytes = [0u8; 32];
86        provider.fill_bytes(&mut ml_seed_bytes);
87        let ml_dsa_seed = ml_dsa::B32::from(ml_seed_bytes);
88        let ml_dsa_sk = Box::new(MlDsaSigningKey::<MlDsa65>::new(&ml_dsa_seed));
89        let ml_dsa_vk = ml_dsa_sk.verifying_key();
90
91        let signing_key = Self {
92            ed25519_sk,
93            ml_dsa_sk,
94        };
95        let verifying_key = HybridVerifyingKey {
96            ed25519_pk: ed25519_pk.to_bytes(),
97            ml_dsa_pk: ml_dsa_vk.encode().to_vec(),
98        };
99        (signing_key, verifying_key)
100    }
101
102    /// FIPS 140-3 §7.10 pairwise-consistency test for a freshly-generated
103    /// **long-term** hybrid signing identity: sign a fixed message with this
104    /// secret key and verify it against `verifying_key`. Returns `Err` iff the
105    /// keypair cannot validate its own signature — i.e. the RNG or a signing
106    /// primitive was faulted and the key must not be used.
107    ///
108    /// Call this **only** at persisted / long-lived-identity generation sites
109    /// (CLI keygen, server identity load-or-create, `PhantomListener::bind`).
110    /// It is deliberately **not** run inside [`generate`](Self::generate) /
111    /// [`generate_with_provider`](Self::generate_with_provider): those mint the
112    /// client's *ephemeral* per-handshake signing key, and a sign+verify there
113    /// would add ~40% to every client handshake. The requirement targets
114    /// long-term keypairs, not per-connection ephemerals.
115    pub fn pairwise_consistency_check(
116        &self,
117        verifying_key: &HybridVerifyingKey,
118    ) -> Result<(), HybridSignError> {
119        let msg: &[u8] = b"phantom-protocol keygen pairwise-consistency test";
120        verifying_key.verify(msg, &self.sign(msg))
121    }
122
123    /// Sign with both algorithms. Both signatures are returned in the
124    /// `HybridSignature`; verification on the peer side requires both to
125    /// be valid.
126    pub fn sign(&self, message: &[u8]) -> HybridSignature {
127        let ed25519_sig = self.ed25519_sk.sign(message);
128        let ml_dsa_sig: MlDsaSignature<MlDsa65> = self.ml_dsa_sk.sign(message);
129        HybridSignature {
130            ed25519_sig: ed25519_sig.to_bytes(),
131            ml_dsa_sig: ml_dsa_sig.encode().to_vec(),
132        }
133    }
134
135    pub fn verifying_key(&self) -> HybridVerifyingKey {
136        let ed25519_pk = self.ed25519_sk.verifying_key();
137        HybridVerifyingKey {
138            ed25519_pk: ed25519_pk.to_bytes(),
139            ml_dsa_pk: self.ml_dsa_sk.verifying_key().encode().to_vec(),
140        }
141    }
142
143    /// Serialize the signing key as `(ed25519_seed[32] || ml_dsa_seed[32])`.
144    /// ML-DSA-65 is fully derivable from its 32-byte seed (per FIPS 204
145    /// `KeyGen` algorithm) so we store the compact form rather than the
146    /// 4032-byte expanded representation.
147    pub fn to_bytes(&self) -> Vec<u8> {
148        let mut out = Vec::with_capacity(64);
149        out.extend_from_slice(&self.ed25519_sk.to_bytes());
150        // KeyExport::to_bytes returns the 32-byte seed.
151        out.extend_from_slice(self.ml_dsa_sk.to_bytes().as_slice());
152        out
153    }
154
155    /// Restore from `to_bytes` output.
156    pub fn from_bytes(bytes: &[u8]) -> Result<Self, HybridSignError> {
157        if bytes.len() != 64 {
158            return Err(HybridSignError::InvalidKeyLength);
159        }
160        let ed25519_bytes: [u8; 32] = bytes[..32]
161            .try_into()
162            .map_err(|_| HybridSignError::InvalidKeyFormat)?;
163        let ed25519_sk = SigningKey::from_bytes(&ed25519_bytes);
164        let ml_dsa_seed =
165            ml_dsa::B32::try_from(&bytes[32..]).map_err(|_| HybridSignError::InvalidKeyFormat)?;
166        // `KeyInit::new(&seed)` reconstructs the SigningKey from its seed
167        // (algorithm 1 in FIPS 204: ML-DSA.KeyGen). Boxed for the same
168        // stack-overflow reason as `generate`.
169        let ml_dsa_sk = Box::new(MlDsaSigningKey::<MlDsa65>::new(&ml_dsa_seed));
170        Ok(Self {
171            ed25519_sk,
172            ml_dsa_sk,
173        })
174    }
175}
176
177impl fmt::Debug for HybridSigningKey {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        f.debug_struct("HybridSigningKey")
180            .field("ed25519_sk", &"REDACTED")
181            .field("ml_dsa_sk", &"REDACTED")
182            .finish()
183    }
184}
185
186#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq, Eq)]
187pub struct HybridVerifyingKey {
188    pub ed25519_pk: [u8; 32],
189    pub ml_dsa_pk: Vec<u8>,
190}
191
192impl HybridVerifyingKey {
193    /// Both signatures must verify for the hybrid to be considered valid.
194    pub fn verify(
195        &self,
196        message: &[u8],
197        signature: &HybridSignature,
198    ) -> Result<(), HybridSignError> {
199        // Ed25519
200        let ed25519_pk = VerifyingKey::from_bytes(&self.ed25519_pk)
201            .map_err(|_| HybridSignError::InvalidPublicKey)?;
202        let ed25519_sig = Ed25519Signature::from_bytes(&signature.ed25519_sig);
203        // CRYPTO-4: `verify_strict` rejects non-canonical / malleable signatures
204        // and low-order ("weak") public keys, which the lenient `verify` accepts.
205        // We only ever produce canonical signatures, so this never rejects a
206        // legitimate one; it just removes signature-malleability as a class.
207        ed25519_pk
208            .verify_strict(message, &ed25519_sig)
209            .map_err(|_| HybridSignError::Ed25519VerificationFailed)?;
210
211        // ML-DSA-65 (FIPS 204)
212        let vk_encoded = EncodedVerifyingKey::<MlDsa65>::try_from(self.ml_dsa_pk.as_slice())
213            .map_err(|_| HybridSignError::InvalidPublicKey)?;
214        let vk = MlDsaVerifyingKey::<MlDsa65>::decode(&vk_encoded);
215
216        let sig_encoded = EncodedSignature::<MlDsa65>::try_from(signature.ml_dsa_sig.as_slice())
217            .map_err(|_| HybridSignError::InvalidSignature)?;
218        let sig = MlDsaSignature::<MlDsa65>::decode(&sig_encoded)
219            .ok_or(HybridSignError::InvalidSignature)?;
220
221        vk.verify(message, &sig)
222            .map_err(|_| HybridSignError::DilithiumVerificationFailed)
223    }
224
225    pub fn to_bytes(&self) -> Vec<u8> {
226        let mut out = Vec::with_capacity(32 + self.ml_dsa_pk.len());
227        out.extend_from_slice(&self.ed25519_pk);
228        out.extend_from_slice(&self.ml_dsa_pk);
229        out
230    }
231
232    pub fn from_bytes(bytes: &[u8]) -> Result<Self, HybridSignError> {
233        const ED_SIZE: usize = 32;
234        // FIPS-204 ML-DSA-65 verifying-key encoded size = 1952 bytes.
235        const VK_SIZE: usize = 1952;
236        if bytes.len() != ED_SIZE + VK_SIZE {
237            return Err(HybridSignError::InvalidKeyLength);
238        }
239        let ed25519_pk: [u8; ED_SIZE] = bytes[..ED_SIZE]
240            .try_into()
241            .map_err(|_| HybridSignError::InvalidKeyFormat)?;
242        let ml_dsa_pk = bytes[ED_SIZE..].to_vec();
243        Ok(Self {
244            ed25519_pk,
245            ml_dsa_pk,
246        })
247    }
248}
249
250#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
251pub struct HybridSignature {
252    pub ed25519_sig: [u8; 64],
253    pub ml_dsa_sig: Vec<u8>,
254}
255
256impl HybridSignature {
257    pub fn size(&self) -> usize {
258        64 + self.ml_dsa_sig.len()
259    }
260
261    pub fn to_bytes(&self) -> Vec<u8> {
262        let mut out = Vec::with_capacity(64 + self.ml_dsa_sig.len());
263        out.extend_from_slice(&self.ed25519_sig);
264        out.extend_from_slice(&self.ml_dsa_sig);
265        out
266    }
267
268    pub fn from_bytes(bytes: &[u8]) -> Result<Self, HybridSignError> {
269        if bytes.len() < 64 {
270            return Err(HybridSignError::InvalidSignatureLength);
271        }
272        let ed25519_sig: [u8; 64] = bytes[..64]
273            .try_into()
274            .map_err(|_| HybridSignError::InvalidKeyFormat)?;
275        let ml_dsa_sig = bytes[64..].to_vec();
276        Ok(Self {
277            ed25519_sig,
278            ml_dsa_sig,
279        })
280    }
281}
282
283#[derive(Debug, Clone, Copy, thiserror::Error)]
284pub enum HybridSignError {
285    #[error("Invalid key length")]
286    InvalidKeyLength,
287    #[error("Invalid key format")]
288    InvalidKeyFormat,
289    #[error("Invalid public key")]
290    InvalidPublicKey,
291    #[error("Invalid signature")]
292    InvalidSignature,
293    #[error("Invalid signature length")]
294    InvalidSignatureLength,
295    #[error("Ed25519 verification failed")]
296    Ed25519VerificationFailed,
297    #[error("Dilithium verification failed")]
298    DilithiumVerificationFailed,
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_hybrid_sign_verify() {
307        let (signing_key, verifying_key) = HybridSigningKey::generate();
308        let message = b"Hello, post-quantum world!";
309        let signature = signing_key.sign(message);
310
311        assert!(verifying_key.verify(message, &signature).is_ok());
312
313        let wrong = b"Wrong message";
314        assert!(verifying_key.verify(wrong, &signature).is_err());
315    }
316
317    #[test]
318    fn pairwise_consistency_check_passes_for_a_matched_keypair_and_fails_for_a_mismatch() {
319        let (sk, vk) = HybridSigningKey::generate();
320        // A correctly-generated keypair passes its own PCT.
321        assert!(sk.pairwise_consistency_check(&vk).is_ok());
322
323        // A signing key checked against a DIFFERENT public key fails — exactly
324        // the fault-injected-keygen signal the long-term-identity sites rely on.
325        let (_other_sk, other_vk) = HybridSigningKey::generate();
326        assert!(sk.pairwise_consistency_check(&other_vk).is_err());
327    }
328
329    #[test]
330    fn test_key_serialization() {
331        let (signing_key, verifying_key) = HybridSigningKey::generate();
332        let bytes = signing_key.to_bytes();
333        let restored = HybridSigningKey::from_bytes(&bytes).expect("restore");
334
335        let message = b"Test message";
336        let sig = restored.sign(message);
337        assert!(verifying_key.verify(message, &sig).is_ok());
338
339        let pk_bytes = verifying_key.to_bytes();
340        let restored_pk = HybridVerifyingKey::from_bytes(&pk_bytes).expect("restore vk");
341        assert!(restored_pk.verify(message, &sig).is_ok());
342    }
343
344    #[test]
345    fn test_signature_sizes() {
346        let (signing_key, _) = HybridSigningKey::generate();
347        let message = b"Size test";
348        let signature = signing_key.sign(message);
349        // FIPS-204 ML-DSA-65 signature is 3309 bytes.
350        assert_eq!(signature.ed25519_sig.len(), 64);
351        assert_eq!(signature.ml_dsa_sig.len(), 3309);
352    }
353}