iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 68bbea72bb4c1f7e78201622069fad8c2816b487
parent 0f6c770f0e8f2abc6eff9db9cd81952e238ddfee
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat, 12 Sep 2026 22:15:14 +0200

feat(protocol): prepare quantum-safe migration

Diffstat:
MREADME.md | 1+
Adocs/quantum-migration.md | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/p2p/identity.rs | 34+++++++++++++++-------------------
Msrc/domain.rs | 10+++++++++-
Msrc/domain/address.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/domain/ledger_ops.rs | 8+-------
Msrc/domain/protocol.rs | 4++--
Asrc/domain/signature.rs | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/transaction.rs | 10++--------
Msrc/domain/wallet.rs | 12+++++-------
10 files changed, 477 insertions(+), 62 deletions(-)

diff --git a/README.md b/README.md @@ -333,6 +333,7 @@ peers. ## Operator Docs - [Protocol](docs/protocol.md) +- [Quantum-resistance migration](docs/quantum-migration.md) - [Operator failure playbooks](docs/operator-playbooks.md) ## Contributing diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -0,0 +1,110 @@ +# Quantum-resistance migration + +## Status + +Iuna is not currently post-quantum secure. The live mainnet-candidate protocol uses Ed25519 for +wallet transactions, leader proofs, burn-bundle attestations, peer identity, and release signing. +Its class-group Wesolowski VDF also does not carry a post-quantum security claim. + +This document defines the migration constraints and staged protocol shape. It does **not** activate +new consensus rules. Activation heights must only be chosen after implementation, independent +cryptographic review, test vectors, adversarial tests, and a multi-node migration rehearsal. + +The standardized signature candidates are ML-DSA (FIPS 204) and SLH-DSA (FIPS 205). The initial +transaction candidate is a hybrid of Ed25519 and ML-DSA-44: both signatures must verify. Hybrid +mode protects the transition if either the classical or post-quantum component later fails, but it +does not remove the need to migrate before a cryptographically relevant quantum computer exists. + +## Stable scheme identifiers + +The protocol reserves these one-byte identifiers: + +| ID | Scheme | Public key bytes | Signature bytes | Consensus status | +|---:|---|---:|---:|---| +| 0 | Ed25519 | 32 | 64 | active legacy scheme | +| 1 | ML-DSA-44 | 1,312 | 2,420 | reserved | +| 2 | Ed25519 + ML-DSA-44 | 1,344 | 2,484 | reserved | + +IDs identify exact parameter sets and encodings, not algorithm families. A future parameter or +encoding change receives a new ID. Unknown IDs must fail closed. + +## Address and authorization model + +Version-0 addresses continue to contain an Ed25519 public key. This representation must remain +valid for historical consensus data. + +Version-1 addresses should contain a fixed 32-byte, domain-separated commitment to: + +1. the signature scheme ID; +2. the exact encoded public-key lengths; +3. the exact encoded public keys. + +The public keys move into the spending authorization rather than the UTXO. Validation recomputes +the commitment before checking every required signature. This keeps outputs and user-facing +addresses compact while allowing large and variable-size post-quantum keys. + +The consensus representation must retain the address version. Returning only the 32-byte payload +from Bech32m decoding is insufficient because a validator must know whether an output is an +Ed25519 key or a commitment. The Rust domain model should therefore replace bare address strings +at new protocol boundaries with a typed `(version, payload)` value. + +## Transaction identity + +Post-quantum signatures must not become transaction identifiers. Hybrid signatures are large and +ML-DSA may produce different valid signatures for the same payload. Version-2 transactions should +use a domain-separated transaction hash over the canonical signed transaction as their fixed-size +ID. Outpoints and indexes must refer to this ID. Legacy signature-based transaction IDs remain +valid for historical transactions. + +Fee and block accounting must use actual canonical byte lengths. The current fixed 32-byte public +key and 64-byte signature assumptions must not be applied to version-2 transactions. Mempool, +gossip, JSON, SQLite compaction, block selection, and the one-megabyte block limit all require +boundary tests with maximum-size hybrid authorizations. + +## Activation sequence + +1. **Crypto agility:** centralize current Ed25519 operations; reserve exact scheme IDs; add typed, + length-delimited key and signature encodings. This is consensus-neutral. +2. **Read support:** nodes parse version-1 addresses and version-2 transactions but reject them as + not-yet-active. Unknown versions and schemes fail closed. +3. **Hybrid activation:** at an announced height, permit version-1 outputs and require both + Ed25519 and ML-DSA-44 signatures when spending them. Keep version-0 spends valid. +4. **Wallet migration:** default all new receive addresses to version 1 and provide one action that + consolidates every version-0 UTXO into version-1 outputs. Show remaining legacy value in node + status and the wallet UI. +5. **Legacy sunset:** only after measured migration coverage and extensive notice, stop creating + version-0 outputs. A later restriction on version-0 spending is a separate consensus decision; + it can strand funds and cannot distinguish an owner from a quantum attacker. +6. **Classical removal:** removing Ed25519 from hybrid authorization requires a new scheme ID and + activation. It is not implied by enabling ML-DSA. + +All stages must be rehearsed across the height boundary with old and new nodes, snapshot restore, +fork recovery, mempool rebroadcast, compact-store reload, and lightweight-wallet signing. + +## Other trust boundaries + +- P2P node IDs need versioned, algorithm-tagged proofs independent of wallet activation. +- CLI and desktop update verification need dual classical/post-quantum signatures and an update + path that installs the new trust root before it becomes mandatory. +- TLS and deployment credentials need a separate cryptographic inventory; they are not consensus + rules. +- Wallet files should move from PBKDF2-SHA256 to a versioned memory-hard password KDF as general + hardening. ChaCha20-Poly1305 with a 256-bit key does not need immediate replacement. + +## VDF gate + +The current `classgroup-wesolowski-bqfc-v1` format remains frozen for historical verification. A +replacement VDF needs its own format identifier, activation height, security argument, reference +implementation, known-answer vectors, performance measurements, and fork-choice simulations. +No candidate should be described as post-quantum merely because it avoids RSA setup. + +## Release gate + +Iuna must not claim quantum resistance until all of the following are true: + +- the active transaction and consensus signature path is independently reviewed; +- existing value has an operational migration path and migration telemetry; +- peer and updater authentication have post-quantum transition paths; +- the VDF has a documented quantum threat model; +- adversarial and six-node tests cover every activation boundary; +- recovery playbooks cover a failed or rolled-back activation. diff --git a/src/adapters/p2p/identity.rs b/src/adapters/p2p/identity.rs @@ -4,10 +4,12 @@ use std::{ }; use anyhow::Result; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use secrecy::{ExposeSecret, SecretBox}; -use crate::app::{GossipEnvelope, NETWORK_ID}; +use crate::{ + app::{GossipEnvelope, NETWORK_ID}, + domain::{ed25519_public_key, sign_ed25519, verify_ed25519}, +}; use super::GossipNetwork; @@ -18,8 +20,7 @@ pub(super) fn new_node_id() -> String { let signing_seed = SecretBox::init_with_mut(|bytes: &mut [u8; 32]| { getrandom::getrandom(bytes).expect("secure randomness unavailable for p2p node id"); }); - let signing_key = SigningKey::from_bytes(signing_seed.expose_secret()); - let node_id = hex_encode(&signing_key.verifying_key().to_bytes()); + let node_id = hex_encode(&ed25519_public_key(signing_seed.expose_secret())); node_signing_keys() .lock() .expect("node signing key registry mutex poisoned") @@ -91,14 +92,13 @@ pub(super) fn peer_verification_response_for_node_id( .lock() .expect("node signing key registry mutex poisoned"); let signing_seed = keys.get(node_id)?; - let signing_key = SigningKey::from_bytes(signing_seed.expose_secret()); let payload = peer_verification_payload(address, nonce, node_id); - let signature: Signature = signing_key.sign(payload.as_bytes()); + let signature = sign_ed25519(signing_seed.expose_secret(), payload.as_bytes()); Some(GossipEnvelope::PeerVerificationResponse { address: address.to_string(), nonce: nonce.to_string(), node_id: node_id.to_string(), - signature: hex_encode(&signature.to_bytes()), + signature: hex_encode(&signature), }) } @@ -122,18 +122,14 @@ pub(super) fn peer_verification_response_is_valid( Err(_) => return false, }; let signature = match decode_hex_array::<64>(signature) { - Ok(signature) => Signature::from_bytes(&signature), - Err(_) => return false, - }; - let verifying_key = match VerifyingKey::from_bytes(&public_key) { - Ok(verifying_key) => verifying_key, + Ok(signature) => signature, Err(_) => return false, }; - verifying_key - .verify( - peer_verification_payload(expected_address, expected_nonce, expected_node_id) - .as_bytes(), - &signature, - ) - .is_ok() + verify_ed25519( + &public_key, + peer_verification_payload(expected_address, expected_nonce, expected_node_id).as_bytes(), + &signature, + "peer verification", + ) + .is_ok() } diff --git a/src/domain.rs b/src/domain.rs @@ -28,13 +28,17 @@ mod profile; mod protocol; mod reveal; mod selection; +mod signature; mod stratum; mod ticket; mod transaction; mod validation; mod vdf; mod wallet; -pub use address::{AddressNetwork, decode_address, encode_address, migrate_legacy_address}; +pub use address::{ + AddressNetwork, AddressVersion, VersionedAddress, decode_address, decode_versioned_address, + encode_address, encode_versioned_address, migrate_legacy_address, +}; use block::LeaderProofPayload; pub use block::{ Block, BurnLeaderRank, ChainSnapshot, ChainStatus, FinalizerMode, LeaderProof, PreparedBlock, @@ -79,6 +83,10 @@ pub use reveal::{ default_burn_bundle_hash, }; use selection::BlockSelection; +pub use signature::{ProtocolPublicKey, ProtocolSignature, SignatureScheme}; +pub(crate) use signature::{ + ed25519_public_key, sign_ed25519, validate_ed25519_public_key, verify_ed25519, +}; pub use stratum::{ STRATUM_EXTRANONCE1_HEX, STRATUM_EXTRANONCE2_SIZE, StratumMineShare, StratumMineTemplate, pack_stratum_nonce, diff --git a/src/domain/address.rs b/src/domain/address.rs @@ -1,9 +1,6 @@ +use super::{PUBLIC_KEY_BYTES, decode_hex_array, hex_encode, validate_ed25519_public_key}; use anyhow::{Context, Result, bail}; -use ed25519_dalek::VerifyingKey; -use super::{PUBLIC_KEY_BYTES, decode_hex_array, hex_encode}; - -const ADDRESS_VERSION: u8 = 0; const BECH32M_CONST: u32 = 0x2bc8_30a3; const BECH32_CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; const MAX_BECH32_LENGTH: usize = 90; @@ -14,6 +11,29 @@ pub enum AddressNetwork { Testnet, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum AddressVersion { + Ed25519PublicKey = 0, + HybridKeyCommitment = 1, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VersionedAddress { + pub version: AddressVersion, + pub payload: [u8; PUBLIC_KEY_BYTES], +} + +impl AddressVersion { + fn from_wire_id(id: u8) -> Option<Self> { + match id { + 0 => Some(Self::Ed25519PublicKey), + 1 => Some(Self::HybridKeyCommitment), + _ => None, + } + } +} + impl AddressNetwork { pub fn from_profile_id(profile_id: &str) -> Self { if matches!(profile_id, "iuna-local-testnet-v1" | "iuna-local-e2e-5s-v1") { @@ -37,8 +57,25 @@ pub fn encode_address(public_key_hex: &str, network: AddressNetwork) -> Result<S .context("address public key must be 32-byte hexadecimal")?; validate_public_key(&public_key)?; - let mut data = vec![ADDRESS_VERSION]; - data.extend(convert_bits(&public_key, 8, 5, true)?); + encode_versioned_address( + VersionedAddress { + version: AddressVersion::Ed25519PublicKey, + payload: public_key, + }, + network, + ) +} + +pub fn encode_versioned_address( + address: VersionedAddress, + network: AddressNetwork, +) -> Result<String> { + if address.version == AddressVersion::Ed25519PublicKey { + validate_public_key(&address.payload)?; + } + + let mut data = vec![address.version as u8]; + data.extend(convert_bits(&address.payload, 8, 5, true)?); let checksum = create_checksum(network.hrp(), &data); let mut encoded = String::with_capacity(network.hrp().len() + 1 + data.len() + 6); encoded.push_str(network.hrp()); @@ -54,6 +91,20 @@ pub fn encode_address(public_key_hex: &str, network: AddressNetwork) -> Result<S /// Legacy hexadecimal addresses are deliberately not accepted here. They remain /// valid only inside existing consensus data and wallet files. pub fn decode_address(address: &str, expected_network: AddressNetwork) -> Result<String> { + let decoded = decode_versioned_address(address, expected_network)?; + if decoded.version != AddressVersion::Ed25519PublicKey { + bail!("address version is recognized but not consensus-active"); + } + validate_public_key(&decoded.payload)?; + Ok(hex_encode(decoded.payload)) +} + +/// Parses every reserved address version without making it consensus-active. +/// Callers must apply the activation rule before accepting the result. +pub fn decode_versioned_address( + address: &str, + expected_network: AddressNetwork, +) -> Result<VersionedAddress> { let address = address.trim(); if address.is_empty() { bail!("address is required"); @@ -92,15 +143,19 @@ pub fn decode_address(address: &str, expected_network: AddressNetwork) -> Result let Some((&version, encoded_key)) = payload.split_first() else { bail!("address payload is empty"); }; - if version != ADDRESS_VERSION { - bail!("unsupported address version {version}"); - } + let version = AddressVersion::from_wire_id(version) + .with_context(|| format!("unsupported address version {version}"))?; let public_key = convert_bits(encoded_key, 5, 8, false)?; let public_key: [u8; PUBLIC_KEY_BYTES] = public_key.try_into().map_err(|bytes: Vec<u8>| { anyhow::anyhow!("address public key has {} bytes", bytes.len()) })?; - validate_public_key(&public_key)?; - Ok(hex_encode(public_key)) + if version == AddressVersion::Ed25519PublicKey { + validate_public_key(&public_key)?; + } + Ok(VersionedAddress { + version, + payload: public_key, + }) } /// Converts a pre-mainnet hex address for one-time display/migration tooling. @@ -109,12 +164,7 @@ pub fn migrate_legacy_address(address: &str, network: AddressNetwork) -> Result< } fn validate_public_key(public_key: &[u8; PUBLIC_KEY_BYTES]) -> Result<()> { - let verifying_key = VerifyingKey::from_bytes(public_key) - .context("address payload is not a valid Ed25519 verifying key")?; - if verifying_key.is_weak() { - bail!("address payload contains a weak Ed25519 verifying key"); - } - Ok(()) + validate_ed25519_public_key(public_key) } fn network_label(network: AddressNetwork) -> &'static str { @@ -213,7 +263,8 @@ fn convert_bits(data: &[u8], from: u8, to: u8, pad: bool) -> Result<Vec<u8>> { #[cfg(test)] mod tests { use super::{ - AddressNetwork, decode_address, decode_charset, encode_address, migrate_legacy_address, + AddressNetwork, AddressVersion, VersionedAddress, decode_address, decode_charset, + decode_versioned_address, encode_address, encode_versioned_address, migrate_legacy_address, verify_checksum, }; use crate::domain::Wallet; @@ -261,6 +312,26 @@ mod tests { } #[test] + fn reserved_hybrid_addresses_parse_but_are_not_consensus_active() { + let expected = VersionedAddress { + version: AddressVersion::HybridKeyCommitment, + payload: [0x42; 32], + }; + let encoded = encode_versioned_address(expected, AddressNetwork::Mainnet).unwrap(); + + assert_eq!( + decode_versioned_address(&encoded, AddressNetwork::Mainnet).unwrap(), + expected + ); + assert!( + decode_address(&encoded, AddressNetwork::Mainnet) + .unwrap_err() + .to_string() + .contains("not consensus-active") + ); + } + + #[test] fn checksum_typos_and_network_mixups_are_rejected() { let key = wallet_key(); let mainnet = encode_address(&key, AddressNetwork::Mainnet).unwrap(); diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -6,7 +6,6 @@ use crate::compact::{ CompactBlockContext, CompactBlockSizeBreakdown, compact_snapshot_fixed_prefix_size, compact_varint_size, }; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use super::hex::hex_hash; use super::reveal::{BurnBundleSection, canonical_burn_bundle_hashes}; @@ -236,12 +235,7 @@ pub(super) fn verify_address_signature( .with_context(|| format!("invalid {label} public key {address}"))?; let signature = decode_hex_array::<SIGNATURE_BYTES>(signature) .with_context(|| format!("invalid {label} signature hex"))?; - let verifying_key = VerifyingKey::from_bytes(&public_key) - .with_context(|| format!("invalid {label} public key"))?; - let signature = Signature::from_bytes(&signature); - verifying_key - .verify(payload.as_bytes(), &signature) - .with_context(|| format!("{label} signature is invalid")) + super::verify_ed25519(&public_key, payload.as_bytes(), &signature, label) } pub(super) fn vdf_seed_for_child( diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -34,9 +34,9 @@ pub(super) const DEFAULT_TICKET_EXPIRY_WINDOW: u64 = 3; pub(super) const MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS: u64 = 2 * 60 * 1_000; pub(super) const BLOCK_MEDIAN_TIME_PAST_WINDOW: usize = 11; pub(super) const FORK_FINALITY_DEPTH: u64 = 6; -pub(super) const PUBLIC_KEY_BYTES: usize = 32; +pub(super) const PUBLIC_KEY_BYTES: usize = super::SignatureScheme::Ed25519.public_key_bytes(); pub(super) const HASH_BYTES: usize = 32; -pub(super) const SIGNATURE_BYTES: usize = 64; +pub(super) const SIGNATURE_BYTES: usize = super::SignatureScheme::Ed25519.signature_bytes(); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TransactionSubmitOutcome { diff --git a/src/domain/signature.rs b/src/domain/signature.rs @@ -0,0 +1,243 @@ +use anyhow::{Context, Result, bail}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; + +/// Signature schemes understood by the protocol implementation. +/// +/// Only `Ed25519` is consensus-active today. The other identifiers reserve a +/// stable vocabulary for the post-quantum migration; accepting either of them +/// requires a separately activated transaction and address format. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum SignatureScheme { + Ed25519 = 0, + MlDsa44 = 1, + HybridEd25519MlDsa44 = 2, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProtocolPublicKey { + scheme: SignatureScheme, + bytes: Vec<u8>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProtocolSignature { + scheme: SignatureScheme, + bytes: Vec<u8>, +} + +impl SignatureScheme { + pub const fn wire_id(self) -> u8 { + self as u8 + } + + pub const fn public_key_bytes(self) -> usize { + match self { + Self::Ed25519 => 32, + Self::MlDsa44 => 1_312, + Self::HybridEd25519MlDsa44 => 32 + 1_312, + } + } + + pub const fn signature_bytes(self) -> usize { + match self { + Self::Ed25519 => 64, + Self::MlDsa44 => 2_420, + Self::HybridEd25519MlDsa44 => 64 + 2_420, + } + } + + pub const fn from_wire_id(id: u8) -> Option<Self> { + match id { + 0 => Some(Self::Ed25519), + 1 => Some(Self::MlDsa44), + 2 => Some(Self::HybridEd25519MlDsa44), + _ => None, + } + } + + pub const fn is_consensus_active(self) -> bool { + matches!(self, Self::Ed25519) + } +} + +impl ProtocolPublicKey { + pub fn new(scheme: SignatureScheme, bytes: Vec<u8>) -> Result<Self> { + validate_material_length("public key", scheme.public_key_bytes(), bytes.len())?; + Ok(Self { scheme, bytes }) + } + + pub fn scheme(&self) -> SignatureScheme { + self.scheme + } + + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn encode(&self) -> Vec<u8> { + encode_material(self.scheme, &self.bytes) + } + + pub fn decode(encoded: &[u8]) -> Result<Self> { + let (scheme, bytes) = decode_material(encoded)?; + Self::new(scheme, bytes.to_vec()) + } +} + +impl ProtocolSignature { + pub fn new(scheme: SignatureScheme, bytes: Vec<u8>) -> Result<Self> { + validate_material_length("signature", scheme.signature_bytes(), bytes.len())?; + Ok(Self { scheme, bytes }) + } + + pub fn scheme(&self) -> SignatureScheme { + self.scheme + } + + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn encode(&self) -> Vec<u8> { + encode_material(self.scheme, &self.bytes) + } + + pub fn decode(encoded: &[u8]) -> Result<Self> { + let (scheme, bytes) = decode_material(encoded)?; + Self::new(scheme, bytes.to_vec()) + } +} + +fn encode_material(scheme: SignatureScheme, bytes: &[u8]) -> Vec<u8> { + let mut encoded = Vec::with_capacity(5 + bytes.len()); + encoded.push(scheme.wire_id()); + encoded.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + encoded.extend_from_slice(bytes); + encoded +} + +fn decode_material(encoded: &[u8]) -> Result<(SignatureScheme, &[u8])> { + let (&scheme, encoded) = encoded + .split_first() + .context("signature material is empty")?; + let scheme = SignatureScheme::from_wire_id(scheme) + .with_context(|| format!("unknown signature scheme {scheme}"))?; + let (length, bytes) = encoded + .split_at_checked(4) + .context("signature material length is missing")?; + let declared = u32::from_be_bytes(length.try_into().expect("four-byte length")) as usize; + if bytes.len() != declared { + bail!( + "signature material declares {declared} bytes but contains {}", + bytes.len() + ); + } + Ok((scheme, bytes)) +} + +fn validate_material_length(label: &str, expected: usize, actual: usize) -> Result<()> { + if actual != expected { + bail!("{label} must contain {expected} bytes, got {actual}"); + } + Ok(()) +} + +pub(crate) fn ed25519_public_key(signing_seed: &[u8; 32]) -> [u8; 32] { + SigningKey::from_bytes(signing_seed) + .verifying_key() + .to_bytes() +} + +pub(crate) fn sign_ed25519(signing_seed: &[u8; 32], payload: &[u8]) -> [u8; 64] { + SigningKey::from_bytes(signing_seed) + .sign(payload) + .to_bytes() +} + +pub(crate) fn validate_ed25519_public_key(public_key: &[u8; 32]) -> Result<()> { + let verifying_key = VerifyingKey::from_bytes(public_key) + .context("address payload is not a valid Ed25519 verifying key")?; + if verifying_key.is_weak() { + bail!("address payload contains a weak Ed25519 verifying key"); + } + Ok(()) +} + +pub(crate) fn verify_ed25519( + public_key: &[u8; 32], + payload: &[u8], + signature: &[u8; 64], + label: &str, +) -> Result<()> { + let verifying_key = VerifyingKey::from_bytes(public_key) + .with_context(|| format!("invalid {label} public key"))?; + verifying_key + .verify(payload, &Signature::from_bytes(signature)) + .with_context(|| format!("{label} signature is invalid")) +} + +#[cfg(test)] +mod tests { + use super::{ + ProtocolPublicKey, ProtocolSignature, SignatureScheme, ed25519_public_key, sign_ed25519, + verify_ed25519, + }; + + #[test] + fn signature_scheme_ids_and_sizes_are_stable() { + assert_eq!(SignatureScheme::Ed25519.wire_id(), 0); + assert_eq!(SignatureScheme::MlDsa44.wire_id(), 1); + assert_eq!(SignatureScheme::HybridEd25519MlDsa44.wire_id(), 2); + assert_eq!(SignatureScheme::Ed25519.public_key_bytes(), 32); + assert_eq!(SignatureScheme::Ed25519.signature_bytes(), 64); + assert_eq!(SignatureScheme::MlDsa44.public_key_bytes(), 1_312); + assert_eq!(SignatureScheme::MlDsa44.signature_bytes(), 2_420); + assert_eq!( + SignatureScheme::HybridEd25519MlDsa44.public_key_bytes(), + 1_344 + ); + assert_eq!( + SignatureScheme::HybridEd25519MlDsa44.signature_bytes(), + 2_484 + ); + assert!(SignatureScheme::Ed25519.is_consensus_active()); + assert!(!SignatureScheme::MlDsa44.is_consensus_active()); + assert_eq!(SignatureScheme::from_wire_id(3), None); + } + + #[test] + fn centralized_ed25519_backend_signs_and_verifies() { + let seed = [7_u8; 32]; + let public_key = ed25519_public_key(&seed); + let signature = sign_ed25519(&seed, b"quantum-agility-test"); + + verify_ed25519(&public_key, b"quantum-agility-test", &signature, "test").unwrap(); + assert!(verify_ed25519(&public_key, b"tampered", &signature, "test").is_err()); + } + + #[test] + fn algorithm_tagged_material_roundtrips_and_rejects_malformed_lengths() { + let key = ProtocolPublicKey::new(SignatureScheme::MlDsa44, vec![5; 1_312]).unwrap(); + let signature = + ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, vec![9; 2_484]).unwrap(); + + assert_eq!(ProtocolPublicKey::decode(&key.encode()).unwrap(), key); + assert_eq!( + ProtocolSignature::decode(&signature.encode()).unwrap(), + signature + ); + + let mut unknown_scheme = key.encode(); + unknown_scheme[0] = 99; + assert!(ProtocolPublicKey::decode(&unknown_scheme).is_err()); + + let mut wrong_declared_length = signature.encode(); + wrong_declared_length[4] -= 1; + assert!(ProtocolSignature::decode(&wrong_declared_length).is_err()); + assert!( + ProtocolSignature::new(SignatureScheme::Ed25519, vec![0; 63]).is_err(), + "scheme-specific lengths must fail closed" + ); + } +} diff --git a/src/domain/transaction.rs b/src/domain/transaction.rs @@ -1,7 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use super::validation::{decode_canonical_hex, decode_canonical_hex_array}; @@ -9,7 +8,7 @@ use super::{ Amount, HASH_BYTES, MINE_FINALIZER_FEE, MINE_REWARD, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Wallet, canonical_transaction_size_bytes, decode_hex_array, genesis_allocation_outpoint, hash_meets_difficulty, hex_encode, hex_hash, mine_payload, mine_signature, - stratum_mine_header_bytes, stratum_mine_signature, + stratum_mine_header_bytes, stratum_mine_signature, verify_ed25519, }; pub const TRANSACTION_SIGNING_FORMAT_VERSION: u16 = 1; @@ -351,17 +350,12 @@ impl Transaction { decode_hex_array::<SIGNATURE_BYTES>(self.signature()) } .context("invalid signature hex")?; - let verifying_key = - VerifyingKey::from_bytes(&public_key).context("invalid transaction public key")?; - let signature = Signature::from_bytes(&signature); let signing_bytes = if domain.is_chain_bound() { self.signing_bytes(domain)? } else { self.signing_payload().into_bytes() }; - verifying_key - .verify(&signing_bytes, &signature) - .context("transaction signature is invalid") + verify_ed25519(&public_key, &signing_bytes, &signature, "transaction") } pub(super) fn inputs(&self) -> &[TxInput] { diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -1,11 +1,12 @@ use std::{fmt, sync::Arc}; -use ed25519_dalek::{Signature, Signer, SigningKey}; use secrecy::{ExposeSecret, SecretBox, zeroize::Zeroize}; use sha2::{Digest, Sha256}; use super::block::LeaderProofPayload; -use super::{BurnBundle, BurnBundlePayload, LeaderProof, hex_encode}; +use super::{ + BurnBundle, BurnBundlePayload, LeaderProof, ed25519_public_key, hex_encode, sign_ed25519, +}; const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed"; @@ -26,8 +27,7 @@ impl Wallet { signing_seed.copy_from_slice(&seed_hash); }); seed_hash.zeroize(); - let signing_key = SigningKey::from_bytes(signing_seed.expose_secret()); - let address = hex_encode(signing_key.verifying_key().to_bytes()); + let address = hex_encode(ed25519_public_key(signing_seed.expose_secret())); Self { address, signing_seed: Arc::new(signing_seed), @@ -43,9 +43,7 @@ impl Wallet { } pub(super) fn sign_bytes(&self, payload: &[u8]) -> String { - let signing_key = SigningKey::from_bytes(self.signing_seed.expose_secret()); - let signature: Signature = signing_key.sign(payload); - hex_encode(signature.to_bytes()) + hex_encode(sign_ed25519(self.signing_seed.expose_secret(), payload)) } pub(super) fn leader_proof(&self, payload: &LeaderProofPayload) -> LeaderProof {