//! 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 { 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 { 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 { 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 { 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 { 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 { 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(&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;