pqm1 0.1.0

Experimental post-quantum mnemonic payload format: BIP-39 24-word payloads with algorithm/version tags and SHAKE256 ML-DSA seed derivation. Unaudited.
Documentation
//! # PQM-1
//!
//! Post-Quantum Mnemonic v1: a 24-word BIP-39-compatible payload format
//! for deterministic ML-DSA-65 wallet recovery.
//!
//! This crate implements only the portable layer:
//! BIP-39 phrase <-> 32-byte PQM-1 payload <-> SHAKE256-derived
//! 32-byte ML-DSA-65 seed. It carries zero chain address/account types.
//! Chains define their own profile domain and account/address derivation above
//! this API.
//!
//! ## Minimal usage
//! ```
//! use pqm1::{Pqm1Payload, Pqm1Profile};
//!
//! let phrase =
//!     "absurd amount abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon armor";
//! let payload = Pqm1Payload::from_mnemonic(phrase).unwrap();
//! let seed = payload.derive_mldsa65_seed();
//!
//! let near = Pqm1Profile { mldsa65_domain_tag: b"near.pqm1.v1.mldsa65" };
//! let near_seed = payload.derive_mldsa65_seed_with_profile(near);
//! assert_ne!(&*seed, &*near_seed);
//! ```
//!
//! ## Caller invariants
//!
//! - PQM-1 v1 is one phrase -> one ML-DSA-65 seed. There are no BIP-32 /
//!   BIP-44 paths in this format.
//! - A profile domain is part of the seed derivation. Use a chain/project-
//!   specific domain when adopting PQM-1 outside Monolythium.
//! - This crate does not encrypt or persist keys. Store derived seeds/signing
//!   keys only through a real keystore.

#![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;

/// Algorithm tag for ML-DSA-65 (the only v1 production algorithm).
pub const PQM1_ALGO_TAG_MLDSA65: u8 = 0x01;

/// Algorithm tag reserved for ML-DSA-87.
pub const PQM1_ALGO_TAG_MLDSA87_RESERVED: u8 = 0x02;

/// Algorithm tag reserved for SLH-DSA-128s.
pub const PQM1_ALGO_TAG_SLHDSA128S_RESERVED: u8 = 0x03;

/// Algorithm tag reserved for FALCON-512.
pub const PQM1_ALGO_TAG_FALCON512_RESERVED: u8 = 0x04;

/// PQM-1 version byte (`0x01` for v1).
pub const PQM1_VERSION_V1: u8 = 0x01;

/// Total payload length: 1 byte algo + 1 byte version + 30 bytes entropy.
pub const PQM1_PAYLOAD_LEN: usize = 32;

/// Entropy length carried inside the PQM-1 v1 payload.
pub const PQM1_ENTROPY_LEN: usize = 30;

/// Number of words in a PQM-1 v1 mnemonic.
pub const PQM1_V1_MNEMONIC_WORDS: usize = 24;

/// FIPS-204 ML-DSA seed length used by PQM-1 v1.
pub const ML_DSA_65_SEED_LEN: usize = 32;

/// Monolythium's locked PQM-1 v1 SHAKE256 domain tag for ML-DSA-65.
///
/// External adopters should define a different chain/project-specific domain
/// tag and pass it via [`Pqm1Profile`] or
/// [`Pqm1Payload::derive_mldsa65_seed_with_domain_tag`].
pub const PQM1_V1_MLDSA65_DOMAIN_TAG: &[u8] = b"monolythium.pqm1.v1.mldsa65";

/// Profile parameters that sit above the chain-neutral PQM-1 payload.
///
/// A profile domain separates two chains using the same 24-word phrase from
/// deriving the same ML-DSA seed. For example, NEAR could use
/// `b"near.pqm1.v1.mldsa65"` while keeping the payload layout identical.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pqm1Profile<'a> {
    /// Domain tag prepended to the PQM-1 payload before SHAKE256 expansion.
    pub mldsa65_domain_tag: &'a [u8],
}

