#![forbid(unsafe_code)]
use core::fmt;
use bip39::{Language, Mnemonic};
use rand_core::RngCore;
use sha3::digest::{ExtendableOutput, Update, XofReader};
use sha3::Shake256;
use thiserror::Error;
use zeroize::Zeroizing;
pub const PQM1_ALGO_TAG_MLDSA65: u8 = 0x01;
pub const PQM1_ALGO_TAG_MLDSA87_RESERVED: u8 = 0x02;
pub const PQM1_ALGO_TAG_SLHDSA128S_RESERVED: u8 = 0x03;
pub const PQM1_ALGO_TAG_FALCON512_RESERVED: u8 = 0x04;
pub const PQM1_VERSION_V1: u8 = 0x01;
pub const PQM1_PAYLOAD_LEN: usize = 32;
pub const PQM1_ENTROPY_LEN: usize = 30;
pub const PQM1_V1_MNEMONIC_WORDS: usize = 24;
pub const ML_DSA_65_SEED_LEN: usize = 32;
pub const PQM1_V1_MLDSA65_DOMAIN_TAG: &[u8] = b"monolythium.pqm1.v1.mldsa65";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pqm1Profile<'a> {
pub mldsa65_domain_tag: &'a [u8],
}
impl Pqm1Profile<'static> {
pub const MONOLYTHIUM: Self = Self {
mldsa65_domain_tag: PQM1_V1_MLDSA65_DOMAIN_TAG,
};
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Pqm1Error {
#[error("BIP-39 phrase decode failed: {0}")]
Bip39Decode(String),
#[error("expected 32-byte payload, got {got} bytes")]
PayloadLength {
got: usize,
},
#[error(
"unsupported PQM-1 algorithm tag: {got:#04x} (this build only honours ML-DSA-65 = 0x01)"
)]
UnsupportedAlgo {
got: u8,
},
#[error("unsupported PQM-1 version: {got:#04x} (this build only honours v1 = 0x01)")]
UnsupportedVersion {
got: u8,
},
#[error("ML-DSA-65 keygen failed: {0}")]
KeygenFailed(String),
}
#[derive(Debug)]
pub struct Pqm1Payload {
bytes: Zeroizing<[u8; PQM1_PAYLOAD_LEN]>,
}
impl Pqm1Payload {
#[must_use]
pub fn from_bytes(bytes: [u8; PQM1_PAYLOAD_LEN]) -> Self {
Self {
bytes: Zeroizing::new(bytes),
}
}
#[must_use]
pub fn assemble(algo: u8, version: u8, entropy: [u8; PQM1_ENTROPY_LEN]) -> Self {
let mut buf = [0u8; PQM1_PAYLOAD_LEN];
buf[0] = algo;
buf[1] = version;
buf[2..].copy_from_slice(&entropy);
Self::from_bytes(buf)
}
pub fn parse(bytes: &[u8]) -> Result<Self, Pqm1Error> {
if bytes.len() != PQM1_PAYLOAD_LEN {
return Err(Pqm1Error::PayloadLength { got: bytes.len() });
}
if bytes[0] != PQM1_ALGO_TAG_MLDSA65 {
return Err(Pqm1Error::UnsupportedAlgo { got: bytes[0] });
}
if bytes[1] != PQM1_VERSION_V1 {
return Err(Pqm1Error::UnsupportedVersion { got: bytes[1] });
}
let mut buf = [0u8; PQM1_PAYLOAD_LEN];
buf.copy_from_slice(bytes);
Ok(Self::from_bytes(buf))
}
#[must_use]
pub fn algo(&self) -> u8 {
self.bytes[0]
}
#[must_use]
pub fn version(&self) -> u8 {
self.bytes[1]
}
#[must_use]
pub fn entropy(&self) -> [u8; PQM1_ENTROPY_LEN] {
let mut out = [0u8; PQM1_ENTROPY_LEN];
out.copy_from_slice(&self.bytes[2..]);
out
}
#[must_use]
pub fn as_bytes(&self) -> [u8; PQM1_PAYLOAD_LEN] {
let mut out = [0u8; PQM1_PAYLOAD_LEN];
out.copy_from_slice(self.bytes.as_ref());
out
}
pub fn from_mnemonic(phrase: &str) -> Result<Self, Pqm1Error> {
let mnemonic = Mnemonic::parse_in(Language::English, phrase)
.map_err(|e| Pqm1Error::Bip39Decode(e.to_string()))?;
if mnemonic.word_count() != PQM1_V1_MNEMONIC_WORDS {
return Err(Pqm1Error::Bip39Decode(format!(
"PQM-1 v1 requires a {PQM1_V1_MNEMONIC_WORDS}-word phrase; got {}",
mnemonic.word_count()
)));
}
let (entropy_bytes, entropy_len) = mnemonic.to_entropy_array();
Self::parse(&entropy_bytes[..entropy_len])
}
pub fn to_mnemonic(&self) -> Result<String, Pqm1Error> {
let mnemonic = Mnemonic::from_entropy_in(Language::English, self.bytes.as_ref())
.map_err(|e| Pqm1Error::Bip39Decode(e.to_string()))?;
Ok(mnemonic.to_string())
}
#[must_use]
pub fn derive_mldsa65_seed(&self) -> Zeroizing<[u8; ML_DSA_65_SEED_LEN]> {
self.derive_mldsa65_seed_with_profile(Pqm1Profile::MONOLYTHIUM)
}
#[must_use]
pub fn derive_mldsa65_seed_with_profile(
&self,
profile: Pqm1Profile<'_>,
) -> Zeroizing<[u8; ML_DSA_65_SEED_LEN]> {
self.derive_mldsa65_seed_with_domain_tag(profile.mldsa65_domain_tag)
}
#[must_use]
pub fn derive_mldsa65_seed_with_domain_tag(
&self,
domain_tag: &[u8],
) -> Zeroizing<[u8; ML_DSA_65_SEED_LEN]> {
let mut hasher = Shake256::default();
hasher.update(domain_tag);
hasher.update(self.bytes.as_ref());
let mut reader = hasher.finalize_xof();
let mut seed = [0u8; ML_DSA_65_SEED_LEN];
reader.read(&mut seed);
Zeroizing::new(seed)
}
}
impl fmt::Display for Pqm1Payload {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Pqm1Payload {{ algo: {:#04x}, version: {:#04x}, entropy: <30 bytes> }}",
self.algo(),
self.version()
)
}
}
pub fn generate_mnemonic(rng: &mut impl RngCore) -> Result<String, Pqm1Error> {
let mut entropy = [0u8; PQM1_ENTROPY_LEN];
rng.fill_bytes(&mut entropy);
let payload = Pqm1Payload::assemble(PQM1_ALGO_TAG_MLDSA65, PQM1_VERSION_V1, entropy);
payload.to_mnemonic()
}
pub fn mnemonic_to_mldsa65_seed(
phrase: &str,
) -> Result<Zeroizing<[u8; ML_DSA_65_SEED_LEN]>, Pqm1Error> {
Ok(Pqm1Payload::from_mnemonic(phrase)?.derive_mldsa65_seed())
}
pub fn mnemonic_to_mldsa65_seed_with_profile(
phrase: &str,
profile: Pqm1Profile<'_>,
) -> Result<Zeroizing<[u8; ML_DSA_65_SEED_LEN]>, Pqm1Error> {
Ok(Pqm1Payload::from_mnemonic(phrase)?.derive_mldsa65_seed_with_profile(profile))
}