diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dfe2e8..1c4dba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.93 + toolchain: 1.94 components: clippy - name: Install protoc run: | @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.93 + toolchain: 1.94 - name: Install protoc run: | sudo apt-get update diff --git a/Cargo.lock b/Cargo.lock index 39620bb..9c4b128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ "k256", "once_cell", "rand 0.8.5", - "secp256k1", + "secp256k1 0.30.0", "serde", "serde_json", "serde_with", @@ -180,7 +180,7 @@ dependencies = [ "either", "serde", "serde_with", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1022,7 +1022,7 @@ dependencies = [ "http 0.2.12", "http 1.4.0", "percent-encoding", - "sha2", + "sha2 0.10.9", "time", "tracing", ] @@ -1312,6 +1312,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bip32" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69" +dependencies = [ + "bs58", + "hmac", + "k256", + "rand_core 0.6.4", + "ripemd", + "secp256k1 0.27.0", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "bip39" version = "2.2.2" @@ -1377,6 +1394,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1428,7 +1454,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "sha2", + "sha2 0.10.9", "tinyvec", ] @@ -1613,6 +1639,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1634,7 +1671,7 @@ dependencies = [ "hmac", "k256", "serde", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", ] @@ -1650,7 +1687,7 @@ dependencies = [ "once_cell", "pbkdf2", "rand 0.8.5", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", ] @@ -1668,7 +1705,7 @@ dependencies = [ "generic-array 0.14.7", "ripemd", "serde", - "sha2", + "sha2 0.10.9", "sha3", "thiserror 1.0.69", ] @@ -1761,6 +1798,36 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cosmos-sdk-proto" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ac39be7373404accccaede7cc1ec942ccef14f0ca18d209967a756bf1dbb1f" +dependencies = [ + "prost", + "tendermint-proto", +] + +[[package]] +name = "cosmrs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e74fa7a22930fe0579bef560f2d64b78415d4c47b9dd976c0635136809471d" +dependencies = [ + "bip32", + "cosmos-sdk-proto", + "ecdsa", + "eyre", + "k256", + "rand_core 0.6.4", + "serde", + "serde_json", + "signature", + "subtle-encoding", + "tendermint", + "thiserror 1.0.69", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1881,6 +1948,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c359b7249347e46fb28804470d071c921156ad62b3eef5d34e2ba867533dec8" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.6.4", + "subtle-ng", + "zeroize", +] + [[package]] name = "darling" version = "0.23.0" @@ -1922,6 +2002,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "der" version = "0.7.10" @@ -2003,7 +2092,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", @@ -2084,6 +2173,19 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-consensus" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8465edc8ee7436ffea81d21a019b16676ee3db267aa8d5a8d729581ecf998b" +dependencies = [ + "curve25519-dalek-ng", + "hex", + "rand_core 0.6.4", + "sha2 0.9.9", + "zeroize", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" @@ -2093,7 +2195,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -2213,7 +2315,7 @@ dependencies = [ "hmac", "p256", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "typenum", ] @@ -2312,6 +2414,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2384,6 +2496,7 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" name = "fkms" version = "0.1.0-alpha.1" dependencies = [ + "aes", "alloy-primitives", "alloy-signer-local", "alloy-sol-types", @@ -2396,17 +2509,22 @@ dependencies = [ "bip39", "bs58", "clap", + "cmac", + "cosmrs", + "ctr", "dirs", "ecdsa", "ed25519-dalek", "elliptic-curve", "hex", "hex-literal", + "hkdf", "hmac", "http 1.4.0", "k256", "p256", "prost", + "rand 0.8.5", "reqwest", "ripemd", "rlp 0.6.1", @@ -2415,7 +2533,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_with", - "sha2", + "sha2 0.10.9", "sha3", "stellar-strkey 0.0.16", "stellar-xdr", @@ -2429,9 +2547,19 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "x25519-dalek", "xrpl-rust", ] +[[package]] +name = "flex-error" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" +dependencies = [ + "paste", +] + [[package]] name = "flume" version = "0.11.1" @@ -3241,6 +3369,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -3372,7 +3506,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2", + "sha2 0.10.9", "signature", ] @@ -3806,7 +3940,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4986,6 +5120,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "secp256k1-sys 0.8.2", +] + [[package]] name = "secp256k1" version = "0.30.0" @@ -4994,10 +5137,19 @@ checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", "rand 0.8.5", - "secp256k1-sys", + "secp256k1-sys 0.10.1", "serde", ] +[[package]] +name = "secp256k1-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" +dependencies = [ + "cc", +] + [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -5191,6 +5343,19 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5358,7 +5523,7 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "time", @@ -5395,7 +5560,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -5440,7 +5605,7 @@ dependencies = [ "rust_decimal", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -5483,7 +5648,7 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -5566,7 +5731,7 @@ dependencies = [ "escape-bytes", "ethnum", "hex", - "sha2", + "sha2 0.10.9", "stellar-strkey 0.0.13", ] @@ -5612,6 +5777,21 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subtle-encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcb1ed7b8330c5eed5441052651dd7a12c75e2ed88f2ec024ae1fa3a5e59945" +dependencies = [ + "zeroize", +] + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + [[package]] name = "syn" version = "1.0.109" @@ -5706,6 +5886,51 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendermint" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc997743ecfd4864bbca8170d68d9b2bee24653b034210752c2d883ef4b838b1" +dependencies = [ + "bytes", + "digest 0.10.7", + "ed25519", + "ed25519-consensus", + "flex-error", + "futures", + "k256", + "num-traits", + "once_cell", + "prost", + "ripemd", + "serde", + "serde_bytes", + "serde_json", + "serde_repr", + "sha2 0.10.9", + "signature", + "subtle", + "subtle-encoding", + "tendermint-proto", + "time", + "zeroize", +] + +[[package]] +name = "tendermint-proto" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c40e13d39ca19082d8a7ed22de7595979350319833698f8b1080f29620a094" +dependencies = [ + "bytes", + "flex-error", + "prost", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6897,6 +7122,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -6935,12 +7172,12 @@ dependencies = [ "reqwless", "ripemd", "rust_decimal", - "secp256k1", + "secp256k1 0.30.0", "serde", "serde_json", "serde_repr", "serde_with", - "sha2", + "sha2 0.10.9", "strum", "strum_macros", "thiserror-no-std", diff --git a/Cargo.toml b/Cargo.toml index 3ef3e8a..25bc0f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,12 +53,20 @@ ed25519-dalek = "2" elliptic-curve = "0.13.8" bip39 = "2" hmac = "0.12" -sha2 = "0.10" stellar-strkey = "0.0.16" stellar-xdr = { version = "26.0.0", features = ["curr", "base64"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } base32 = "0.5.1" +cosmrs = "0.22.0" +rand = "0.8.5" +hkdf = "0.12.4" +sha2 = "0.10.8" +x25519-dalek = { version = "2.0.0", features = ["static_secrets"] } +aes = "0.8.4" +cmac = "0.7.2" +ctr = "0.9.0" + [build-dependencies] tonic-build = { version = "0.13.1", features = ["prost"] } diff --git a/proto/fkms/v1/signer.proto b/proto/fkms/v1/signer.proto index ce3bd73..ff729eb 100644 --- a/proto/fkms/v1/signer.proto +++ b/proto/fkms/v1/signer.proto @@ -8,6 +8,7 @@ service FkmsService { rpc SignIcon(SignIconRequest) returns (SignIconResponse); rpc SignFlow(SignFlowRequest) returns (SignFlowResponse); rpc SignSoroban(SignSorobanRequest) returns (SignSorobanResponse); + rpc SignSecret(SignSecretRequest) returns (SignSecretResponse); rpc GetSignerAddresses(GetSignerAddressesRequest) returns (GetSignerAddressesResponse); } @@ -57,6 +58,15 @@ message SignSorobanResponse { string tx_blob = 1; } +message SignSecretRequest { + SecretSignerPayload signer_payload = 1; + Tss tss = 2; +} + +message SignSecretResponse { + bytes tx_blob = 1; +} + message GetSignerAddressesRequest {} message GetSignerAddressesResponse { @@ -95,6 +105,19 @@ message SorobanSignerPayload { repeated string rpc_urls = 6; } +message SecretSignerPayload { + string sender = 1; + string contract_address = 2; + string chain_id = 3; + uint64 account_number = 4; + uint64 sequence = 5; + uint64 gas_limit = 6; + string gas_prices = 7; + string memo = 8; + string code_hash = 9; + string chain_pubkey = 10; +} + message Tss { bytes message = 1; bytes random_addr = 2; @@ -112,4 +135,5 @@ enum ChainType { ICON = 2; FLOW = 3; SOROBAN = 4; + SECRET = 5; } diff --git a/src/codec.rs b/src/codec.rs index dbd1582..2e56ad3 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -1,3 +1,4 @@ +pub mod cosmwasm_secret; pub mod evm; pub mod flow; pub mod icon; diff --git a/src/codec/cosmwasm_secret.rs b/src/codec/cosmwasm_secret.rs new file mode 100644 index 0000000..f25b551 --- /dev/null +++ b/src/codec/cosmwasm_secret.rs @@ -0,0 +1,548 @@ +use anyhow::{Context, Result, anyhow}; +use cmac::{Cmac, Mac}; +use cosmrs::{ + AccountId, Any, + tx::{Body, Fee, SignDoc, SignerInfo}, +}; +use hkdf::Hkdf; +use prost::Message; +use rand::RngCore; +use serde_json::json; +use sha2::Sha256; +use std::str; +use x25519_dalek::{PublicKey, StaticSecret}; + +use aes::Aes128; +use ctr::Ctr128BE; +use ctr::cipher::{KeyIvInit, StreamCipher}; + +const HKDF_SALT: [u8; 32] = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x4b, 0xea, 0xd8, 0xdf, 0x69, 0x99, + 0x08, 0x52, 0xc2, 0x02, 0xdb, 0x0e, 0x00, 0x97, 0xc1, 0xa1, 0x2e, 0xa6, 0x37, 0xd7, 0xe9, 0x6d, +]; + +// Secret compute uses this exact type url for MsgExecuteContract. +const SECRET_MSG_EXECUTE_CONTRACT_TYPE_URL: &str = "/secret.compute.v1beta1.MsgExecuteContract"; + +// ---- Protobuf types (minimal subset) ---- + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Coin { + #[prost(string, tag = "1")] + pub denom: String, + #[prost(string, tag = "2")] + pub amount: String, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MsgExecuteContract { + // sender sdk.AccAddress (20 bytes) + #[prost(bytes, tag = "1")] + pub sender: Vec, + // contract sdk.AccAddress (20 bytes) + #[prost(bytes, tag = "2")] + pub contract: Vec, + // encrypted bytes (nonce||pubkey||ciphertext) + #[prost(bytes, tag = "3")] + pub msg: Vec, + // optional on chain; empty is fine + #[prost(string, tag = "4")] + pub callback_code_hash: String, + // repeated sent_funds; empty for oracle relays + #[prost(message, repeated, tag = "5")] + pub sent_funds: Vec, + #[prost(bytes, tag = "6")] + pub callback_sig: Vec, +} + +// ---- Public helpers used by fkms service.rs ---- + +pub fn secret_execute_msg_json( + symbols: Vec, + rates: Vec, + resolve_time: u64, + request_id: u64, +) -> Result> { + let rates_str = rates.iter().map(|r| r.to_string()).collect::>(); + let obj = json!({ + "relay": { + "symbols": symbols, + "rates": rates_str, + "resolve_time": resolve_time, + "request_id": request_id, + } + }); + + serde_json::to_vec(&obj).context("failed to serialize secret execute msg json") +} + +pub fn decode_acc_address_bech32(addr: &str) -> Result> { + let account_id: AccountId = addr + .parse() + .map_err(|e| anyhow!("failed to parse bech32 account id: {e}"))?; + let bytes = account_id.to_bytes(); + if bytes.len() != 20 { + return Err(anyhow!( + "invalid account address length: expected 20 bytes, got {}", + bytes.len() + )); + } + Ok(bytes) +} + +pub fn encrypt_secret_execute_msg( + code_hash: &str, + encryption_pubkey_hex: &str, + execute_msg_json: &[u8], +) -> Result> { + let encryption_pubkey_hex = encryption_pubkey_hex.trim_start_matches("0x"); + let receiver_pubkey_bytes = + hex::decode(encryption_pubkey_hex).with_context(|| "invalid hex pubkey")?; + if receiver_pubkey_bytes.len() != 32 { + return Err(anyhow!( + "invalid x25519 pubkey length: expected 32, got {}", + receiver_pubkey_bytes.len() + )); + } + let receiver_pubkey = PublicKey::from( + <[u8; 32]>::try_from(receiver_pubkey_bytes.as_slice()) + .map_err(|_| anyhow!("pubkey should be 32 bytes"))?, + ); + + let code_hash_hex = code_hash.trim_start_matches("0x").to_lowercase(); + if code_hash_hex.len() != 64 { + return Err(anyhow!( + "invalid code_hash length: expected 64 hex chars, got {}", + code_hash_hex.len() + )); + } + if !code_hash_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(anyhow!( + "invalid code_hash hex: contains non-hex characters" + )); + } + + let mut plaintext = Vec::with_capacity(code_hash_hex.len() + execute_msg_json.len()); + plaintext.extend_from_slice(code_hash_hex.as_bytes()); + plaintext.extend_from_slice(execute_msg_json); + + offline_encrypt_secret_message(&plaintext, &receiver_pubkey) +} + +pub struct SignSecretTxParams { + pub sender_address_bech32: String, + pub contract_address_bech32: String, + pub encrypted_execute_msg: Vec, + pub chain_id: String, + pub account_number: u64, + pub sequence: u64, + pub gas_limit: u64, + pub gas_prices: String, + pub memo: String, +} + +pub fn prepare_secret_tx_for_signing( + public_key: &[u8], + params: SignSecretTxParams, +) -> Result<(Vec, SignDoc)> { + let sender_bytes = decode_acc_address_bech32(¶ms.sender_address_bech32)?; + let contract_bytes = decode_acc_address_bech32(¶ms.contract_address_bech32)?; + + // Build secret MsgExecuteContract protobuf bytes. + let msg = MsgExecuteContract { + sender: sender_bytes, + contract: contract_bytes, + msg: params.encrypted_execute_msg, + callback_code_hash: "".to_string(), + sent_funds: vec![], + callback_sig: vec![], + }; + let msg_bytes = msg.encode_to_vec(); + + let msg_any = Any { + type_url: SECRET_MSG_EXECUTE_CONTRACT_TYPE_URL.to_string(), + value: msg_bytes, + }; + + let tx_body = Body::new(vec![msg_any], params.memo, 0u16); + + let fee_coin = parse_gas_prices_to_fee_coin(¶ms.gas_prices, params.gas_limit)?; + + let verifying_key = k256::ecdsa::VerifyingKey::from_sec1_bytes(public_key) + .map_err(|e| anyhow!("invalid signer public key: {e}"))?; + let pubkey = cosmrs::crypto::PublicKey::from(verifying_key); + + let signer_info = SignerInfo::single_direct(Some(pubkey), params.sequence); + + let auth_info = signer_info.auth_info(Fee::from_amount_and_gas(fee_coin, params.gas_limit)); + + let chain_id = params + .chain_id + .parse() + .map_err(|e| anyhow!("invalid chain_id {}: {e}", params.chain_id))?; + + let sign_doc = SignDoc::new(&tx_body, &auth_info, &chain_id, params.account_number) + .map_err(|e| anyhow!("failed to create SignDoc: {e}"))?; + + let sign_doc_bytes = sign_doc + .clone() + .into_bytes() + .map_err(|e| anyhow!("failed to serialize SignDoc: {e}"))?; + + Ok((sign_doc_bytes, sign_doc)) +} + +pub fn finalize_secret_tx(sign_doc: SignDoc, signature: &[u8]) -> Result> { + if signature.len() < 64 { + return Err(anyhow!( + "invalid signature length: expected at least 64 bytes, got {}", + signature.len() + )); + } + + let proto = sign_doc.into_proto(); + let tx_raw = cosmrs::proto::cosmos::tx::v1beta1::TxRaw { + body_bytes: proto.body_bytes, + auth_info_bytes: proto.auth_info_bytes, + signatures: vec![signature[..64].to_vec()], + }; + + Ok(tx_raw.encode_to_vec()) +} + +fn offline_encrypt_secret_message( + plaintext: &[u8], + receiver_pubkey: &PublicKey, +) -> Result> { + let mut tx_sender_privkey = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut tx_sender_privkey); + let tx_sender_static = StaticSecret::from(tx_sender_privkey); + let tx_sender_pubkey = PublicKey::from(&tx_sender_static); + + let mut nonce = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut nonce); + + let tx_encryption_ikm = tx_sender_static.diffie_hellman(receiver_pubkey); + + let mut ikm = Vec::with_capacity(32 + 32); + ikm.extend_from_slice(tx_encryption_ikm.as_bytes()); + ikm.extend_from_slice(&nonce); + + let hk = Hkdf::::new(Some(&HKDF_SALT), &ikm); + let mut okm = [0u8; 32]; + hk.expand(&[], &mut okm) + .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; + + let aes_encryption_key = okm; // 32 bytes + // The Go implementation calls: cipher.Seal(nil, plaintext, []byte{}) + // This passes ONE variadic arg (an empty slice) as associated data. + // S2V with [empty] != S2V with [] — they produce different SIV tags. + let empty: &[u8] = &[]; + let associated_data: &[&[u8]] = &[empty]; + + let ciphertext = encrypt_data_cmac_siv(&aes_encryption_key, plaintext, associated_data)?; + + let mut out = Vec::with_capacity(32 + 32 + ciphertext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(tx_sender_pubkey.as_bytes()); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +fn encrypt_data_cmac_siv( + aes_encryption_key: &[u8; 32], + plaintext: &[u8], + associated_data: &[&[u8]], +) -> Result> { + let (cmac_key, ctr_key) = aes_encryption_key.split_at(16); + let cmac_key: &[u8; 16] = cmac_key.try_into().expect("16 bytes"); + let ctr_key: &[u8; 16] = ctr_key.try_into().expect("16 bytes"); + + let siv_tag = s2v_aes_cmac_siv(cmac_key, associated_data, plaintext)?; + + // SIV => CTR IV with top bits cleared in last two 32-bit words. + let mut ctr_iv = siv_tag; + zero_iv_bits(&mut ctr_iv); + + // Encrypt with AES-CTR, producing ciphertext = siv_tag || encrypted(plaintext) + let mut buf = plaintext.to_vec(); + let mut stream_cipher = Ctr128BE::::new_from_slices(ctr_key, &ctr_iv) + .map_err(|e| anyhow!("failed to create AES-CTR cipher: {e}"))?; + stream_cipher.apply_keystream(&mut buf); + + let mut out = Vec::with_capacity(16 + buf.len()); + out.extend_from_slice(&siv_tag); + out.extend_from_slice(&buf); + + Ok(out) +} + +fn zero_iv_bits(iv: &mut [u8; 16]) { + // "We zero-out the top bit in each of the last two 32-bit words of the IV" + // — http://web.cs.ucdavis.edu/~rogaway/papers/siv.pdf + iv[16 - 8] &= 0x7f; + iv[16 - 4] &= 0x7f; +} + +fn s2v_aes_cmac_siv( + cmac_key: &[u8; 16], + associated_data_items: &[&[u8]], + plaintext: &[u8], +) -> Result<[u8; 16]> { + // Port of miscreant.go's Cipher.s2v for AES-CMAC-SIV-256. + // block size is 16. + let block_size = 16usize; + let zeros = [0u8; 16]; + let mut d = cmac_digest(cmac_key, &zeros)?; + + for v in associated_data_items { + let tmp = cmac_digest(cmac_key, v)?; + d = dbl_128(&d); + xor_inplace(&mut d, &tmp); + } + + if plaintext.len() >= block_size { + let n = plaintext.len() - block_size; + let mut mac = cmac_init(cmac_key)?; + mac.update(&plaintext[..n]); + + let mut tmp = [0u8; 16]; + tmp.copy_from_slice(&plaintext[n..]); + xor_inplace(&mut tmp, &d); + mac.update(&tmp); + + let result = mac.finalize().into_bytes(); + Ok(result.into()) + } else { + let mut tmp = [0u8; 16]; + tmp[..plaintext.len()].copy_from_slice(plaintext); + tmp[plaintext.len()] = 0x80; + let d2 = dbl_128(&d); + xor_inplace(&mut tmp, &d2); + + let mut mac = cmac_init(cmac_key)?; + mac.update(&tmp); + let result = mac.finalize().into_bytes(); + Ok(result.into()) + } +} + +fn xor_inplace(a: &mut [u8; 16], b: &[u8; 16]) { + for i in 0..16 { + a[i] ^= b[i]; + } +} + +fn dbl_128(d: &[u8; 16]) -> [u8; 16] { + // CMAC subkey doubling in GF(2^128) with Rb=0x87. + let msb = (d[0] & 0x80) != 0; + let mut out = [0u8; 16]; + + let mut carry = 0u8; + for i in (0..16).rev() { + out[i] = (d[i] << 1) | carry; + carry = if (d[i] & 0x80) != 0 { 1 } else { 0 }; + } + + if msb { + out[15] ^= 0x87; + } + out +} + +fn cmac_init(key: &[u8; 16]) -> Result> { + let mac = Cmac::::new_from_slice(key).map_err(|e| anyhow!("CMAC init failed: {e}"))?; + Ok(mac) +} + +fn cmac_digest(key: &[u8; 16], data: &[u8]) -> Result<[u8; 16]> { + let mut mac = cmac_init(key)?; + mac.update(data); + let result = mac.finalize().into_bytes(); + Ok(result.into()) +} + +fn parse_gas_prices_to_fee_coin(gas_prices: &str, gas_limit: u64) -> Result { + let gas_prices = gas_prices.trim(); + if gas_prices.is_empty() { + return Err(anyhow!("gas_prices is empty")); + } + + // Split numeric part vs denom part. + let mut idx = 0usize; + for (i, ch) in gas_prices.char_indices() { + if ch.is_ascii_digit() || ch == '.' { + idx = i + ch.len_utf8(); + } else { + break; + } + } + if idx == 0 || idx >= gas_prices.len() { + return Err(anyhow!("invalid gas_prices format: {gas_prices}")); + } + let (num_str, denom) = gas_prices.split_at(idx); + let denom = denom.to_string(); + + let (int_part, frac_part) = if let Some(dot) = num_str.find('.') { + (&num_str[..dot], &num_str[dot + 1..]) + } else { + (num_str, "") + }; + + let scale: u128 = if frac_part.is_empty() { + 1 + } else { + 10u128.pow(frac_part.len() as u32) + }; + + let int_part_val: u128 = if int_part.is_empty() { + 0 + } else { + int_part.parse()? + }; + let frac_part_val: u128 = if frac_part.is_empty() { + 0 + } else { + frac_part.parse()? + }; + + let amount_scaled = int_part_val + .checked_mul(scale) + .ok_or_else(|| anyhow!("gas_prices amount overflow"))? + .checked_add(frac_part_val) + .ok_or_else(|| anyhow!("gas_prices amount overflow"))?; + + let fee_scaled = amount_scaled + .checked_mul(gas_limit as u128) + .ok_or_else(|| anyhow!("fee overflow"))?; + let fee_amount = fee_scaled.div_ceil(scale); + + let coin = cosmrs::Coin { + denom: denom + .parse() + .map_err(|e| anyhow!("invalid denom in gas_prices: {e}"))?, + amount: fee_amount, + }; + + Ok(coin) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_acc_address_bech32_length() { + // This sample is expected to fail decoding. + assert!(decode_acc_address_bech32("secret1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3d4r").is_err()); + } + + #[test] + fn test_secret_execute_msg_json_shape() { + let symbols = vec!["BTC".to_string(), "ETH".to_string()]; + let rates = vec![67758920310332u64, 1410834569u64]; + let json_bz = secret_execute_msg_json(symbols, rates, 10, 20).unwrap(); + let s = str::from_utf8(&json_bz).unwrap(); + assert!(s.contains("\"relay\"")); + assert!(s.contains("\"symbols\"")); + assert!(s.contains("\"rates\"")); + // resolve_time and request_id must be JSON numbers, not strings + assert!(s.contains("\"resolve_time\":10")); + assert!(s.contains("\"request_id\":20")); + } + + #[test] + fn test_parse_gas_prices_rounding() { + // 0.025 * 10 = 0.25 -> should round up to 1 + let coin = parse_gas_prices_to_fee_coin("0.025uscrt", 10).unwrap(); + assert_eq!(coin.amount, 1); + assert_eq!(coin.denom.to_string(), "uscrt"); + + // 0.001 * 500 = 0.5 -> should round up to 1 + let coin = parse_gas_prices_to_fee_coin("0.001uscrt", 500).unwrap(); + assert_eq!(coin.amount, 1); + + // 0.15 * 10 = 1.5 -> should round up to 2 + let coin = parse_gas_prices_to_fee_coin("0.15uscrt", 10).unwrap(); + assert_eq!(coin.amount, 2); + + // Exact integer case: 0.1 * 10 = 1.0 -> should be 1 + let coin = parse_gas_prices_to_fee_coin("0.1uscrt", 10).unwrap(); + assert_eq!(coin.amount, 1); + } + + #[test] + fn test_encrypt_secret_execute_msg_code_hash_handling() { + let code_hash = "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + let pubkey_hex = "415082ba441584c017f8a75e3328229e64a4d656f4e1f727bcaadc3b7e71612a"; + let msg = b"{}"; + + // Should succeed with 0x prefix + let result = encrypt_secret_execute_msg(code_hash, pubkey_hex, msg); + assert!(result.is_ok(), "failed with 0x prefix: {:?}", result.err()); + + // Should succeed without 0x prefix + let result = encrypt_secret_execute_msg(&code_hash[2..], pubkey_hex, msg); + assert!( + result.is_ok(), + "failed without 0x prefix: {:?}", + result.err() + ); + + // Should fail with wrong length (too short) + let result = encrypt_secret_execute_msg("deadbeef", pubkey_hex, msg); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid code_hash length"), + "expected length error" + ); + + // Should fail with correct length (64 chars) but non-hex characters + // 63 valid hex chars + 'g' (non-hex) + let non_hex_hash = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1g"; + assert_eq!(non_hex_hash.len(), 64); + let result = encrypt_secret_execute_msg(non_hex_hash, pubkey_hex, msg); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid code_hash hex"), + "expected hex char error" + ); + } + + #[test] + fn test_siv_associated_data_binding() { + let key = [0u8; 32]; + let plaintext = b"hello world"; + + let ad1 = [b"nonce1".as_slice(), b"pubkey1".as_slice()]; + let ad2 = [b"nonce2".as_slice(), b"pubkey1".as_slice()]; // Different nonce + let ad3 = [b"nonce1".as_slice(), b"pubkey2".as_slice()]; // Different pubkey + + let ct1 = encrypt_data_cmac_siv(&key, plaintext, &ad1).unwrap(); + let ct2 = encrypt_data_cmac_siv(&key, plaintext, &ad2).unwrap(); + let ct3 = encrypt_data_cmac_siv(&key, plaintext, &ad3).unwrap(); + + // Ciphertexts should be different because AD is different + assert_ne!(ct1, ct2, "Ciphertexts should differ with different nonce"); + assert_ne!(ct1, ct3, "Ciphertexts should differ with different pubkey"); + assert_ne!(ct2, ct3, "Ciphertexts should differ with both different"); + + // The first 16 bytes are the SIV tag; they must differ + assert_ne!( + &ct1[..16], + &ct2[..16], + "SIV tags should differ with different nonce" + ); + assert_ne!( + &ct1[..16], + &ct3[..16], + "SIV tags should differ with different pubkey" + ); + } +} diff --git a/src/config/signer/local.rs b/src/config/signer/local.rs index b839019..3abca22 100644 --- a/src/config/signer/local.rs +++ b/src/config/signer/local.rs @@ -36,4 +36,5 @@ pub enum ChainType { Icon, Flow, Soroban, + Secret, } diff --git a/src/server/service.rs b/src/server/service.rs index 3c0bff0..4317403 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -1,3 +1,7 @@ +use crate::codec::cosmwasm_secret::{ + SignSecretTxParams, encrypt_secret_execute_msg, finalize_secret_tx, + prepare_secret_tx_for_signing, secret_execute_msg_json, +}; use crate::codec::evm::decode_tx; use crate::codec::flow; use crate::codec::icon::{ @@ -12,12 +16,13 @@ use crate::proto::fkms::v1::ChainType as proto_chain_type; use crate::proto::fkms::v1::fkms_service_server::FkmsService; use crate::proto::fkms::v1::{ GetSignerAddressesRequest, GetSignerAddressesResponse, SignEvmRequest, SignEvmResponse, - SignFlowRequest, SignFlowResponse, SignIconRequest, SignIconResponse, SignSorobanRequest, - SignSorobanResponse, SignXrplRequest, SignXrplResponse, Signers, + SignFlowRequest, SignFlowResponse, SignIconRequest, SignIconResponse, SignSecretRequest, + SignSecretResponse, SignSorobanRequest, SignSorobanResponse, SignXrplRequest, SignXrplResponse, + Signers, }; use crate::server::Server; use crate::server::utils::filter_usd_signal; -use k256::sha2::Sha512; +use k256::sha2::{Sha256, Sha512}; use sha3::{Digest, Sha3_256}; use std::collections::HashMap; use tonic::{Request, Response, Status}; @@ -470,6 +475,101 @@ impl FkmsService for Server { } } + #[instrument(skip(self, request))] + async fn sign_secret( + &self, + request: Request, + ) -> Result, Status> { + let sign_secret_request = request.into_inner(); + + let signer_payload = sign_secret_request.signer_payload.ok_or_else(|| { + Status::invalid_argument("signer_payload field is required and cannot be null") + })?; + let tss = sign_secret_request + .tss + .ok_or_else(|| Status::invalid_argument("tss field is required and cannot be null"))?; + + // verify tss signature + if let Some(verifier) = &self.tss_signature_verifier { + verifier + .verify_signature(&tss.message, &tss.random_addr, &tss.signature_s) + .map_err(|e| { + error!("failed to verify tss message: {:?}", e); + Status::invalid_argument(format!("Failed to verify tss signature: {e}")) + })?; + } + + let decoded_tss_message = decode_tss_message(&tss.message) + .map_err(|e| Status::internal(format!("Failed to decode TSS message: {e}")))?; + let tunnel_packet = decoded_tss_message.packet; + + for hook in &self.pre_sign_hooks { + hook.call(&tunnel_packet).await?; + } + + match self + .signers + .get(&(ChainType::Secret, signer_payload.sender.clone())) + { + Some(signer) => { + let (symbols, rates): (Vec, Vec) = tunnel_packet + .signals + .iter() + .filter_map(filter_usd_signal) + .unzip(); + + let resolve_time = u64::try_from(tunnel_packet.timestamp) + .map_err(|_| Status::invalid_argument("Timestamp must be non-negative"))?; + let request_id = tunnel_packet.sequence; + + let execute_msg_json = + secret_execute_msg_json(symbols, rates, resolve_time, request_id).map_err( + |e| Status::internal(format!("Failed to build execute msg json: {e}")), + )?; + + let encrypted_execute_msg = encrypt_secret_execute_msg( + &signer_payload.code_hash, + &signer_payload.chain_pubkey, + &execute_msg_json, + ) + .map_err(|e| Status::internal(format!("Failed to encrypt execute msg: {e}")))?; + + let secret_params = SignSecretTxParams { + sender_address_bech32: signer_payload.sender.clone(), + contract_address_bech32: signer_payload.contract_address.clone(), + encrypted_execute_msg, + chain_id: signer_payload.chain_id.clone(), + account_number: signer_payload.account_number, + sequence: signer_payload.sequence, + gas_limit: signer_payload.gas_limit, + gas_prices: signer_payload.gas_prices.clone(), + memo: signer_payload.memo.clone(), + }; + + let (sign_doc_bytes, sign_doc) = + prepare_secret_tx_for_signing(signer.public_key(), secret_params).map_err( + |e| Status::internal(format!("Failed to prepare Secret tx: {e}")), + )?; + + let digest = Sha256::digest(&sign_doc_bytes); + let signature = signer.sign(&digest).await.map_err(|e| { + error!("failed to sign secret message: {:?}", e); + Status::internal(format!("Failed to sign message: {e}")) + })?; + + let tx_blob = finalize_secret_tx(sign_doc, &signature) + .map_err(|e| Status::internal(format!("Failed to finalize Secret tx: {e}")))?; + + info!("successfully signed secret cosmwasm message"); + Ok(Response::new(SignSecretResponse { tx_blob })) + } + None => { + warn!("no signer found for {}", signer_payload.sender); + Err(Status::not_found("Signer not found")) + } + } + } + #[instrument(skip(self, _request))] async fn get_signer_addresses( &self, @@ -494,6 +594,7 @@ impl FkmsService for Server { ChainType::Icon => proto_chain_type::Icon, ChainType::Flow => proto_chain_type::Flow, ChainType::Soroban => proto_chain_type::Soroban, + ChainType::Secret => proto_chain_type::Secret, }; Signers { chain_type: proto_ct as i32, diff --git a/src/signer.rs b/src/signer.rs index 982e320..7ab821d 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -11,6 +11,7 @@ use ripemd::Ripemd160; use sha3::Sha3_256; use crate::config::signer::local::ChainType; +use cosmrs::AccountId; #[async_trait::async_trait] pub trait Signer: Send + Sync + 'static { @@ -97,3 +98,31 @@ pub fn public_key_to_icon_address(public_key: &[u8]) -> anyhow::Result { // Icon address is the last 20 bytes of the Sha3_256 hash Ok(format!("hx{}", hex::encode(&hash[12..]))) } + +pub fn public_key_to_secret_address(public_key: &[u8]) -> anyhow::Result { + // Cosmos SDK account address for secp256k1 is: + // bech32(prefix, ripemd160(sha256(pubkey_bytes))) + // where pubkey_bytes is the compressed SEC1 encoded pubkey (33 bytes). + if public_key.len() != 33 { + return Err(anyhow!( + "Invalid public key length for Secret address. Expected 33 bytes, got {}", + public_key.len() + )); + } + + // For compressed SEC1 pubkeys, the first byte is 0x02 or 0x03. + if public_key[0] != 0x02 && public_key[0] != 0x03 { + return Err(anyhow!( + "Invalid public key SEC1 prefix for Secret address. Expected 0x02/0x03, got 0x{:02x}", + public_key[0] + )); + } + + let sha256 = Sha256::digest(public_key); + let account_id = Ripemd160::digest(sha256); + let acc_id_bytes: &[u8] = account_id.as_slice(); + // Secret Network typically uses the `secret` bech32 prefix. + let account_id = AccountId::new("secret", acc_id_bytes) + .map_err(|e| anyhow!("failed to create Secret AccountId: {e}"))?; + Ok(account_id.to_string()) +} diff --git a/src/signer/local.rs b/src/signer/local.rs index e349bc2..73b2178 100644 --- a/src/signer/local.rs +++ b/src/signer/local.rs @@ -2,7 +2,8 @@ use crate::config::signer::local::ChainType; use crate::signer::signature::Signature; use crate::signer::signature::ecdsa::{DerSignature, P256Signature}; use crate::signer::{ - Signer, public_key_to_evm_address, public_key_to_icon_address, public_key_to_xrpl_address, + Signer, public_key_to_evm_address, public_key_to_icon_address, public_key_to_secret_address, + public_key_to_xrpl_address, }; use ecdsa::SignatureSize; use ecdsa::hazmat::SignPrimitive; @@ -96,6 +97,12 @@ impl LocalSigner { public_key_address, ) } + ChainType::Secret => { + let signing_key = EcdsaSigningKey::from_slice(private_key)?; + let public_key = create_ecdsa_public_key(&signing_key, true); + let address = public_key_to_secret_address(&public_key)?; + (SigningKey::EcdsaK256(signing_key), public_key, address) + } }; Ok(LocalSigner { @@ -157,6 +164,7 @@ impl Signer for LocalSigner { ChainType::Icon => self.sign_ecdsa(message).await, ChainType::Flow => self.sign_p256(message).await, ChainType::Soroban => self.sign_ed25519(message).await, + ChainType::Secret => self.sign_ecdsa(message).await, } } @@ -198,7 +206,7 @@ mod test { use super::*; use crate::signer::Signer; use base32; - use k256::sha2::{Digest, Sha512}; + use k256::sha2::{Digest, Sha256, Sha512}; use sha3::Keccak256; #[tokio::test] @@ -359,4 +367,56 @@ mod test { let expected = "hx8521060f28fdedcc4e4544ee499008809d4c0322"; assert_eq!(address, expected); } + + #[test] + fn test_public_key_to_secret_address() { + let pk = hex::decode("031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f") + .unwrap(); + let address = public_key_to_secret_address(&pk).unwrap(); + let expected = "secret10xcqpzrky6eff2g52qdye53xkk9jxkvrr9w4al"; + assert_eq!(address, expected); + } + + #[test] + fn test_address_generation_secret() { + // Known test vector for secp256k1 (private key: 0x01*32) + let pk = hex::decode("0101010101010101010101010101010101010101010101010101010101010101") + .unwrap(); + let signer = LocalSigner::new(&pk, &ChainType::Secret, None).unwrap(); + + // Verify address derivation (Cosmos style with 'secret' prefix) + // PK 0x01... -> Pubkey 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f + // Ripemd160(Sha256(Pubkey)) -> 0021c25805dc8f352e698188619bc16940866509 + // Bech32("secret", ...) -> secret10xcqpzrky6eff2g52qdye53xkk9jxkvrr9w4al + assert_eq!( + signer.address(), + "secret10xcqpzrky6eff2g52qdye53xkk9jxkvrr9w4al" + ); + + // Validate public key is compressed (33 bytes) + let pubkey = signer.public_key(); + assert_eq!( + pubkey.len(), + 33, + "Secret public key must be compressed (33 bytes)" + ); + assert!( + pubkey[0] == 0x02 || pubkey[0] == 0x03, + "Secret public key must have compressed prefix (0x02 or 0x03)" + ); + } + + #[tokio::test] + async fn test_sign_ecdsa_secret() { + let pk = hex::decode("0101010101010101010101010101010101010101010101010101010101010101") + .unwrap(); + let signer = LocalSigner::new(&pk, &ChainType::Secret, None).unwrap(); + let message = b"Hello, Secret Network!"; + // use Sha256 for Cosmos/Secret pre-hashing + let digest = Sha256::digest(message); + let signature = signer.sign(&digest).await.unwrap(); + + // Secp256k1 recoverable signature (R || S || V) should be 65 bytes + assert_eq!(signature.len(), 65); + } }