code/primitives/hashed/src/mldsa.rs

mldsa.rs

396 lines · 13,280 bytes · d967d13588RawGitHub
//! ML-DSA-65 authorization inside the unchanged signed-v4 `MultiAddress::Raw`.
//!
//! V1 encodings and domains are independent. The ordinary `MultiSignature`
//! field contains only [`CARRIER_SIGNATURE`]; it is never an authorization.

use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen, Output};
use curve25519_dalek::{edwards::CompressedEdwardsY, ristretto::CompressedRistretto};
use fips204::{
    ml_dsa_65,
    traits::{KeyGen, SerDes, Signer, Verifier},
};
use scale_info::TypeInfo;
use sha3::{
    Shake256,
    digest::{ExtendableOutput, Update, XofReader},
};

pub const VERSION: u8 = 2;
/// Original rotating ML-DSA-65 authorization profile; its encoding is unchanged.
pub const ALGORITHM: u8 = 4;
/// Registration-free fixed-key ML-DSA-65 profile. Its derived account must not
/// encode a legacy Ed25519 or Sr25519 public key.
pub const FIXED_ALGORITHM: u8 = 5;

pub fn is_account_profile(profile: u8) -> bool {
    matches!(profile, ALGORITHM | FIXED_ALGORITHM)
}
pub const PUBLIC_KEY_LEN: usize = 1952;
pub const SIGNATURE_LEN: usize = 3309;
pub const COMMITMENT_LEN: usize = 64;
pub const ACCOUNT_DOMAIN: &[u8] = b"bittensor/ms/v2/account";
pub const KEY_DOMAIN: &[u8] = b"bittensor/ms/v2/key";
pub const TRANSACTION_DOMAIN: &[u8] = b"bittensor/ms/v2/transaction";
pub const TRANSACTION_CONTEXT: &[u8] = b"bittensor/ms/v2/tx";
const _: () = assert!(TRANSACTION_CONTEXT.len() == 18);
pub const CARRIER_SIGNATURE: [u8; 64] = [0x4d; 64];
/// Protocol ceiling, in addition to the runtime's stricter admission limits.
pub const MAX_PAYLOAD_LEN: usize = 16 * 1024 * 1024;

pub type PublicKeyBytes = [u8; PUBLIC_KEY_LEN];
pub type SignatureBytes = [u8; SIGNATURE_LEN];
pub type Commitment = [u8; COMMITMENT_LEN];

#[derive(
    Clone,
    Copy,
    Debug,
    PartialEq,
    Eq,
    Encode,
    Decode,
    DecodeWithMemTracking,
    TypeInfo,
    MaxEncodedLen,
)]
pub struct Descriptor {
    pub version: u8,
    pub algorithm: u8,
    pub initial_commitment: Commitment,
}

impl Descriptor {
    pub const ENCODED_LEN: usize = 66;

    pub fn is_supported(&self) -> bool {
        self.version == VERSION
            && (is_account_profile(self.algorithm)
                || crate::falcon::is_account_profile(self.algorithm))
            && self.initial_commitment != [0; 64]
    }

    pub fn is_rotating(&self) -> bool {
        self.algorithm == ALGORITHM || crate::falcon::is_rotating_profile(self.algorithm)
    }

    pub fn is_stateless(&self) -> bool {
        self.algorithm == FIXED_ALGORITHM || crate::falcon::is_fixed_profile(self.algorithm)
    }

    /// Rotation policy is immutable account identity, never supplied by a signer.
    pub fn accepts_next_commitment(&self, current: &Commitment, next: &Commitment) -> bool {
        self.is_supported()
            && *current != [0; 64]
            && *next != [0; 64]
            && if self.is_rotating() {
                next != current
            } else {
                current == &self.initial_commitment && next == current
            }
    }

    pub fn from_bytes(bytes: &[u8]) -> Result<Self, codec::Error> {
        if bytes.len() != Self::ENCODED_LEN {
            return Err("Invalid ML-DSA descriptor length".into());
        }
        let descriptor = Self::decode(&mut &bytes[..])?;
        if !descriptor.is_supported()
            || (descriptor.is_stateless() && !rejects_legacy_public_keys(&account_id(&descriptor)))
        {
            return Err("Unsupported ML-DSA descriptor".into());
        }
        Ok(descriptor)
    }
}

#[derive(
    Clone, Debug, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen,
)]
pub struct Header {
    pub account: [u8; 32],
    pub version: u8,
    pub algorithm: u8,
    pub generation: u64,
    pub public_key: PublicKeyBytes,
    pub next_commitment: Commitment,
}

impl Header {
    pub const ENCODED_LEN: usize = 2058;

    pub fn is_supported(&self) -> bool {
        self.version == VERSION && is_account_profile(self.algorithm)
    }

