use crate::constants::*;
use crate::error::{Error, Result};
use crate::keys::PublicKeyBundle;
use alloc::string::String;
use alloc::vec::Vec;
use ed25519_dalek::pkcs8::{DecodePublicKey as _, EncodePublicKey as _};
use ml_dsa::{KeyExport as _, MlDsa87};
use ml_kem::pkcs8::{DecodePublicKey as _, EncodePublicKey as _};
use ml_kem::EncapsulationKey1024;
const X25519_PEM_LABEL: &str = "X25519 PUBLIC KEY";
impl PublicKeyBundle {
#[cfg_attr(docsrs, doc(cfg(feature = "pem")))]
pub fn to_pem(&self) -> Result<String> {
let x25519 = pem_rfc7468::encode_string(
X25519_PEM_LABEL,
pem_rfc7468::LineEnding::LF,
self.x25519.as_bytes(),
)
.map_err(|_| Error::InvalidKey)?;
let mlkem = self
.mlkem
.to_public_key_pem(Default::default())
.map_err(|_| Error::InvalidKey)?;
let mldsa = self
.mldsa
.to_public_key_pem(Default::default())
.map_err(|_| Error::InvalidKey)?;
let ed25519 = self
.ed25519
.to_public_key_pem(Default::default())
.map_err(|_| Error::InvalidKey)?;
let mut out =
String::with_capacity(x25519.len() + mlkem.len() + mldsa.len() + ed25519.len());
out.push_str(&x25519);
out.push_str(&mlkem);
out.push_str(&mldsa);
out.push_str(&ed25519);
Ok(out)
}
#[cfg_attr(docsrs, doc(cfg(feature = "pem")))]
pub fn from_pem(pem: &str) -> Result<Self> {
let blocks = split_pem_blocks(pem);
if blocks.len() != 4 {
return Err(Error::InvalidKey);
}
let (label, x25519_der) =
pem_rfc7468::decode_vec(blocks[0].as_bytes()).map_err(|_| Error::InvalidKey)?;
if label != X25519_PEM_LABEL || x25519_der.len() != X25519_PK_LEN {
return Err(Error::InvalidKey);
}
let mlkem =
EncapsulationKey1024::from_public_key_pem(&blocks[1]).map_err(|_| Error::InvalidKey)?;
let mldsa = ml_dsa::VerifyingKey::<MlDsa87>::from_public_key_pem(&blocks[2])
.map_err(|_| Error::InvalidKey)?;
let ed25519 = ed25519_dalek::VerifyingKey::from_public_key_pem(&blocks[3])
.map_err(|_| Error::InvalidKey)?;
let mut bytes = Vec::with_capacity(PUBLIC_BUNDLE_LEN);
bytes.extend_from_slice(&MAGIC_PUBLIC_BUNDLE);
bytes.push(WIRE_VERSION);
bytes.push(SUITE_ID);
bytes.extend_from_slice(&x25519_der);
bytes.extend_from_slice(&mlkem.to_bytes());
bytes.extend_from_slice(&ed25519.to_bytes());
bytes.extend_from_slice(&mldsa.encode());
Self::from_bytes(&bytes)
}
}
fn split_pem_blocks(pem: &str) -> Vec<String> {
let mut blocks = Vec::new();
let mut current = String::new();
for line in pem.lines() {
let trimmed = line.trim();
if trimmed.is_empty() && current.is_empty() {
continue;
}
current.push_str(trimmed);
current.push('\n');
if trimmed.starts_with("-----END") {
blocks.push(core::mem::take(&mut current));
}
}
blocks
}
#[cfg(test)]
mod tests {
use crate::HybridCrypto;
use crate::PublicKeyBundle;
#[test]
fn pem_roundtrip() {
let kp = HybridCrypto::generate().unwrap();
let pem = kp.public_keys().to_pem().unwrap();
assert!(pem.contains("BEGIN X25519 PUBLIC KEY"));
assert!(pem.contains("BEGIN PUBLIC KEY"));
let parsed = PublicKeyBundle::from_pem(&pem).unwrap();
assert_eq!(&parsed, kp.public_keys());
}
#[test]
fn rejects_wrong_block_count() {
let kp = HybridCrypto::generate().unwrap();
let pem = kp.public_keys().to_pem().unwrap();
let cut = pem.rfind("-----BEGIN PUBLIC KEY").unwrap();
assert!(PublicKeyBundle::from_pem(&pem[..cut]).is_err());
}
#[test]
fn rejects_reordered_blocks() {
let kp = HybridCrypto::generate().unwrap();
let pem = kp.public_keys().to_pem().unwrap();
let blocks = super::split_pem_blocks(&pem);
let reordered = alloc::format!("{}{}{}{}", blocks[0], blocks[3], blocks[2], blocks[1]);
assert!(PublicKeyBundle::from_pem(&reordered).is_err());
}
#[test]
fn tampered_x25519_yields_different_key() {
use alloc::string::String;
let kp = HybridCrypto::generate().unwrap();
let pem = kp.public_keys().to_pem().unwrap();
let body = pem.find('\n').unwrap() + 1;
let tampered: String = pem
.char_indices()
.map(|(i, c)| if i == body { flip(c) } else { c })
.collect();
if let Ok(parsed) = PublicKeyBundle::from_pem(&tampered) {
assert_ne!(&parsed, kp.public_keys());
}
}
fn flip(c: char) -> char {
match c {
'A'..='Y' | 'a'..='y' | '0'..='8' => ((c as u8) + 1) as char,
_ => 'A',
}
}
}