impl Pqm1Profile<'static> {
    /// Monolythium's locked PQM-1 v1 profile.
    pub const MONOLYTHIUM: Self = Self {
        mldsa65_domain_tag: PQM1_V1_MLDSA65_DOMAIN_TAG,
    };
}

/// Errors decoding or deriving from a PQM-1 mnemonic / payload.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Pqm1Error {
    /// The 24-word phrase failed BIP-39 entropy decode.
    #[error("BIP-39 phrase decode failed: {0}")]
    Bip39Decode(String),

    /// The decoded BIP-39 entropy length was not the PQM-1 v1 payload size.
    #[error("expected 32-byte payload, got {got} bytes")]
    PayloadLength {
        /// Number of bytes actually decoded.
        got: usize,
    },

    /// The algorithm tag is not supported by this v1 implementation.
    #[error(
        "unsupported PQM-1 algorithm tag: {got:#04x} (this build only honours ML-DSA-65 = 0x01)"
    )]
    UnsupportedAlgo {
        /// Offending algorithm tag byte.
        got: u8,
    },

    /// The version byte is not supported by this implementation.
    #[error("unsupported PQM-1 version: {got:#04x} (this build only honours v1 = 0x01)")]
    UnsupportedVersion {
        /// Offending version byte.
        got: u8,
    },

    /// Integration-level ML-DSA key generation or wrapping failed.
    ///
    /// The core crate does not emit this variant itself; it is kept so chain
    /// adapters can preserve one typed PQM-1 error surface.
    #[error("ML-DSA-65 keygen failed: {0}")]
    KeygenFailed(String),
}

/// A decoded PQM-1 v1 payload: algorithm tag + version + 240 bits of entropy.
///
/// The backing bytes are zeroized on drop so wallet code can avoid leaving
/// phrase entropy in ordinary heap memory longer than needed.
#[derive(Debug)]
pub struct Pqm1Payload {
    bytes: Zeroizing<[u8; PQM1_PAYLOAD_LEN]>,
}

impl Pqm1Payload {
    /// Construct from a raw 32-byte buffer without validation.
    #[must_use]
    pub fn from_bytes(bytes: [u8; PQM1_PAYLOAD_LEN]) -> Self {
        Self {
            bytes: Zeroizing::new(bytes),
        }
    }

    /// Construct from algo + version + 30 bytes of entropy.
    #[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)
    }

    /// Parse and validate a PQM-1 v1 payload.
    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))
    }

    /// Algorithm tag (byte 0).
    #[must_use]
    pub fn algo(&self) -> u8 {
        self.bytes[0]
    }

    /// Version byte (byte 1).
    #[must_use]
    pub fn version(&self) -> u8 {
        self.bytes[1]
    }

    /// 30-byte entropy slice (bytes 2..32).
    #[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
    }

    /// Raw 32-byte payload.
    #[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
    }

    /// Decode a 24-word BIP-39 English-wordlist phrase into a PQM-1 payload.
    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])
    }

    /// Encode this payload as a 24-word BIP-39 English-wordlist phrase.
    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())
    }

    /// Derive the Monolythium-profile ML-DSA-65 seed from this payload.
    ///
    /// External adopters should prefer
    /// [`Self::derive_mldsa65_seed_with_profile`] with their own domain.
    #[must_use]
    pub fn derive_mldsa65_seed(&self) -> Zeroizing<[u8; ML_DSA_65_SEED_LEN]> {
        self.derive_mldsa65_seed_with_profile(Pqm1Profile::MONOLYTHIUM)
    }

    /// Derive an ML-DSA-65 seed using a caller-supplied PQM-1 profile.
    #[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)
    }

    /// Derive an ML-DSA-65 seed with an explicit SHAKE256 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()
        )
    }
}

/// Mint a fresh PQM-1 v1 mnemonic from caller-supplied randomness.
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()
}

/// Decode a PQM-1 v1 mnemonic and derive the Monolythium-profile ML-DSA seed.
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())
}

/// Decode a PQM-1 v1 mnemonic and derive an ML-DSA seed for `profile`.
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))
}