    /// Validate immutable, self-contained authority before doing ML-DSA work.
    /// This does not consult account storage, and never accepts a legacy curve
    /// public key as a registration-free account. Generation is reserved at zero;
    /// transaction nonce, era and chain binding remain in the signed payload.
    pub fn is_valid_stateless(&self) -> bool {
        if self.version != VERSION || self.algorithm != FIXED_ALGORITHM || self.generation != 0 {
            return false;
        }
        let current = commitment(&self.public_key);
        let descriptor = Descriptor {
            version: VERSION,
            algorithm: FIXED_ALGORITHM,
            initial_commitment: current,
        };
        current != [0; COMMITMENT_LEN]
            && self.next_commitment == current
            && self.account == account_id(&descriptor)
            && rejects_legacy_public_keys(&self.account)
    }

    /// SDK signing. `payload` is the full encoded `(call, extension, implicit)`
    /// tuple, without Substrate's 256-byte shortening rule.
    /// `randomness` is fresh CSPRNG output, or zero for deterministic signing.
    pub fn sign(
        &self,
        seed: &[u8; 32],
        payload: &[u8],
        randomness: &[u8; 32],
    ) -> Result<SignerAddress, &'static str> {
        if !self.is_supported()
            || (self.algorithm == FIXED_ALGORITHM && !self.is_valid_stateless())
            || payload.len() > MAX_PAYLOAD_LEN
        {
            return Err("Unsupported ML-DSA signing header or payload size");
        }
        if public_key_from_seed(seed)? != self.public_key {
            return Err("ML-DSA signing seed does not match header public key");
        }
        // Only the SDK signing path allocates a complete transcript. Runtime
        // verification streams Encode directly through the pure-mode mu hash.
        let mut transcript = TRANSACTION_DOMAIN.to_vec();
        self.encode_to(&mut transcript);
        transcript.extend_from_slice(payload);
        let signature = sign_message_unbounded(seed, &transcript, TRANSACTION_CONTEXT, randomness)?;
        Ok(SignerAddress {
            account: self.account,
            version: self.version,
            algorithm: self.algorithm,
            generation: self.generation,
            public_key: self.public_key,
            next_commitment: self.next_commitment,
            signature,
        })
    }

    fn representative(&self, payload: &impl Encode) -> Option<[u8; 64]> {
        let tr = shake256_64(&[&self.public_key]);
        let mut hash = Shake256::default();
        hash.update(&tr);
        hash.update(&[0, 18]); // Pure mode and exact ASCII context length.
        hash.update(TRANSACTION_CONTEXT);
        hash.update(TRANSACTION_DOMAIN);
        // A header is small and fixed, but encode it directly as well.
        let mut output = HashOutput {
            hash,
            remaining: Header::ENCODED_LEN,
            exhausted: false,
        };
        self.encode_to(&mut output);
        if output.exhausted || output.remaining != 0 {
            return None;
        }
        output.remaining = MAX_PAYLOAD_LEN;
        payload.encode_to(&mut output);
        if output.exhausted {
            return None;
        }
        let mut mu = [0u8; 64];
        output.hash.finalize_xof().read(&mut mu);
        Some(mu)
    }
}

#[derive(
    Clone, Debug, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen,
)]
pub struct SignerAddress {
    pub account: [u8; 32],
    pub version: u8,
    pub algorithm: u8,
    pub generation: u64,
    pub public_key: PublicKeyBytes,
    pub next_commitment: Commitment,
    pub signature: SignatureBytes,
}

impl SignerAddress {
    pub const ENCODED_LEN: usize = 5367;

    pub fn from_bytes(bytes: &[u8]) -> Result<Self, codec::Error> {
        if bytes.len() != Self::ENCODED_LEN {
            return Err("Invalid ML-DSA signer address length".into());
        }
        let address = Self::decode(&mut &bytes[..])?;
        if address.version != VERSION || !is_account_profile(address.algorithm) {
            return Err("Unsupported ML-DSA signer address".into());
        }
        Ok(address)
    }

    pub fn header(&self) -> Header {
        Header {
            account: self.account,
            version: self.version,
            algorithm: self.algorithm,
            generation: self.generation,
            public_key: self.public_key,
            next_commitment: self.next_commitment,
        }
    }

    /// Verify already encoded payload bytes; no Vec/slice SCALE length prefix.
    pub fn verify(&self, payload: &[u8]) -> bool {
        self.verify_encoded(&RawPayload(payload))
    }

