From d320fc228a7975402c2ede6047f6c66c65495a02 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 27 May 2026 11:40:08 +0900 Subject: [PATCH] first draft pallet --- Cargo.lock | 14 + Cargo.toml | 2 + pallets/key-association/Cargo.toml | 48 +++ pallets/key-association/src/lib.rs | 285 +++++++++++++++++ pallets/key-association/src/mock.rs | 63 ++++ pallets/key-association/src/tests.rs | 417 +++++++++++++++++++++++++ pallets/key-association/src/types.rs | 63 ++++ pallets/key-association/src/weights.rs | 47 +++ 8 files changed, 939 insertions(+) create mode 100644 pallets/key-association/Cargo.toml create mode 100644 pallets/key-association/src/lib.rs create mode 100644 pallets/key-association/src/mock.rs create mode 100644 pallets/key-association/src/tests.rs create mode 100644 pallets/key-association/src/types.rs create mode 100644 pallets/key-association/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 41e77fc7..1adf3f33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6605,6 +6605,20 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-key-association" +version = "1.0.0" +dependencies = [ + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", +] + [[package]] name = "pallet-mining-rewards" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 90eb9690..163ebd6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "miner-api", "node", "pallets/frame-system", + "pallets/key-association", "pallets/mining-rewards", "pallets/multisig", "pallets/qpow", @@ -132,6 +133,7 @@ zeroize = { version = "1.7.0", default-features = false } # Own dependencies pallet-balances = { version = "46.0.0", default-features = false } +pallet-key-association = { path = "./pallets/key-association", default-features = false } pallet-mining-rewards = { path = "./pallets/mining-rewards", default-features = false } pallet-multisig = { path = "./pallets/multisig", default-features = false } pallet-qpow = { path = "./pallets/qpow", default-features = false } diff --git a/pallets/key-association/Cargo.toml b/pallets/key-association/Cargo.toml new file mode 100644 index 00000000..371a7727 --- /dev/null +++ b/pallets/key-association/Cargo.toml @@ -0,0 +1,48 @@ +[package] +authors.workspace = true +description = "Pallet for associating classical cryptographic keys (ECDSA/Ed25519) with ML-DSA-87 accounts" +edition.workspace = true +homepage.workspace = true +license = "MIT-0" +name = "pallet-key-association" +repository.workspace = true +version = "1.0.0" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { features = ["derive", "max-encoded-len"], workspace = true } +frame-support.workspace = true +frame-system.workspace = true +scale-info = { features = ["derive"], workspace = true } +sp-core.workspace = true +sp-io.workspace = true +sp-runtime.workspace = true + +[dev-dependencies] +pallet-balances = { workspace = true, features = ["std"] } +sp-core = { workspace = true, features = ["std"] } +sp-io = { workspace = true, features = ["std"] } +sp-runtime = { workspace = true, features = ["std"] } + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/std", + "frame-system/std", + "scale-info/std", + "sp-core/std", + "sp-io/std", + "sp-runtime/std", +] +runtime-benchmarks = [ + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", +] diff --git a/pallets/key-association/src/lib.rs b/pallets/key-association/src/lib.rs new file mode 100644 index 00000000..c95cf823 --- /dev/null +++ b/pallets/key-association/src/lib.rs @@ -0,0 +1,285 @@ +//! # Key Association Pallet +//! +//! This pallet allows users to associate classical cryptographic keys (ECDSA secp256k1, Ed25519) +//! with their post-quantum ML-DSA-87 accounts on Quantus. +//! +//! ## Purpose +//! +//! Forward migration: Users with existing classical key wallets (Ethereum, Polkadot, etc.) +//! can cryptographically prove ownership and link those identities to their Quantus account. +//! +//! ## Features +//! +//! - Associate multiple classical keys with a single ML-DSA-87 account +//! - On-chain signature verification using sp-core primitives +//! - Block-hash-based replay protection (unpredictable challenge) +//! - Reverse index for looking up which ML-DSA account owns a classical key +//! +//! ## Design Notes +//! +//! - Associations are permanent (no disassociation by design) +//! - Each classical key can only be associated with one ML-DSA account +//! - Signature validity window derived from `frame_system::BlockHashCount` + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +pub mod types; +pub mod weights; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +pub use pallet::*; +pub use types::*; +pub use weights::*; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + use sp_io::hashing::blake2_128; + use sp_runtime::{traits::Verify, Saturating}; + + /// The in-code storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Maximum number of classical keys that can be associated with a single ML-DSA account. + #[pallet::constant] + type MaxAssociations: Get; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + } + + /// ML-DSA account -> list of associated classical keys with metadata. + #[pallet::storage] + #[pallet::getter(fn associations)] + pub type Associations = StorageMap< + _, + Blake2_128Concat, + T::AccountId, + BoundedVec<(ClassicalKey, AssociationRecord>), T::MaxAssociations>, + ValueQuery, + >; + + /// Reverse index: Blake2-128 hash of ClassicalKey -> ML-DSA account. + /// + /// This enables efficient lookups of which ML-DSA account owns a given classical key. + #[pallet::storage] + #[pallet::getter(fn key_index)] + pub type KeyIndex = StorageMap<_, Blake2_128Concat, [u8; 16], T::AccountId, OptionQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A classical key was associated with an ML-DSA-87 account. + KeyAssociated { + /// The ML-DSA-87 account that now owns this classical key association. + account: T::AccountId, + /// The type of classical key (ECDSA or Ed25519). + key_type: KeyType, + /// Blake2-128 hash of the classical key (for indexing). + key_hash: [u8; 16], + }, + } + + #[pallet::error] + pub enum Error { + /// The signature type does not match the key type. + SignatureKeyMismatch, + /// Signature verification failed. + InvalidSignature, + /// The block hash does not match the hash at the given block number. + BlockHashMismatch, + /// The signed block is too old (outside validity window). + SignatureExpired, + /// This classical key is already associated with an account. + KeyAlreadyAssociated, + /// The account has reached the maximum number of key associations. + TooManyAssociations, + } + + #[pallet::call] + impl Pallet { + /// Associate a classical key with the caller's ML-DSA-87 account. + /// + /// The caller must provide: + /// - `classical_key`: The public key to associate + /// - `signature`: A signature from that key over the challenge message + /// - `signed_block_number`: The block number whose hash was signed + /// - `signed_block_hash`: The block hash that was included in the signed message + /// + /// ## Challenge Message Format + /// + /// The message that must be signed is: + /// ```text + /// Quantus Key Association + /// Account: + /// Key: + /// Block: + /// ``` + /// + /// ## Replay Protection + /// + /// The signed block must be within `BlockHashCount` blocks of the current block. + /// The provided block hash must match the on-chain hash at that block number. + /// + /// ## Errors + /// + /// - `BlockHashMismatch`: The hash doesn't match the on-chain hash at that block + /// - `SignatureExpired`: The block is older than the validity window + /// - `SignatureKeyMismatch`: Signature type doesn't match key type + /// - `InvalidSignature`: Signature verification failed + /// - `KeyAlreadyAssociated`: This classical key is already linked to an account + /// - `TooManyAssociations`: Account has reached `MaxAssociations` limit + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::associate())] + pub fn associate( + origin: OriginFor, + classical_key: ClassicalKey, + signature: ClassicalSignature, + signed_block_number: BlockNumberFor, + signed_block_hash: T::Hash, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + // 1. Verify block hash matches the on-chain hash at the given block number + let actual_hash = frame_system::Pallet::::block_hash(signed_block_number); + ensure!(actual_hash == signed_block_hash, Error::::BlockHashMismatch); + + // 2. Verify block is within validity window (derived from BlockHashCount) + let current_block = frame_system::Pallet::::block_number(); + let validity_window = T::BlockHashCount::get(); + + ensure!( + current_block.saturating_sub(signed_block_number) < validity_window, + Error::::SignatureExpired + ); + + // 3. Build challenge message + let message = Self::challenge_message(&who, &classical_key, &signed_block_hash); + + // 4. Verify signature + Self::verify_signature(&classical_key, &signature, &message)?; + + // 5. Check key is not already associated + let key_hash = Self::hash_key(&classical_key); + ensure!(!KeyIndex::::contains_key(key_hash), Error::::KeyAlreadyAssociated); + + // 6. Add to associations (enforces MaxAssociations bound) + Associations::::try_mutate(&who, |associations| { + associations + .try_push(( + classical_key.clone(), + AssociationRecord { created_at: current_block }, + )) + .map_err(|_| Error::::TooManyAssociations) + })?; + + // 7. Add reverse index + KeyIndex::::insert(key_hash, &who); + + // 8. Emit event + Self::deposit_event(Event::KeyAssociated { + account: who, + key_type: classical_key.key_type(), + key_hash, + }); + + Ok(()) + } + } + + impl Pallet { + /// Build the challenge message that must be signed by the classical key. + /// + /// Format is human-readable for hardware wallet compatibility: + /// ```text + /// Quantus Key Association + /// Account: + /// Key: + /// Block: + /// ``` + fn challenge_message( + account: &T::AccountId, + classical_key: &ClassicalKey, + block_hash: &T::Hash, + ) -> alloc::vec::Vec { + use codec::Encode; + + let mut msg = b"Quantus Key Association\n".to_vec(); + msg.extend_from_slice(b"Account: "); + msg.extend_from_slice(&account.encode()); + msg.extend_from_slice(b"\nKey: "); + msg.extend_from_slice(&classical_key.encode()); + msg.extend_from_slice(b"\nBlock: "); + msg.extend_from_slice(block_hash.as_ref()); + msg + } + + /// Verify a classical signature over a message. + fn verify_signature( + key: &ClassicalKey, + signature: &ClassicalSignature, + message: &[u8], + ) -> Result<(), Error> { + match (key, signature) { + (ClassicalKey::Ecdsa(pub_key), ClassicalSignature::Ecdsa(sig)) => { + ensure!(sig.verify(message, pub_key), Error::::InvalidSignature); + } + (ClassicalKey::Ed25519(pub_key), ClassicalSignature::Ed25519(sig)) => { + ensure!(sig.verify(message, pub_key), Error::::InvalidSignature); + } + _ => return Err(Error::::SignatureKeyMismatch), + } + Ok(()) + } + + /// Compute Blake2-128 hash of a classical key for indexing. + fn hash_key(key: &ClassicalKey) -> [u8; 16] { + use codec::Encode; + blake2_128(&key.encode()) + } + + // ==================== Public Read APIs ==================== + + /// Get all classical keys associated with an ML-DSA account. + pub fn associations_for( + account: &T::AccountId, + ) -> alloc::vec::Vec<(ClassicalKey, AssociationRecord>)> { + Associations::::get(account).into_inner() + } + + /// Look up which ML-DSA account owns a classical key. + pub fn account_for_key(key: &ClassicalKey) -> Option { + let key_hash = Self::hash_key(key); + KeyIndex::::get(key_hash) + } + + /// Check if a classical key is already associated with any account. + pub fn is_key_associated(key: &ClassicalKey) -> bool { + let key_hash = Self::hash_key(key); + KeyIndex::::contains_key(key_hash) + } + + /// Compute the key hash for a given classical key (useful for clients). + pub fn compute_key_hash(key: &ClassicalKey) -> [u8; 16] { + Self::hash_key(key) + } + } +} diff --git a/pallets/key-association/src/mock.rs b/pallets/key-association/src/mock.rs new file mode 100644 index 00000000..cbc5a16d --- /dev/null +++ b/pallets/key-association/src/mock.rs @@ -0,0 +1,63 @@ +//! Mock runtime for testing pallet-key-association. + +use crate as pallet_key_association; +use frame_support::{derive_impl, parameter_types}; +use sp_runtime::BuildStorage; + +type Block = frame_system::mocking::MockBlock; +pub type AccountId = sp_core::crypto::AccountId32; + +/// Create an AccountId from a u64 (for test convenience). +pub fn account_id(id: u64) -> AccountId { + let mut data = [0u8; 32]; + data[0..8].copy_from_slice(&id.to_le_bytes()); + AccountId::new(data) +} + +#[frame_support::runtime] +mod runtime { + #[runtime::runtime] + #[runtime::derive( + RuntimeCall, + RuntimeEvent, + RuntimeError, + RuntimeOrigin, + RuntimeFreezeReason, + RuntimeHoldReason, + RuntimeSlashReason, + RuntimeLockId, + RuntimeTask + )] + pub struct Test; + + #[runtime::pallet_index(0)] + pub type System = frame_system::Pallet; + + #[runtime::pallet_index(1)] + pub type KeyAssociation = pallet_key_association::Pallet; +} + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = Block; + type AccountId = AccountId; + type Lookup = sp_runtime::traits::IdentityLookup; +} + +parameter_types! { + pub const MaxAssociationsParam: u32 = 8; +} + +impl pallet_key_association::Config for Test { + type RuntimeEvent = RuntimeEvent; + type MaxAssociations = MaxAssociationsParam; + type WeightInfo = (); +} + +/// Build test externalities with default genesis. +pub fn new_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::::default() + .build_storage() + .expect("Genesis build should succeed"); + t.into() +} diff --git a/pallets/key-association/src/tests.rs b/pallets/key-association/src/tests.rs new file mode 100644 index 00000000..93f013ec --- /dev/null +++ b/pallets/key-association/src/tests.rs @@ -0,0 +1,417 @@ +//! Unit tests for pallet-key-association. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use crate::{mock::*, Error, Event, KeyIndex, Associations, ClassicalKey, ClassicalSignature, KeyType}; +use codec::Encode; +use frame_support::{assert_noop, assert_ok}; +use sp_core::{ecdsa, ed25519, Pair, H256}; + +/// Build the challenge message (mirrors the pallet's internal function). +fn build_challenge_message( + account: &AccountId, + classical_key: &ClassicalKey, + block_hash: &::Hash, +) -> Vec { + let mut msg = b"Quantus Key Association\n".to_vec(); + msg.extend_from_slice(b"Account: "); + msg.extend_from_slice(&account.encode()); + msg.extend_from_slice(b"\nKey: "); + msg.extend_from_slice(&classical_key.encode()); + msg.extend_from_slice(b"\nBlock: "); + msg.extend_from_slice(block_hash.as_ref()); + msg +} + +/// Set up test environment with a known block hash at block 5. +/// Returns the block hash that can be used in tests. +fn setup_test_blocks() -> H256 { + // Set current block to 10 so block 5 is within the validity window + System::set_block_number(10); + + // Manually insert a known block hash at block 5 + let test_block_hash = H256::from([0x42u8; 32]); + frame_system::BlockHash::::insert(5u64, test_block_hash); + + test_block_hash +} + +// ==================== ECDSA TESTS ==================== + +#[test] +fn associate_ecdsa_key_works() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let quantus_account = account_id(1); + + // Generate ECDSA keypair + let ecdsa_pair = ecdsa::Pair::generate().0; + let ecdsa_public = ecdsa_pair.public(); + let classical_key = ClassicalKey::Ecdsa(ecdsa_public); + + // Build and sign the challenge message + let message = build_challenge_message(&quantus_account, &classical_key, &block_hash); + let signature = ecdsa_pair.sign(&message); + let classical_sig = ClassicalSignature::Ecdsa(signature); + + // Associate the key + assert_ok!(KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account.clone()), + classical_key.clone(), + classical_sig, + block_number, + block_hash, + )); + + // Verify storage + let associations = Associations::::get(&quantus_account); + assert_eq!(associations.len(), 1); + assert_eq!(associations[0].0, classical_key); + assert_eq!(associations[0].1.created_at, 10); // Current block + + // Verify reverse index + let key_hash = KeyAssociation::compute_key_hash(&classical_key); + assert_eq!(KeyIndex::::get(key_hash), Some(quantus_account.clone())); + + // Verify event + System::assert_last_event( + Event::KeyAssociated { + account: quantus_account, + key_type: KeyType::Ecdsa, + key_hash, + } + .into(), + ); + }); +} + +// ==================== ED25519 TESTS ==================== + +#[test] +fn associate_ed25519_key_works() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let quantus_account = account_id(2); + + // Generate Ed25519 keypair + let ed_pair = ed25519::Pair::generate().0; + let ed_public = ed_pair.public(); + let classical_key = ClassicalKey::Ed25519(ed_public); + + let message = build_challenge_message(&quantus_account, &classical_key, &block_hash); + let signature = ed_pair.sign(&message); + let classical_sig = ClassicalSignature::Ed25519(signature); + + assert_ok!(KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account.clone()), + classical_key.clone(), + classical_sig, + block_number, + block_hash, + )); + + // Verify storage + let associations = Associations::::get(&quantus_account); + assert_eq!(associations.len(), 1); + assert_eq!(associations[0].0, classical_key); + + // Verify event + let key_hash = KeyAssociation::compute_key_hash(&classical_key); + System::assert_last_event( + Event::KeyAssociated { + account: quantus_account, + key_type: KeyType::Ed25519, + key_hash, + } + .into(), + ); + }); +} + +// ==================== MULTIPLE KEYS TESTS ==================== + +#[test] +fn associate_multiple_keys_works() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let quantus_account = account_id(1); + + // Associate an ECDSA key + let ecdsa_pair = ecdsa::Pair::generate().0; + let ecdsa_key = ClassicalKey::Ecdsa(ecdsa_pair.public()); + let msg1 = build_challenge_message(&quantus_account, &ecdsa_key, &block_hash); + let sig1 = ClassicalSignature::Ecdsa(ecdsa_pair.sign(&msg1)); + + assert_ok!(KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account.clone()), + ecdsa_key.clone(), + sig1, + block_number, + block_hash, + )); + + // Associate an Ed25519 key + let ed_pair = ed25519::Pair::generate().0; + let ed_key = ClassicalKey::Ed25519(ed_pair.public()); + let msg2 = build_challenge_message(&quantus_account, &ed_key, &block_hash); + let sig2 = ClassicalSignature::Ed25519(ed_pair.sign(&msg2)); + + assert_ok!(KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account.clone()), + ed_key.clone(), + sig2, + block_number, + block_hash, + )); + + // Verify both are stored + let associations = Associations::::get(&quantus_account); + assert_eq!(associations.len(), 2); + }); +} + +#[test] +fn max_associations_enforced() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let quantus_account = account_id(1); + + // Associate MaxAssociations (8) keys + for _ in 0..8u8 { + // Use generate() with a seed to get deterministic but unique keys + let pair = ecdsa::Pair::generate().0; + let key = ClassicalKey::Ecdsa(pair.public()); + let msg = build_challenge_message(&quantus_account, &key, &block_hash); + let sig = ClassicalSignature::Ecdsa(pair.sign(&msg)); + + assert_ok!(KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account.clone()), + key, + sig, + block_number, + block_hash, + )); + } + + // 9th key should fail + let pair = ecdsa::Pair::generate().0; + let key = ClassicalKey::Ecdsa(pair.public()); + let msg = build_challenge_message(&quantus_account, &key, &block_hash); + let sig = ClassicalSignature::Ecdsa(pair.sign(&msg)); + + assert_noop!( + KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account), + key, + sig, + block_number, + block_hash, + ), + Error::::TooManyAssociations + ); + }); +} + +// ==================== ERROR CASES ==================== + +#[test] +fn invalid_signature_rejected() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let quantus_account = account_id(1); + + // Generate key but sign wrong message + let pair = ecdsa::Pair::generate().0; + let key = ClassicalKey::Ecdsa(pair.public()); + let wrong_message = b"wrong message"; + let sig = ClassicalSignature::Ecdsa(pair.sign(wrong_message)); + + assert_noop!( + KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account), + key, + sig, + block_number, + block_hash, + ), + Error::::InvalidSignature + ); + }); +} + +#[test] +fn signature_key_mismatch_rejected() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let quantus_account = account_id(1); + + // ECDSA key with Ed25519 signature + let ecdsa_pair = ecdsa::Pair::generate().0; + let ecdsa_key = ClassicalKey::Ecdsa(ecdsa_pair.public()); + + let ed_pair = ed25519::Pair::generate().0; + let msg = build_challenge_message(&quantus_account, &ecdsa_key, &block_hash); + let wrong_sig = ClassicalSignature::Ed25519(ed_pair.sign(&msg)); + + assert_noop!( + KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account), + ecdsa_key, + wrong_sig, + block_number, + block_hash, + ), + Error::::SignatureKeyMismatch + ); + }); +} + +#[test] +fn key_already_associated_rejected() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let account1 = account_id(1); + let account2 = account_id(2); + + // Account 1 associates a key + let pair = ecdsa::Pair::generate().0; + let key = ClassicalKey::Ecdsa(pair.public()); + let msg1 = build_challenge_message(&account1, &key, &block_hash); + let sig1 = ClassicalSignature::Ecdsa(pair.sign(&msg1)); + + assert_ok!(KeyAssociation::associate( + RuntimeOrigin::signed(account1), + key.clone(), + sig1, + block_number, + block_hash, + )); + + // Account 2 tries to associate the same key + let msg2 = build_challenge_message(&account2, &key, &block_hash); + let sig2 = ClassicalSignature::Ecdsa(pair.sign(&msg2)); + + assert_noop!( + KeyAssociation::associate( + RuntimeOrigin::signed(account2), + key, + sig2, + block_number, + block_hash, + ), + Error::::KeyAlreadyAssociated + ); + }); +} + +#[test] +fn block_hash_mismatch_rejected() { + new_test_ext().execute_with(|| { + let _correct_hash = setup_test_blocks(); + let block_number = 5u64; + + // Use a fake block hash that doesn't match block 5 + let fake_block_hash = H256::from([0xffu8; 32]); + + let quantus_account = account_id(1); + + let pair = ecdsa::Pair::generate().0; + let key = ClassicalKey::Ecdsa(pair.public()); + let msg = build_challenge_message(&quantus_account, &key, &fake_block_hash); + let sig = ClassicalSignature::Ecdsa(pair.sign(&msg)); + + assert_noop!( + KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account), + key, + sig, + block_number, + fake_block_hash, + ), + Error::::BlockHashMismatch + ); + }); +} + +#[test] +fn signature_expired_rejected() { + new_test_ext().execute_with(|| { + // Set up block hash at block 5, but set current block very far ahead + let test_block_hash = H256::from([0x42u8; 32]); + frame_system::BlockHash::::insert(5u64, test_block_hash); + + // Set current block to 1000 (way past the 256 block validity window) + System::set_block_number(1000); + + let quantus_account = account_id(1); + + let pair = ecdsa::Pair::generate().0; + let key = ClassicalKey::Ecdsa(pair.public()); + let msg = build_challenge_message(&quantus_account, &key, &test_block_hash); + let sig = ClassicalSignature::Ecdsa(pair.sign(&msg)); + + assert_noop!( + KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account), + key, + sig, + 5u64, + test_block_hash, + ), + Error::::SignatureExpired + ); + }); +} + +// ==================== READ API TESTS ==================== + +#[test] +fn read_apis_work() { + new_test_ext().execute_with(|| { + let block_hash = setup_test_blocks(); + let block_number = 5u64; + + let quantus_account = account_id(1); + + let pair = ecdsa::Pair::generate().0; + let key = ClassicalKey::Ecdsa(pair.public()); + let msg = build_challenge_message(&quantus_account, &key, &block_hash); + let sig = ClassicalSignature::Ecdsa(pair.sign(&msg)); + + // Before association + assert!(!KeyAssociation::is_key_associated(&key)); + assert_eq!(KeyAssociation::account_for_key(&key), None); + assert!(KeyAssociation::associations_for(&quantus_account).is_empty()); + + // Associate + assert_ok!(KeyAssociation::associate( + RuntimeOrigin::signed(quantus_account.clone()), + key.clone(), + sig, + block_number, + block_hash, + )); + + // After association + assert!(KeyAssociation::is_key_associated(&key)); + assert_eq!(KeyAssociation::account_for_key(&key), Some(quantus_account.clone())); + + let associations = KeyAssociation::associations_for(&quantus_account); + assert_eq!(associations.len(), 1); + assert_eq!(associations[0].0, key); + }); +} diff --git a/pallets/key-association/src/types.rs b/pallets/key-association/src/types.rs new file mode 100644 index 00000000..48effe4a --- /dev/null +++ b/pallets/key-association/src/types.rs @@ -0,0 +1,63 @@ +//! Types for the key-association pallet. + +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_core::{ecdsa, ed25519}; +use sp_runtime::RuntimeDebug; + +/// Supported classical key types. +/// +/// These are the pre-quantum cryptographic keys that users may want to +/// associate with their post-quantum ML-DSA-87 accounts for migration purposes. +#[derive( + Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, TypeInfo, MaxEncodedLen, RuntimeDebug, +)] +pub enum ClassicalKey { + /// ECDSA secp256k1 compressed public key (33 bytes). + /// Compatible with Ethereum, Bitcoin, and other secp256k1-based chains. + Ecdsa(ecdsa::Public), + /// Ed25519 public key (32 bytes). + /// Compatible with Polkadot (Sr25519 uses the same curve), Solana, etc. + Ed25519(ed25519::Public), +} + +impl ClassicalKey { + /// Returns the key type discriminant. + pub fn key_type(&self) -> KeyType { + match self { + ClassicalKey::Ecdsa(_) => KeyType::Ecdsa, + ClassicalKey::Ed25519(_) => KeyType::Ed25519, + } + } +} + +/// Signature types matching the classical keys. +#[derive( + Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, TypeInfo, MaxEncodedLen, RuntimeDebug, +)] +pub enum ClassicalSignature { + /// ECDSA signature (65 bytes: r + s + recovery byte). + Ecdsa(ecdsa::Signature), + /// Ed25519 signature (64 bytes). + Ed25519(ed25519::Signature), +} + +/// Key type discriminant for events. +#[derive( + Encode, Decode, DecodeWithMemTracking, Clone, Copy, PartialEq, Eq, TypeInfo, MaxEncodedLen, RuntimeDebug, +)] +pub enum KeyType { + /// ECDSA secp256k1 + Ecdsa, + /// Ed25519 + Ed25519, +} + +/// Metadata stored with each key association. +#[derive( + Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, TypeInfo, MaxEncodedLen, RuntimeDebug, +)] +pub struct AssociationRecord { + /// Block number when the association was created. + pub created_at: BlockNumber, +} diff --git a/pallets/key-association/src/weights.rs b/pallets/key-association/src/weights.rs new file mode 100644 index 00000000..fb05846e --- /dev/null +++ b/pallets/key-association/src/weights.rs @@ -0,0 +1,47 @@ +//! Placeholder weights for pallet-key-association. +//! +//! These are temporary weights that should be replaced with benchmarked values. +//! Run `cargo run --release -p quantus-node benchmark pallet` to generate real weights. + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_key_association`. +pub trait WeightInfo { + fn associate() -> Weight; +} + +/// Placeholder weights for `pallet_key_association`. +/// +/// These are conservative estimates. Real benchmarks should be run for production. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Weight for the `associate` extrinsic. + /// + /// Storage: `System::BlockHash` (r:1 for block hash lookup) + /// Storage: `KeyAssociation::KeyIndex` (r:1 w:1) + /// Storage: `KeyAssociation::Associations` (r:1 w:1) + /// + /// Computation: Signature verification (ECDSA ~50µs, Ed25519 ~30µs) + fn associate() -> Weight { + // Conservative placeholder: 100ms execution + storage ops + // ECDSA verification is more expensive than Ed25519 + Weight::from_parts(100_000_000, 0) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } +} + +/// Default implementation for tests. +impl WeightInfo for () { + fn associate() -> Weight { + Weight::from_parts(100_000_000, 0) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } +}