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:
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 {