diff --git a/changelog.d/ed25519-verify.added b/changelog.d/ed25519-verify.added new file mode 100644 index 00000000000..50c9f8e2ed5 --- /dev/null +++ b/changelog.d/ed25519-verify.added @@ -0,0 +1 @@ +Added ed25519-verify to clarity6 \ No newline at end of file diff --git a/clarity/src/vm/analysis/arithmetic_checker/mod.rs b/clarity/src/vm/analysis/arithmetic_checker/mod.rs index af12b273e77..30b9cd645c2 100644 --- a/clarity/src/vm/analysis/arithmetic_checker/mod.rs +++ b/clarity/src/vm/analysis/arithmetic_checker/mod.rs @@ -210,7 +210,9 @@ impl ArithmeticOnlyChecker<'_> { Err(Error::FunctionNotPermitted(function)) } Sha512 | Sha512Trunc256 | Secp256k1Recover | Secp256k1Verify | Secp256r1Verify - | Hash160 | Sha256 | Keccak256 => Err(Error::FunctionNotPermitted(function)), + | Ed25519Verify | Hash160 | Sha256 | Keccak256 => { + Err(Error::FunctionNotPermitted(function)) + } Add | Subtract | Divide | Multiply | CmpGeq | CmpLeq | CmpLess | CmpGreater | Modulo | Power | Sqrti | Log2 | BitwiseXor | And | Or | Not | Equals | If | ConsSome | ConsOkay | ConsError | DefaultTo | UnwrapRet | UnwrapErrRet | IsOkay diff --git a/clarity/src/vm/analysis/read_only_checker/mod.rs b/clarity/src/vm/analysis/read_only_checker/mod.rs index 35bb30fd3cc..e64d1eadba4 100644 --- a/clarity/src/vm/analysis/read_only_checker/mod.rs +++ b/clarity/src/vm/analysis/read_only_checker/mod.rs @@ -312,6 +312,7 @@ impl<'a, 'b> ReadOnlyChecker<'a, 'b> { | Secp256k1Recover | Secp256k1Verify | Secp256r1Verify + | Ed25519Verify | ConsSome | ConsOkay | ConsError diff --git a/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs index 3ae35190eff..ff0ddecfaf9 100644 --- a/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_05/natives/mod.rs @@ -839,7 +839,8 @@ impl TypedNativeFunction { | AllowanceWithNft | AllowanceWithStacking | AllowanceAll - | Secp256r1Verify => { + | Secp256r1Verify + | Ed25519Verify => { return Err(StaticCheckErrorKind::Unreachable( "Clarity 2+ keywords should not show up in 2.05".into(), )); diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index 1795fa8c7a4..d8aebf54e99 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -775,6 +775,18 @@ fn check_secp256r1_verify( Ok(TypeSignature::BoolType) } +fn check_ed25519_verify( + checker: &mut TypeChecker, + args: &[SymbolicExpression], + context: &TypingContext, +) -> Result { + check_argument_count(3, args)?; + checker.type_check_expects(&args[0], context, &TypeSignature::BUFFER_MAX)?; + checker.type_check_expects(&args[1], context, &TypeSignature::BUFFER_64)?; + checker.type_check_expects(&args[2], context, &TypeSignature::BUFFER_32)?; + Ok(TypeSignature::BoolType) +} + fn check_get_block_info( checker: &mut TypeChecker, args: &[SymbolicExpression], @@ -1272,6 +1284,7 @@ impl TypedNativeFunction { | AllowanceWithStacking | AllowanceAll => Special(SpecialNativeFunction(&post_conditions::check_allowance_err)), Secp256r1Verify => Special(SpecialNativeFunction(&check_secp256r1_verify)), + Ed25519Verify => Special(SpecialNativeFunction(&check_ed25519_verify)), }; Ok(out) diff --git a/clarity/src/vm/costs/cost_functions.rs b/clarity/src/vm/costs/cost_functions.rs index 9621d9cc8bf..49be8fc2bd2 100644 --- a/clarity/src/vm/costs/cost_functions.rs +++ b/clarity/src/vm/costs/cost_functions.rs @@ -161,6 +161,7 @@ define_named_enum!(ClarityCostFunction { RestrictAssets("cost_restrict_assets"), AsContractSafe("cost_as_contract_safe"), Secp256r1verify("cost_secp256r1verify"), + Ed25519verify("cost_ed25519verify"), Unimplemented("cost_unimplemented"), }); @@ -339,6 +340,7 @@ pub trait CostValues { fn cost_restrict_assets(n: u64) -> Result; fn cost_as_contract_safe(n: u64) -> Result; fn cost_secp256r1verify(n: u64) -> Result; + fn cost_ed25519verify(n: u64) -> Result; } impl ClarityCostFunction { @@ -496,6 +498,7 @@ impl ClarityCostFunction { ClarityCostFunction::RestrictAssets => C::cost_restrict_assets(n), ClarityCostFunction::AsContractSafe => C::cost_as_contract_safe(n), ClarityCostFunction::Secp256r1verify => C::cost_secp256r1verify(n), + ClarityCostFunction::Ed25519verify => C::cost_ed25519verify(n), ClarityCostFunction::Unimplemented => Err(RuntimeError::NotImplemented.into()), } } diff --git a/clarity/src/vm/costs/costs_1.rs b/clarity/src/vm/costs/costs_1.rs index dc330f955de..3bfb29f8d74 100644 --- a/clarity/src/vm/costs/costs_1.rs +++ b/clarity/src/vm/costs/costs_1.rs @@ -765,4 +765,8 @@ impl CostValues for Costs1 { fn cost_secp256r1verify(n: u64) -> Result { Err(RuntimeError::NotImplemented.into()) } + + fn cost_ed25519verify(n: u64) -> Result { + Err(RuntimeError::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_2.rs b/clarity/src/vm/costs/costs_2.rs index f6fdc4dec81..b32be047d63 100644 --- a/clarity/src/vm/costs/costs_2.rs +++ b/clarity/src/vm/costs/costs_2.rs @@ -765,4 +765,8 @@ impl CostValues for Costs2 { fn cost_secp256r1verify(n: u64) -> Result { Err(RuntimeError::NotImplemented.into()) } + + fn cost_ed25519verify(n: u64) -> Result { + Err(RuntimeError::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_2_testnet.rs b/clarity/src/vm/costs/costs_2_testnet.rs index 4a9e5ab0a31..a9e76d6f0e2 100644 --- a/clarity/src/vm/costs/costs_2_testnet.rs +++ b/clarity/src/vm/costs/costs_2_testnet.rs @@ -765,4 +765,8 @@ impl CostValues for Costs2Testnet { fn cost_secp256r1verify(n: u64) -> Result { Err(RuntimeError::NotImplemented.into()) } + + fn cost_ed25519verify(n: u64) -> Result { + Err(RuntimeError::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_3.rs b/clarity/src/vm/costs/costs_3.rs index 21dce8313c2..e56b52f6670 100644 --- a/clarity/src/vm/costs/costs_3.rs +++ b/clarity/src/vm/costs/costs_3.rs @@ -783,4 +783,8 @@ impl CostValues for Costs3 { fn cost_secp256r1verify(n: u64) -> Result { Err(RuntimeError::NotImplemented.into()) } + + fn cost_ed25519verify(n: u64) -> Result { + Err(RuntimeError::NotImplemented.into()) + } } diff --git a/clarity/src/vm/costs/costs_4.rs b/clarity/src/vm/costs/costs_4.rs index 7ab6df637c5..d13e6683c37 100644 --- a/clarity/src/vm/costs/costs_4.rs +++ b/clarity/src/vm/costs/costs_4.rs @@ -21,6 +21,7 @@ use super::ExecutionCost; /// overrides only `cost_contract_hash`. use super::cost_functions::CostValues; use super::costs_3::Costs3; +use crate::vm::RuntimeError; use crate::vm::costs::cost_functions::linear; use crate::vm::errors::VmExecutionError; @@ -473,4 +474,8 @@ impl CostValues for Costs4 { fn cost_secp256r1verify(n: u64) -> Result { Ok(ExecutionCost::runtime(51750)) } + + fn cost_ed25519verify(n: u64) -> Result { + Err(RuntimeError::NotImplemented.into()) + } } diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs index 2041bdd14fa..2b671a572b3 100644 --- a/clarity/src/vm/docs/mod.rs +++ b/clarity/src/vm/docs/mod.rs @@ -1418,6 +1418,20 @@ NIST P-256 curve (also known as secp256r1).", 0x031ccbe91c075fc7f4f033bfa248db8fccd3565de94bbfb12f3c59ff46c271bf83) ;; Returns false" }; +const ED25519VERIFY_API: SpecialAPI = SpecialAPI { + input_type: "(buff), (buff 64), (buff 32)", + snippet: "ed25519-verify ${1:message} ${2:signature} ${3:public-key})", + output_type: "bool", + signature: "(ed25519-verify message signature public-key)", + description: "The `ed25519-verify` function verifies that the provided signature of the message +was signed with the private key that generated the public key.", + example: "(ed25519-verify 0x68656c6c6f20776f726c64 + 0x7e8346b0d9ef1151608df9d436c646b9df23758b292e0df400032f2603417724a25997d81a95a8997a55252813589b9409893df1ec75249a5b6f38753232810e + 0xec172b93ad5e563bf49683c1397357b1af93d4e937abda610c10ccc6112217c0) ;; Returns true +(ed25519-verify 0x00000000000000000000000000000000000000 0x7e8346b0d9ef1151608df9d436c646b9df23758b292e0df400032f2603417724a25997d81a95a8997a55252813589b9409893df1ec75249a5b6f38753232810e + 0xec172b93ad5e563bf49683c1397357b1af93d4e937abda610c10ccc6112217c0) ;; Returns false" +}; + const CONTRACT_CALL_API: SpecialAPI = SpecialAPI { input_type: "ContractName, PublicFunctionName, Arg0, ...", snippet: "contract-call? ${1:contract-principal} ${2:func} ${3:arg1}", @@ -2900,6 +2914,7 @@ pub fn make_api_reference(function: &NativeFunctions) -> FunctionAPI { AllowanceWithStacking => make_for_special(&ALLOWANCE_WITH_STACKING, function), AllowanceAll => make_for_special(&ALLOWANCE_WITH_ALL, function), Secp256r1Verify => make_for_special(&SECP256R1VERIFY_API, function), + Ed25519Verify => make_for_special(&ED25519VERIFY_API, function), } } diff --git a/clarity/src/vm/functions/crypto.rs b/clarity/src/vm/functions/crypto.rs index fab77e0550f..6cd9c704a8e 100644 --- a/clarity/src/vm/functions/crypto.rs +++ b/clarity/src/vm/functions/crypto.rs @@ -14,10 +14,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use clarity_types::types::MAX_VALUE_SIZE; use stacks_common::address::{ AddressHashMode, C32_ADDRESS_VERSION_MAINNET_SINGLESIG, C32_ADDRESS_VERSION_TESTNET_SINGLESIG, }; use stacks_common::types::chainstate::StacksAddress; +use stacks_common::util::ed25519::ed25519_verify; use stacks_common::util::hash; use stacks_common::util::secp256k1::{Secp256k1PublicKey, secp256k1_recover, secp256k1_verify}; use stacks_common::util::secp256r1::{secp256r1_verify, secp256r1_verify_digest}; @@ -331,3 +333,74 @@ pub fn special_secp256r1_verify( Ok(Value::Bool(verify_result.is_ok())) } + +pub fn special_ed25519_verify( + args: &[SymbolicExpression], + exec_state: &mut ExecutionState, + invoke_ctx: &InvocationContext, + context: &LocalContext, +) -> Result { + // (ed25519-verify message signature public-key) + // message: (buff MAX_VALUE_SIZE), signature: (buff 64), public-key: (buff 32) + check_argument_count(3, args)?; + + runtime_cost(ClarityCostFunction::Ed25519verify, exec_state, 0)?; + + let arg0 = args + .first() + .ok_or(RuntimeCheckErrorKind::IncorrectArgumentCount(0, 3))?; + let message_value = eval(arg0, exec_state, invoke_ctx, context)?; + let message = match message_value.as_ref() { + Value::Sequence(SequenceData::Buffer(BuffData { data })) + if data.len() <= MAX_VALUE_SIZE as usize => + { + data + } + _ => { + return Err(RuntimeCheckErrorKind::TypeValueError( + Box::new(TypeSignature::BUFFER_MAX), + message_value.as_ref().to_error_string(), + ) + .into()); + } + }; + + let arg1 = args + .get(1) + .ok_or(RuntimeCheckErrorKind::IncorrectArgumentCount(1, 3))?; + let signature_value = eval(arg1, exec_state, invoke_ctx, context)?; + let signature = match signature_value.as_ref() { + Value::Sequence(SequenceData::Buffer(BuffData { data })) if data.len() <= 64 => { + if data.len() != 64 { + return Ok(Value::Bool(false)); + } + data + } + _ => { + return Err(RuntimeCheckErrorKind::TypeValueError( + Box::new(TypeSignature::BUFFER_64), + signature_value.as_ref().to_error_string(), + ) + .into()); + } + }; + + let arg2 = args + .get(2) + .ok_or(RuntimeCheckErrorKind::IncorrectArgumentCount(2, 3))?; + let pubkey_value = eval(arg2, exec_state, invoke_ctx, context)?; + let pubkey = match pubkey_value.as_ref() { + Value::Sequence(SequenceData::Buffer(BuffData { data })) if data.len() == 32 => data, + _ => { + return Err(RuntimeCheckErrorKind::TypeValueError( + Box::new(TypeSignature::BUFFER_32), + pubkey_value.as_ref().to_error_string(), + ) + .into()); + } + }; + + let verify_result = ed25519_verify(message, signature, pubkey); + + Ok(Value::Bool(verify_result.is_ok())) +} diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index 1ccc699e635..113f6fa9b7f 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -187,6 +187,7 @@ define_versioned_named_enum_with_max!(NativeFunctions(ClarityVersion) { AllowanceWithStacking("with-stacking", ClarityVersion::Clarity4, None), AllowanceAll("with-all-assets-unsafe", ClarityVersion::Clarity4, None), Secp256r1Verify("secp256r1-verify", ClarityVersion::Clarity4, None), + Ed25519Verify("ed25519-verify", ClarityVersion::Clarity6, None), }); /// @@ -576,6 +577,9 @@ pub fn lookup_reserved_functions(name: &str, version: &ClarityVersion) -> Option Secp256r1Verify => { SpecialFunction("native_secp256r1-verify", &crypto::special_secp256r1_verify) } + Ed25519Verify => { + SpecialFunction("native_ed25519-verify", &crypto::special_ed25519_verify) + } }; Some(callable) } else { diff --git a/clarity/src/vm/tests/crypto.rs b/clarity/src/vm/tests/crypto.rs index c18a9ec8f85..2f53d8251dc 100644 --- a/clarity/src/vm/tests/crypto.rs +++ b/clarity/src/vm/tests/crypto.rs @@ -1,3 +1,4 @@ +use clarity_types::types::MAX_VALUE_SIZE; // Copyright (C) 2026 Stacks Open Internet Foundation // // This program is free software: you can redistribute it and/or modify @@ -13,9 +14,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . use pinny::tag; +use proptest::collection::vec; use proptest::prelude::*; use stacks_common::types::chainstate::{StacksPrivateKey, StacksPublicKey}; use stacks_common::types::{PrivateKey, StacksEpochId}; +use stacks_common::util::ed25519::{self, Ed25519PrivateKey, Ed25519PublicKey, MessageSignature}; use stacks_common::util::hash::{Sha256Sum, to_hex}; use stacks_common::util::secp256k1::MessageSignature as Secp256k1Signature; use stacks_common::util::secp256r1::{Secp256r1PrivateKey, Secp256r1PublicKey}; @@ -736,6 +739,64 @@ fn test_secp256k1_recover_invalid_signature_returns_err_code() { } } +#[test] +fn test_ed25519_verify_valid_signature_returns_true() { + let sk = Ed25519PrivateKey::random(); + let pk = Ed25519PublicKey::from_private(&sk); + + let message = b"Hello World"; + + let signature = sk.sign(message).unwrap(); + + let program = format!( + "(ed25519-verify {} {} {})", + buff_literal(message), + buff_literal(&signature.to_bytes()), + buff_literal(&pk.to_bytes()) + ); + + assert_eq!( + Value::Bool(true), + execute_with_parameters( + program.as_str(), + ClarityVersion::latest(), + StacksEpochId::latest(), + false + ) + .expect("execution should succeed") + .expect("should return a value") + ); +} + +#[test] +fn test_ed25519_verify_invalid_signature_returns_false() { + let sk = Ed25519PrivateKey::random(); + let pk = Ed25519PublicKey::from_private(&sk); + + let message = b"Hello World"; + + let signature = MessageSignature::empty(); + + let program = format!( + "(ed25519-verify {} {} {})", + buff_literal(message), + buff_literal(&signature.to_bytes()), + buff_literal(&pk.to_bytes()) + ); + + assert_eq!( + Value::Bool(false), + execute_with_parameters( + program.as_str(), + ClarityVersion::latest(), + StacksEpochId::latest(), + false + ) + .expect("execution should succeed") + .expect("should return a value") + ); +} + proptest! { #[tag(t_prop)] #[test] @@ -1061,4 +1122,35 @@ proptest! { prop_assert_eq!(Value::Bool(false), result); } + + #[tag(t_prop)] + #[test] + fn prop_ed25519_verify_accepts_valid_signatures( + seed in any::<[u8; 32]>(), + message_bytes in vec(any::(), 0..MAX_VALUE_SIZE as usize) + ) { + let privk = Ed25519PrivateKey::from_seed(&seed); + let pubk = Ed25519PublicKey::from_private(&privk); + let pubkey_bytes = pubk.to_bytes(); + + let signature: ed25519::MessageSignature = privk.sign(&message_bytes).expect("ed25519 signing should succeed"); + let signature_bytes = signature.to_bytes(); + let program = format!( + "(ed25519-verify {} {} {})", + buff_literal(&message_bytes), + buff_literal(&signature_bytes), + buff_literal(&pubkey_bytes) + ); + + let result = execute_with_parameters( + program.as_str(), + ClarityVersion::latest(), + StacksEpochId::latest(), + false, + ) + .expect("execution should succeed") + .expect("should return a value"); + + prop_assert_eq!(Value::Bool(true), result); + } } diff --git a/stacks-common/src/util/ed25519.rs b/stacks-common/src/util/ed25519.rs new file mode 100644 index 00000000000..ee6bf91bde1 --- /dev/null +++ b/stacks-common/src/util/ed25519.rs @@ -0,0 +1,289 @@ +// Copyright (C) 2026 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use thiserror::Error; + +use crate::util::hash::{hex_bytes, to_hex, Sha256Sum}; + +pub const MESSAGE_SIGNATURE_ENCODED_SIZE: u32 = 64; + +pub struct MessageSignature(pub [u8; 64]); +impl_array_newtype!(MessageSignature, u8, 64); +impl_array_hexstring_fmt!(MessageSignature); +impl_byte_array_newtype!(MessageSignature, u8, 64); +impl_byte_array_serde!(MessageSignature); + +#[derive(Debug, PartialEq, Eq, Clone, Error)] +pub enum Ed25519Error { + #[error("Invalid key")] + InvalidKey, + #[error("Invalid signature")] + InvalidSignature, + #[error("Invalid message")] + InvalidMessage, + #[error("Invalid recovery ID")] + InvalidRecoveryId, + #[error("Signing failed")] + SigningFailed, +} + +/// A Ed25519 public key +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Ed25519PublicKey { + key: VerifyingKey, +} +impl_byte_array_serde!(Ed25519PublicKey); + +/// A Ed25519 private key +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Ed25519PrivateKey { + key: SigningKey, +} +impl_byte_array_serde!(Ed25519PrivateKey); + +impl MessageSignature { + /// Creates an "empty" signature (all zeros). Note this is not a valid signature. + pub fn empty() -> MessageSignature { + // NOTE: this cannot be a valid signature + MessageSignature([0u8; 64]) + } + + /// Generates place-holder data (for testing purposes only) + #[cfg(any(test, feature = "testing"))] + pub fn from_raw(sig: &[u8]) -> MessageSignature { + let mut buf = [0u8; 64]; + if sig.len() < 64 { + buf[..sig.len()].copy_from_slice(sig); + } else { + buf.copy_from_slice(&sig[..64]); + } + MessageSignature(buf) + } + + /// Converts from a ed25519_dalek::Signature to our MessageSignature + pub fn from_ed25519_signature(sig: &Signature) -> MessageSignature { + let sig_bytes = sig.to_bytes(); + let mut ret_bytes = [0u8; 64]; + ret_bytes.copy_from_slice(&sig_bytes); + MessageSignature(ret_bytes) + } + + /// Converts to a ed25519_dalek::Signature + pub fn to_ed25519_signature(&self) -> Signature { + Signature::from_bytes(&self.0) + } +} + +impl Ed25519PublicKey { + /// Generates a new random public key (for testing purposes only). + #[cfg(any(test, feature = "testing"))] + pub fn random() -> Ed25519PublicKey { + Ed25519PublicKey::from_private(&Ed25519PrivateKey::random()) + } + + /// Creates a Ed25519PublicKey from a hex string representation. + pub fn from_hex(hex_string: &str) -> Result { + let data = hex_bytes(hex_string).map_err(|_e| "Failed to decode hex public key")?; + Ed25519PublicKey::from_slice(&data[..]).map_err(|_e| "Invalid public key hex string") + } + + /// Creates a Ed25519PublicKey from a byte slice. + pub fn from_slice(data: &[u8]) -> Result { + let data32: [u8; 32] = data + .try_into() + .map_err(|_| "Invalid public key: length must be 32 bytes")?; + + let verifying_key = + VerifyingKey::from_bytes(&data32).map_err(|_| "Invalid public key: failed to load")?; + + Ok(Ed25519PublicKey { key: verifying_key }) + } + + /// Creates a Ed25519PublicKey from a Ed25519PrivateKey. + pub fn from_private(privk: &Ed25519PrivateKey) -> Ed25519PublicKey { + let verifying_key = privk.key.verifying_key(); + Ed25519PublicKey { key: verifying_key } + } + + /// Converts the public key to a hex string representation. + pub fn to_hex(&self) -> String { + to_hex(&self.to_bytes()) + } + + pub fn to_bytes(&self) -> Vec { + self.key.to_bytes().to_vec() + } + + /// Verify a signature against a message. + /// Returns Ok(()) if the signature is valid, or an error otherwise. + pub fn verify(&self, msg: &[u8], sig: &MessageSignature) -> Result<(), Ed25519Error> { + let ed25519_sig = sig.to_ed25519_signature(); + + // Verify the signature + self.key + .verify(msg, &ed25519_sig) + .map_err(|_| Ed25519Error::InvalidSignature) + } +} + +#[cfg(any(test, feature = "testing"))] +impl Default for Ed25519PublicKey { + fn default() -> Self { + Self::random() + } +} + +impl Ed25519PrivateKey { + /// Generates a new random private key. + #[cfg(feature = "rand")] + pub fn random() -> Ed25519PrivateKey { + use rand::RngCore as _; + let mut rng = rand::thread_rng(); + let mut sk_bytes = [0u8; 32]; + rng.fill_bytes(&mut sk_bytes); + + let signing_key = SigningKey::from_bytes(&sk_bytes); + Ed25519PrivateKey { key: signing_key } + } + + /// Creates a Ed25519PrivateKey from seed bytes by repeatedly + /// SHA256 hashing the seed bytes until a private key is found. + /// + /// If `seed` is a valid private key, it will be returned without hashing. + /// The returned private key's compress_public flag will be `true`. + pub fn from_seed(seed: &[u8]) -> Ed25519PrivateKey { + let mut re_hashed_seed = Vec::from(seed); + loop { + if let Ok(sk) = Ed25519PrivateKey::from_slice(&re_hashed_seed[..]) { + return sk; + } else { + re_hashed_seed = Sha256Sum::from_data(&re_hashed_seed[..]) + .as_bytes() + .to_vec() + } + } + } + + /// Creates a Ed25519PrivateKey from a hex string representation. + pub fn from_hex(hex_string: &str) -> Result { + let data = hex_bytes(hex_string).map_err(|_e| "Failed to decode hex private key")?; + Ed25519PrivateKey::from_slice(&data[..]).map_err(|_e| "Invalid private key hex string") + } + + /// Creates a Ed25519PrivateKey from a byte slice. + pub fn from_slice(data: &[u8]) -> Result { + if data.len() != 32 { + return Err("Invalid private key: not 32 bytes"); + } + + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&data[0..32]); + + let signing_key = SigningKey::from_bytes(&key_bytes); + + Ok(Ed25519PrivateKey { key: signing_key }) + } + + /// Converts the private key to a hex string representation. + pub fn to_hex(&self) -> String { + let bytes = self.key.to_bytes().to_vec(); + to_hex(&bytes) + } + + /// Converts the private key to a byte vector representation. + pub fn to_bytes(&self) -> Vec { + self.key.to_bytes().to_vec() + } + + /// Sign a message + pub fn sign(&self, data: &[u8]) -> Result { + let signature: Signature = self + .key + .try_sign(data) + .map_err(|_| "Failed to sign message")?; + Ok(MessageSignature::from_ed25519_signature(&signature)) + } +} + +/// Verify a ed25519 signature against the message +/// The signature must be a 64-byte signature + +pub fn ed25519_verify( + message_arr: &[u8], + signature_arr: &[u8], + pubkey_arr: &[u8], +) -> Result<(), Ed25519Error> { + let sig_bytes: &[u8; 64] = signature_arr + .try_into() + .map_err(|_| Ed25519Error::InvalidSignature)?; + + let pk = Ed25519PublicKey::from_slice(pubkey_arr).map_err(|_| Ed25519Error::InvalidKey)?; + let sig = MessageSignature::from_bytes(sig_bytes).ok_or(Ed25519Error::InvalidSignature)?; + pk.verify(message_arr, &sig) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_seed() { + let sk = Ed25519PrivateKey::from_seed(&[2; 32]); + let pubk = Ed25519PublicKey::from_private(&sk); + + // Test that from_seed is deterministic + let sk2 = Ed25519PrivateKey::from_seed(&[2; 32]); + let pubk2 = Ed25519PublicKey::from_private(&sk2); + + assert_eq!(sk.to_hex(), sk2.to_hex()); + assert_eq!(pubk.to_hex(), pubk2.to_hex()); + } + + #[test] + fn test_roundtrip_sign_verify() { + let privk = Ed25519PrivateKey::random(); + let pubk = Ed25519PublicKey::from_private(&privk); + + let msg = b"hello world"; + + let sig = privk.sign(msg).unwrap(); + pubk.verify(msg, &sig).expect("invalid signature"); + } + + #[test] + fn test_roundtrip_sign_verify_with_zero() { + let privk = Ed25519PrivateKey::random(); + let pubk = Ed25519PublicKey::from_private(&privk); + + let msg = b""; + + let sig = privk.sign(msg).unwrap(); + pubk.verify(msg, &sig).expect("invalid signature"); + } + + #[test] + fn test_verify_with_different_key() { + let privk1 = Ed25519PrivateKey::random(); + let privk2 = Ed25519PrivateKey::random(); + let pubk2 = Ed25519PublicKey::from_private(&privk2); + + let msg = b"hello world"; + + let sig = privk1.sign(msg).unwrap(); + let e = pubk2.verify(msg, &sig).expect_err("expected an error"); + assert_eq!(e, Ed25519Error::InvalidSignature); + } +} diff --git a/stacks-common/src/util/mod.rs b/stacks-common/src/util/mod.rs index 8b68c6eaa58..1ca6e78991a 100644 --- a/stacks-common/src/util/mod.rs +++ b/stacks-common/src/util/mod.rs @@ -21,6 +21,7 @@ pub mod macros; pub mod chunked_encoding; #[cfg(feature = "rusqlite")] pub mod db; +pub mod ed25519; pub mod hash; pub mod lru_cache; pub mod pair; diff --git a/stackslib/src/clarity_vm/tests/costs.rs b/stackslib/src/clarity_vm/tests/costs.rs index 2f1a7117b31..2e40e9f9149 100644 --- a/stackslib/src/clarity_vm/tests/costs.rs +++ b/stackslib/src/clarity_vm/tests/costs.rs @@ -188,6 +188,7 @@ pub fn get_simple_test(function: &NativeFunctions) -> Option<&'static str> { RestrictAssets => "(restrict-assets? tx-sender () (+ u1 u2))", AsContractSafe => "(as-contract? () (+ u1 u2))", Secp256r1Verify => "(secp256r1-verify 0xc3abef6a775793dfbc8e0719e7a1de1fc2f90d37a7912b1ce8e300a5a03b06a8 0xf2b8c0645caa7250e3b96d633cf40a88456e4ffbddffb69200c4e019039dfd310eac59293c23e6d6aa8b0c5d9e4e48fa4c4fdf1ace2ba618dc0263b5e90a0903 0x031e18532fd4754c02f3041d9c75ceb33b83ffd81ac7ce4fe882ccb1c98bc5896e)", + Ed25519Verify => "(ed25519-verify 0x68656c6c6f20776f726c64 0x7e8346b0d9ef1151608df9d436c646b9df23758b292e0df400032f2603417724a25997d81a95a8997a55252813589b9409893df1ec75249a5b6f38753232810e 0xec172b93ad5e563bf49683c1397357b1af93d4e937abda610c10ccc6112217c0)", // These expressions are not usable in this context, since they are // only allowed within `restrict-assets?` or `as-contract?` AllowanceWithStx