Skip to main content

quantum_shield/
pem.rs

1//! Per-component PEM export/import of public keys (`pem` feature).
2//!
3//! ML-KEM-1024, ML-DSA-87, and Ed25519 public keys are emitted as standard
4//! `SubjectPublicKeyInfo` PEM ("PUBLIC KEY") blocks via each crate's native
5//! support. X25519 has no PKCS#8/SPKI support upstream, so it is emitted as a
6//! raw block ("X25519 PUBLIC KEY") holding the 32 raw public-key bytes.
7//!
8//! The document is the four blocks concatenated in a fixed order
9//! (X25519, ML-KEM, ML-DSA, Ed25519). This is an interop convenience — the
10//! compact [`PublicKeyBundle::to_bytes`](crate::PublicKeyBundle::to_bytes)
11//! (`QSP2`) remains the primary, validated key format, and `from_pem`
12//! round-trips through it so parsing enforces exactly the same checks.
13
14use crate::constants::*;
15use crate::error::{Error, Result};
16use crate::keys::PublicKeyBundle;
17use alloc::string::String;
18use alloc::vec::Vec;
19
20use ed25519_dalek::pkcs8::{DecodePublicKey as _, EncodePublicKey as _};
21use ml_dsa::{KeyExport as _, MlDsa87};
22use ml_kem::pkcs8::{DecodePublicKey as _, EncodePublicKey as _};
23use ml_kem::EncapsulationKey1024;
24
25const X25519_PEM_LABEL: &str = "X25519 PUBLIC KEY";
26
27impl PublicKeyBundle {
28    /// Serialize the public keys as a concatenated multi-PEM document.
29    ///
30    /// # Errors
31    ///
32    /// Returns [`Error::InvalidKey`] if any component fails to PEM-encode
33    /// (not expected for a valid bundle).
34    #[cfg_attr(docsrs, doc(cfg(feature = "pem")))]
35    pub fn to_pem(&self) -> Result<String> {
36        let x25519 = pem_rfc7468::encode_string(
37            X25519_PEM_LABEL,
38            pem_rfc7468::LineEnding::LF,
39            self.x25519.as_bytes(),
40        )
41        .map_err(|_| Error::InvalidKey)?;
42
43        let mlkem = self
44            .mlkem
45            .to_public_key_pem(Default::default())
46            .map_err(|_| Error::InvalidKey)?;
47        let mldsa = self
48            .mldsa
49            .to_public_key_pem(Default::default())
50            .map_err(|_| Error::InvalidKey)?;
51        let ed25519 = self
52            .ed25519
53            .to_public_key_pem(Default::default())
54            .map_err(|_| Error::InvalidKey)?;
55
56        let mut out =
57            String::with_capacity(x25519.len() + mlkem.len() + mldsa.len() + ed25519.len());
58        out.push_str(&x25519);
59        out.push_str(&mlkem);
60        out.push_str(&mldsa);
61        out.push_str(&ed25519);
62        Ok(out)
63    }
64
65    /// Parse a public-key bundle from a [`PublicKeyBundle::to_pem`] document.
66    ///
67    /// The four blocks must appear in order (X25519, ML-KEM, ML-DSA, Ed25519).
68    /// Every component is validated (the result round-trips through the
69    /// canonical `QSP2` parser).
70    ///
71    /// # Errors
72    ///
73    /// Returns [`Error::InvalidKey`] on any malformed block, wrong count,
74    /// wrong order, or component that fails validation.
75    #[cfg_attr(docsrs, doc(cfg(feature = "pem")))]
76    pub fn from_pem(pem: &str) -> Result<Self> {
77        let blocks = split_pem_blocks(pem);
78        if blocks.len() != 4 {
79            return Err(Error::InvalidKey);
80        }
81
82        // Block 0: X25519 raw block.
83        let (label, x25519_der) =
84            pem_rfc7468::decode_vec(blocks[0].as_bytes()).map_err(|_| Error::InvalidKey)?;
85        if label != X25519_PEM_LABEL || x25519_der.len() != X25519_PK_LEN {
86            return Err(Error::InvalidKey);
87        }
88
89        // Blocks 1-3: standard SPKI, parsed by their respective crates.
90        let mlkem =
91            EncapsulationKey1024::from_public_key_pem(&blocks[1]).map_err(|_| Error::InvalidKey)?;
92        let mldsa = ml_dsa::VerifyingKey::<MlDsa87>::from_public_key_pem(&blocks[2])
93            .map_err(|_| Error::InvalidKey)?;
94        let ed25519 = ed25519_dalek::VerifyingKey::from_public_key_pem(&blocks[3])
95            .map_err(|_| Error::InvalidKey)?;
96
97        // Reassemble the canonical QSP2 bytes and reuse its validated parser,
98        // so PEM import enforces exactly the same checks as `from_bytes`.
99        let mut bytes = Vec::with_capacity(PUBLIC_BUNDLE_LEN);
100        bytes.extend_from_slice(&MAGIC_PUBLIC_BUNDLE);
101        bytes.push(WIRE_VERSION);
102        bytes.push(SUITE_ID);
103        bytes.extend_from_slice(&x25519_der);
104        bytes.extend_from_slice(&mlkem.to_bytes());
105        bytes.extend_from_slice(&ed25519.to_bytes());
106        bytes.extend_from_slice(&mldsa.encode());
107        Self::from_bytes(&bytes)
108    }
109}
110
111/// Split a concatenated PEM document into its constituent block strings,
112/// each a complete `-----BEGIN…-----END-----` unit.
113fn split_pem_blocks(pem: &str) -> Vec<String> {
114    let mut blocks = Vec::new();
115    let mut current = String::new();
116    for line in pem.lines() {
117        let trimmed = line.trim();
118        if trimmed.is_empty() && current.is_empty() {
119            continue;
120        }
121        current.push_str(trimmed);
122        current.push('\n');
123        if trimmed.starts_with("-----END") {
124            blocks.push(core::mem::take(&mut current));
125        }
126    }
127    blocks
128}
129
130#[cfg(test)]
131mod tests {
132    use crate::HybridCrypto;
133    use crate::PublicKeyBundle;
134
135    #[test]
136    fn pem_roundtrip() {
137        let kp = HybridCrypto::generate().unwrap();
138        let pem = kp.public_keys().to_pem().unwrap();
139        assert!(pem.contains("BEGIN X25519 PUBLIC KEY"));
140        assert!(pem.contains("BEGIN PUBLIC KEY"));
141        let parsed = PublicKeyBundle::from_pem(&pem).unwrap();
142        assert_eq!(&parsed, kp.public_keys());
143    }
144
145    #[test]
146    fn rejects_wrong_block_count() {
147        let kp = HybridCrypto::generate().unwrap();
148        let pem = kp.public_keys().to_pem().unwrap();
149        // Drop the last block (Ed25519).
150        let cut = pem.rfind("-----BEGIN PUBLIC KEY").unwrap();
151        assert!(PublicKeyBundle::from_pem(&pem[..cut]).is_err());
152    }
153
154    #[test]
155    fn rejects_reordered_blocks() {
156        let kp = HybridCrypto::generate().unwrap();
157        let pem = kp.public_keys().to_pem().unwrap();
158        let blocks = super::split_pem_blocks(&pem);
159        // Swap ML-KEM and Ed25519 (blocks 1 and 3): SPKI parse mismatches.
160        let reordered = alloc::format!("{}{}{}{}", blocks[0], blocks[3], blocks[2], blocks[1]);
161        assert!(PublicKeyBundle::from_pem(&reordered).is_err());
162    }
163
164    #[test]
165    fn tampered_x25519_yields_different_key() {
166        use alloc::string::String;
167        let kp = HybridCrypto::generate().unwrap();
168        let pem = kp.public_keys().to_pem().unwrap();
169        // Flip one base64 char on the first body line of the X25519 block.
170        // X25519 has no point validation — every 32-byte string is a valid
171        // u-coordinate — so this parses to a *different* bundle, not an error.
172        let body = pem.find('\n').unwrap() + 1;
173        let tampered: String = pem
174            .char_indices()
175            .map(|(i, c)| if i == body { flip(c) } else { c })
176            .collect();
177        // Err is also acceptable (e.g. if base64 became invalid); a successful
178        // parse must at least differ from the original.
179        if let Ok(parsed) = PublicKeyBundle::from_pem(&tampered) {
180            assert_ne!(&parsed, kp.public_keys());
181        }
182    }
183
184    fn flip(c: char) -> char {
185        match c {
186            'A'..='Y' | 'a'..='y' | '0'..='8' => ((c as u8) + 1) as char,
187            _ => 'A',
188        }
189    }
190}