    /// Stream the full canonical tuple through pure ML-DSA verification.
    /// The caller must perform state/fee/resource admission before invoking it.
    pub fn verify_encoded(&self, payload: &impl Encode) -> bool {
        let header = self.header();
        if !header.is_supported()
            || (header.algorithm == FIXED_ALGORITHM && !header.is_valid_stateless())
        {
            return false;
        }
        let Some(mu) = header.representative(payload) else {
            return false;
        };
        let Ok(public_key) = ml_dsa_65::PublicKey::try_from_bytes(self.public_key) else {
            return false;
        };
        public_key.verify_mu(&mu, &self.signature)
    }
}

/// Internal SDK authorization result. Its encoding is not a new extrinsic format.
#[derive(
    Clone, Debug, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen,
)]
pub struct Proof {
    pub generation: u64,
    pub public_key: PublicKeyBytes,
    pub next_commitment: Commitment,
    pub signature: SignatureBytes,
}

pub fn commitment(public_key: &PublicKeyBytes) -> Commitment {
    shake256_64(&[KEY_DOMAIN, &[VERSION, ALGORITHM], public_key])
}

pub fn key_commitment(public_key: &PublicKeyBytes) -> Commitment {
    commitment(public_key)
}

pub fn account_id(descriptor: &Descriptor) -> [u8; 32] {
    let domain = if crate::falcon::is_account_profile(descriptor.algorithm) {
        crate::falcon::ACCOUNT_DOMAIN
    } else {
        ACCOUNT_DOMAIN
    };
    let mut encoded = domain.to_vec();
    descriptor.encode_to(&mut encoded);
    sp_core::hashing::blake2_256(&encoded)
}

/// True only when neither existing raw-key signature scheme can authorize the
/// account. Ed25519 uses the permissive ZIP-215 point decoder: a noncanonical
/// encoding alone is NOT sufficient. ECDSA identities are hashes of public keys
/// and remain protected by their full-width target-preimage requirement.
///
/// This is an encoding property, not an account-type discriminator: existing
/// ECDSA, multisig and proxy account hashes can satisfy it as well.
pub fn rejects_legacy_public_keys(account: &[u8; 32]) -> bool {
    CompressedEdwardsY(*account).decompress().is_none()
        && CompressedRistretto(*account).decompress().is_none()
}

/// Derive the exact FIPS 204 public key from an independently derived key seed.
pub fn public_key_from_seed(seed: &[u8; 32]) -> Result<PublicKeyBytes, &'static str> {
    let (public_key, _secret_key) = ml_dsa_65::KG::keygen_from_seed(seed);
    Ok(public_key.into_bytes())
}

pub fn sign_message(
    seed: &[u8; 32],
    message: &[u8],
    context: &[u8],
    randomness: &[u8; 32],
) -> Result<SignatureBytes, &'static str> {
    if message.len() > MAX_PAYLOAD_LEN {
        return Err("ML-DSA message exceeds protocol size limit");
    }
    sign_message_unbounded(seed, message, context, randomness)
}

fn sign_message_unbounded(
    seed: &[u8; 32],
    message: &[u8],
    context: &[u8],
    randomness: &[u8; 32],
) -> Result<SignatureBytes, &'static str> {
    let (_public_key, secret_key) = ml_dsa_65::KG::keygen_from_seed(seed);
    secret_key.try_sign_with_seed(randomness, message, context)
}

pub fn verify_message(
    public_key: &PublicKeyBytes,
    message: &[u8],
    context: &[u8],
    signature: &SignatureBytes,
) -> bool {
    if message.len() > MAX_PAYLOAD_LEN || context.len() > 255 {
        return false;
    }
    let Ok(key) = ml_dsa_65::PublicKey::try_from_bytes(*public_key) else {
        return false;
    };
    key.verify(message, signature, context)
}

/// SHAKE256 with an exact 64-byte output; callers supply protocol domain separation.
pub fn shake256_64(parts: &[&[u8]]) -> [u8; 64] {
    let mut hash = Shake256::default();
    for part in parts {
        hash.update(part);
    }
    let mut digest = [0u8; 64];
    hash.finalize_xof().read(&mut digest);
    digest
}

struct RawPayload<'a>(&'a [u8]);

impl Encode for RawPayload<'_> {
    fn encode_to<T: Output + ?Sized>(&self, dest: &mut T) {
        dest.write(self.0);
    }
}

struct HashOutput {
    hash: Shake256,
    remaining: usize,
    exhausted: bool,
}

impl Output for HashOutput {
    fn write(&mut self, bytes: &[u8]) {
        if self.exhausted {
            return;
        }
        if let Some(remaining) = self.remaining.checked_sub(bytes.len()) {
            self.remaining = remaining;
            self.hash.update(bytes);
        } else {
            self.exhausted = true;
        }
    }
}

#[cfg(test)]
#[path = "mldsa_tests.rs"]
mod tests;