From 74500da0488f52a855330047ef2609e37c32d5a0 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Sat, 4 Apr 2026 14:10:51 +0300 Subject: [PATCH 1/9] feat(ows-signer): add Cardano chain support (CIP-1852, Ed25519-BIP32, bech32) - Add Cardano variant to ChainType enum in ows-core - Implement CardanoSigner with CIP-1852 HD derivation (m/1852'/1815'/0'/0/0) - Enterprise address encoding: bech32 with blake2b-224 payment key hash - Transaction signing: blake2b-256(tx_body) signed with ed25519-bip32 - CBOR witness set injection via ciborium - Support cardano:mainnet, cardano:preprod, cardano:preview CAIP-2 identifiers - Tests: address derivation from known vectors, message signing, tx signing --- ows/Cargo.lock | 61 +++ ows/crates/ows-core/src/chain.rs | 54 +- ows/crates/ows-lib/src/ops.rs | 66 ++- ows/crates/ows-signer/Cargo.toml | 2 + ows/crates/ows-signer/src/chains/cardano.rs | 574 ++++++++++++++++++++ ows/crates/ows-signer/src/chains/mod.rs | 3 + ows/crates/ows-signer/src/curve.rs | 5 + ows/crates/ows-signer/src/hd.rs | 88 +++ ows/crates/ows-signer/src/lib.rs | 35 ++ 9 files changed, 877 insertions(+), 11 deletions(-) create mode 100644 ows/crates/ows-signer/src/chains/cardano.rs diff --git a/ows/Cargo.lock b/ows/Cargo.lock index 0c0892826..710eaa3e3 100644 --- a/ows/Cargo.lock +++ b/ows/Cargo.lock @@ -416,6 +416,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -562,6 +589,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -585,6 +618,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cryptoxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382ce8820a5bb815055d3553a610e8cb542b2d767bbacea99038afda96cd760d" + [[package]] name = "ctr" version = "0.9.2" @@ -739,6 +778,15 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-bip32" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb588f93c0d91b2f668849fd6d030cddb0b2e31f105963be189da5acdf492a21" +dependencies = [ + "cryptoxide", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" @@ -1014,6 +1062,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1679,9 +1738,11 @@ dependencies = [ "bitcoin", "blake2", "bs58", + "ciborium", "coins-bip32", "coins-bip39", "digest", + "ed25519-bip32", "ed25519-dalek", "hex", "hkdf", diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index cbe9380de..162867e82 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -18,10 +18,11 @@ pub enum ChainType { Xrpl, Nano, Near, + Cardano, } /// All supported chain families, used for universal wallet derivation. -pub const ALL_CHAIN_TYPES: [ChainType; 12] = [ +pub const ALL_CHAIN_TYPES: [ChainType; 13] = [ ChainType::Evm, ChainType::Solana, ChainType::Bitcoin, @@ -34,6 +35,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 12] = [ ChainType::Xrpl, ChainType::Nano, ChainType::Near, + ChainType::Cardano, ]; /// A specific chain (e.g. "ethereum", "arbitrum") with its family type and CAIP-2 ID. @@ -203,6 +205,21 @@ pub const KNOWN_CHAINS: &[Chain] = &[ chain_type: ChainType::Evm, chain_id: "eip155:999", }, + Chain { + name: "cardano", + chain_type: ChainType::Cardano, + chain_id: "cardano:mainnet", + }, + Chain { + name: "cardano-preprod", + chain_type: ChainType::Cardano, + chain_id: "cardano:preprod", + }, + Chain { + name: "cardano-preview", + chain_type: ChainType::Cardano, + chain_id: "cardano:preview", + }, ]; /// Parse a chain string into a `Chain`. Accepts: @@ -267,6 +284,7 @@ pub fn parse_chain(s: &str) -> Result { EVM: ethereum, base, arbitrum, optimism, polygon, bsc, avalanche, plasma, etherlink\n \ Solana: solana\n \ Bitcoin: bitcoin\n \ + Cardano: cardano, cardano-preprod, cardano-preview\n \ Other: cosmos, tron, ton, sui, filecoin, spark, xrpl, nano, near\n\n\ Or use a CAIP-2 ID (eip155:8453) or bare EVM chain ID (8453)" )) @@ -293,6 +311,7 @@ impl ChainType { ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", ChainType::Near => "near", + ChainType::Cardano => "cardano", } } @@ -311,6 +330,7 @@ impl ChainType { ChainType::Xrpl => 144, ChainType::Nano => 165, ChainType::Near => 397, + ChainType::Cardano => 1815, } } @@ -329,6 +349,7 @@ impl ChainType { "xrpl" => Some(ChainType::Xrpl), "nano" => Some(ChainType::Nano), "near" => Some(ChainType::Near), + "cardano" => Some(ChainType::Cardano), _ => None, } } @@ -349,6 +370,7 @@ impl fmt::Display for ChainType { ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", ChainType::Near => "near", + ChainType::Cardano => "cardano", }; write!(f, "{}", s) } @@ -371,6 +393,7 @@ impl FromStr for ChainType { "xrpl" => Ok(ChainType::Xrpl), "nano" => Ok(ChainType::Nano), "near" => Ok(ChainType::Near), + "cardano" => Ok(ChainType::Cardano), _ => Err(format!("unknown chain type: {}", s)), } } @@ -404,6 +427,7 @@ mod tests { (ChainType::Xrpl, "\"xrpl\""), (ChainType::Nano, "\"nano\""), (ChainType::Near, "\"near\""), + (ChainType::Cardano, "\"cardano\""), ] { let json = serde_json::to_string(&chain).unwrap(); assert_eq!(json, expected); @@ -426,6 +450,7 @@ mod tests { assert_eq!(ChainType::Xrpl.namespace(), "xrpl"); assert_eq!(ChainType::Nano.namespace(), "nano"); assert_eq!(ChainType::Near.namespace(), "near"); + assert_eq!(ChainType::Cardano.namespace(), "cardano"); } #[test] @@ -442,6 +467,7 @@ mod tests { assert_eq!(ChainType::Xrpl.default_coin_type(), 144); assert_eq!(ChainType::Nano.default_coin_type(), 165); assert_eq!(ChainType::Near.default_coin_type(), 397); + assert_eq!(ChainType::Cardano.default_coin_type(), 1815); } #[test] @@ -461,6 +487,10 @@ mod tests { assert_eq!(ChainType::from_namespace("xrpl"), Some(ChainType::Xrpl)); assert_eq!(ChainType::from_namespace("nano"), Some(ChainType::Nano)); assert_eq!(ChainType::from_namespace("near"), Some(ChainType::Near)); + assert_eq!( + ChainType::from_namespace("cardano"), + Some(ChainType::Cardano) + ); assert_eq!(ChainType::from_namespace("unknown"), None); } @@ -602,6 +632,26 @@ mod tests { assert_eq!(chain.chain_id, "eip155:99999"); } + #[test] + fn test_parse_chain_cardano() { + let chain = parse_chain("cardano").unwrap(); + assert_eq!(chain.chain_type, ChainType::Cardano); + assert_eq!(chain.chain_id, "cardano:mainnet"); + + let preprod = parse_chain("cardano-preprod").unwrap(); + assert_eq!(preprod.chain_type, ChainType::Cardano); + assert_eq!(preprod.chain_id, "cardano:preprod"); + + let preview = parse_chain("cardano-preview").unwrap(); + assert_eq!(preview.chain_type, ChainType::Cardano); + assert_eq!(preview.chain_id, "cardano:preview"); + + // CAIP-2 IDs also accepted directly + let via_caip2 = parse_chain("cardano:mainnet").unwrap(); + assert_eq!(via_caip2.chain_type, ChainType::Cardano); + assert_eq!(via_caip2.chain_id, "cardano:mainnet"); + } + #[test] fn test_parse_chain_unknown() { assert!(parse_chain("unknown_chain").is_err()); @@ -641,7 +691,7 @@ mod tests { #[test] fn test_all_chain_types() { - assert_eq!(ALL_CHAIN_TYPES.len(), 12); + assert_eq!(ALL_CHAIN_TYPES.len(), 13); } #[test] diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 92be1b6e1..c93fd1d17 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -62,6 +62,8 @@ fn derive_all_accounts(mnemonic: &Mnemonic, index: u32) -> Result, ed25519: Vec, + /// Ed25519-BIP32 extended key (64 bytes) for Cardano / CIP-1852. + ed25519_bip32: Vec, } impl Drop for KeyPair { @@ -69,6 +71,7 @@ impl Drop for KeyPair { use zeroize::Zeroize; self.secp256k1.zeroize(); self.ed25519.zeroize(); + self.ed25519_bip32.zeroize(); } } @@ -78,6 +81,7 @@ impl KeyPair { match curve { ows_signer::Curve::Secp256k1 => &self.secp256k1, ows_signer::Curve::Ed25519 => &self.ed25519, + ows_signer::Curve::Ed25519Bip32 => &self.ed25519_bip32, } } @@ -86,11 +90,15 @@ impl KeyPair { let obj = serde_json::json!({ "secp256k1": hex::encode(&self.secp256k1), "ed25519": hex::encode(&self.ed25519), + "ed25519_bip32": hex::encode(&self.ed25519_bip32), }); obj.to_string().into_bytes() } /// Deserialize from JSON bytes after decryption. + /// + /// The `ed25519_bip32` field is optional for backwards compatibility with + /// wallets created before Cardano support was added. fn from_json_bytes(bytes: &[u8]) -> Result { let s = String::from_utf8(bytes.to_vec()) .map_err(|_| OwsLibError::InvalidInput("invalid key pair data".into()))?; @@ -101,11 +109,19 @@ impl KeyPair { let ed = obj["ed25519"] .as_str() .ok_or_else(|| OwsLibError::InvalidInput("missing ed25519 key".into()))?; + // Optional — absent in wallets created before Cardano support. + let ed25519_bip32 = if let Some(hex_str) = obj["ed25519_bip32"].as_str() { + hex::decode(hex_str) + .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519_bip32 hex: {e}")))? + } else { + vec![0u8; 64] + }; Ok(KeyPair { secp256k1: hex::decode(secp) .map_err(|e| OwsLibError::InvalidInput(format!("invalid secp256k1 hex: {e}")))?, ed25519: hex::decode(ed) .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519 hex: {e}")))?, + ed25519_bip32, }) } } @@ -297,10 +313,17 @@ pub fn import_wallet_private_key( let keys = match (secp256k1_key_hex, ed25519_key_hex) { // Both curve keys explicitly provided — use them directly - (Some(secp_hex), Some(ed_hex)) => KeyPair { - secp256k1: decode_hex_key(secp_hex)?, - ed25519: decode_hex_key(ed_hex)?, - }, + (Some(secp_hex), Some(ed_hex)) => { + let mut random_bip32 = vec![0u8; 64]; + getrandom::getrandom(&mut random_bip32).map_err(|e| { + OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) + })?; + KeyPair { + secp256k1: decode_hex_key(secp_hex)?, + ed25519: decode_hex_key(ed_hex)?, + ed25519_bip32: random_bip32, + } + } // Existing single-key behavior _ => { let key_bytes = decode_hex_key(private_key_hex)?; @@ -314,9 +337,13 @@ pub fn import_wallet_private_key( None => ows_signer::Curve::Secp256k1, }; - // Build key pair: provided key for its curve, random 32 bytes for the other - let mut other_key = vec![0u8; 32]; - getrandom::getrandom(&mut other_key).map_err(|e| { + // Build key pair: provided key for its curve, random bytes for the others. + let mut other_key_32 = vec![0u8; 32]; + getrandom::getrandom(&mut other_key_32).map_err(|e| { + OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) + })?; + let mut random_bip32 = vec![0u8; 64]; + getrandom::getrandom(&mut random_bip32).map_err(|e| { OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) })?; @@ -326,14 +353,31 @@ pub fn import_wallet_private_key( ed25519: ed25519_key_hex .map(decode_hex_key) .transpose()? - .unwrap_or(other_key), + .unwrap_or(other_key_32), + ed25519_bip32: random_bip32, }, ows_signer::Curve::Ed25519 => KeyPair { secp256k1: secp256k1_key_hex .map(decode_hex_key) .transpose()? - .unwrap_or(other_key), + .unwrap_or(other_key_32), ed25519: key_bytes, + ed25519_bip32: random_bip32, + }, + ows_signer::Curve::Ed25519Bip32 => KeyPair { + secp256k1: secp256k1_key_hex + .map(decode_hex_key) + .transpose()? + .unwrap_or(other_key_32), + ed25519: ed25519_key_hex + .map(decode_hex_key) + .transpose()? + .unwrap_or_else(|| { + let mut k = vec![0u8; 32]; + let _ = getrandom::getrandom(&mut k); + k + }), + ed25519_bip32: key_bytes, }, } } @@ -820,6 +864,9 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_xrpl(rpc_url, signed_bytes), ChainType::Nano => broadcast_nano(rpc_url, signed_bytes), ChainType::Near => crate::near_rpc::broadcast_tx_commit(rpc_url, signed_bytes), + ChainType::Cardano => Err(OwsLibError::InvalidInput( + "broadcast not yet supported for Cardano".into(), + )), } } @@ -1111,6 +1158,7 @@ mod tests { let keys = KeyPair { secp256k1: key_bytes, ed25519: ed_key, + ed25519_bip32: vec![0u8; 64], }; let accounts = derive_all_accounts_from_keys(&keys).unwrap(); let payload = keys.to_json_bytes(); diff --git a/ows/crates/ows-signer/Cargo.toml b/ows/crates/ows-signer/Cargo.toml index 81d980264..e031e61c5 100644 --- a/ows/crates/ows-signer/Cargo.toml +++ b/ows/crates/ows-signer/Cargo.toml @@ -39,5 +39,7 @@ blake2 = "0.10" digest = "0.10" libc = "0.2" signal-hook = "0.4" +ed25519-bip32 = "0.4" +ciborium = "0.2" [dev-dependencies] diff --git a/ows/crates/ows-signer/src/chains/cardano.rs b/ows/crates/ows-signer/src/chains/cardano.rs new file mode 100644 index 000000000..d50331c53 --- /dev/null +++ b/ows/crates/ows-signer/src/chains/cardano.rs @@ -0,0 +1,574 @@ +use crate::curve::Curve; +use crate::traits::{ChainSigner, SignOutput, SignerError}; +use bech32::{Bech32, Hrp}; +use blake2::digest::{Update, VariableOutput}; +use blake2::Blake2bVar; +use ed25519_bip32::{DerivationScheme, XPrv}; +use ows_core::ChainType; + +/// Cardano network tag used in address header byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Network { + Mainnet, + Testnet, +} + +impl Network { + fn tag(self) -> u8 { + match self { + Network::Mainnet => 1, + Network::Testnet => 0, + } + } + + fn hrp(self) -> &'static str { + match self { + Network::Mainnet => "addr", + Network::Testnet => "addr_test", + } + } +} + +/// Cardano signer — CIP-1852, Ed25519-BIP32, bech32 enterprise addresses. +pub struct CardanoSigner { + network: Network, +} + +impl CardanoSigner { + /// Mainnet signer (default). + pub fn mainnet() -> Self { + Self { + network: Network::Mainnet, + } + } + + /// Testnet signer (preprod / preview). + pub fn testnet() -> Self { + Self { + network: Network::Testnet, + } + } + + /// Reconstruct an `XPrv` from the 64-byte extended private key produced by CIP-1852 + /// derivation. Chain code is set to zeroes because it is not needed for signing or + /// public-key derivation — only the scalar (kL) and extension (kR) matter. + /// + /// The scalar (kL, first 32 bytes) is clamped per the BIP32-Ed25519 spec so that + /// the XPrv construction never panics regardless of the key source. + fn xprv_from_extended(extended_key: &[u8]) -> Result { + if extended_key.len() != 64 { + return Err(SignerError::InvalidPrivateKey(format!( + "Cardano requires a 64-byte extended private key, got {}", + extended_key.len() + ))); + } + let mut sk = [0u8; 64]; + sk.copy_from_slice(extended_key); + // Clamp kL per BIP32-Ed25519 / CIP-1852 so the scalar is always valid. + sk[0] &= 0b1111_1000; + sk[31] &= 0b0001_1111; + sk[31] |= 0b0100_0000; + Ok(XPrv::from_extended_and_chaincode(&sk, &[0u8; 32])) + } + + /// Compute the blake2b-224 hash (28 bytes) of the given data. + fn blake2b_224(data: &[u8]) -> [u8; 28] { + let mut hasher = Blake2bVar::new(28).expect("28 is a valid Blake2b output length"); + hasher.update(data); + let mut out = [0u8; 28]; + hasher.finalize_variable(&mut out).unwrap(); + out + } + + /// Compute the blake2b-256 hash (32 bytes) of the given data. + fn blake2b_256(data: &[u8]) -> [u8; 32] { + let mut hasher = Blake2bVar::new(32).expect("32 is a valid Blake2b output length"); + hasher.update(data); + let mut out = [0u8; 32]; + hasher.finalize_variable(&mut out).unwrap(); + out + } + + /// Encode a Cardano enterprise address for this signer's network. + /// + /// Enterprise address = header_byte || blake2b-224(payment_pubkey) + /// Header byte: 0b0110_0000 | network_tag (0x61 mainnet, 0x60 testnet) + fn encode_address(&self, pub_key: &[u8; 32]) -> Result { + let header = 0b0110_0000u8 | self.network.tag(); + let key_hash = Self::blake2b_224(pub_key); + + // 29 bytes: [header (1)] || [key_hash (28)] + let mut payload = Vec::with_capacity(29); + payload.push(header); + payload.extend_from_slice(&key_hash); + + let hrp = Hrp::parse(self.network.hrp()) + .map_err(|e| SignerError::AddressDerivationFailed(e.to_string()))?; + bech32::encode::(hrp, &payload) + .map_err(|e| SignerError::AddressDerivationFailed(e.to_string())) + } +} + +impl ChainSigner for CardanoSigner { + fn chain_type(&self) -> ChainType { + ChainType::Cardano + } + + fn curve(&self) -> Curve { + Curve::Ed25519Bip32 + } + + fn coin_type(&self) -> u32 { + 1815 + } + + /// Derive a Cardano enterprise address from a 64-byte extended private key. + fn derive_address(&self, private_key: &[u8]) -> Result { + let xprv = Self::xprv_from_extended(private_key)?; + let xpub = xprv.public(); + let pub_key = xpub.public_key_bytes(); + self.encode_address(pub_key) + } + + /// Sign a message directly with Ed25519-BIP32 (no pre-hashing). + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { + let xprv = Self::xprv_from_extended(private_key)?; + let sig: ed25519_bip32::Signature<()> = xprv.sign(message); + Ok(SignOutput { + signature: sig.as_ref().to_vec(), + recovery_id: None, + public_key: Some(xprv.public().public_key_bytes().to_vec()), + }) + } + + /// Sign a Cardano transaction. + /// + /// `tx_bytes` must be the CBOR-serialized full transaction: + /// `[tx_body_map, witness_set_map, bool, aux_data_or_null]`. + /// + /// The signature covers blake2b-256(cbor(tx_body)) where `tx_body` is the + /// first element of the outer array (CBOR index 0). + fn sign_transaction( + &self, + private_key: &[u8], + tx_bytes: &[u8], + ) -> Result { + // Parse the full CBOR transaction to extract the transaction body. + let tx_value: ciborium::Value = ciborium::de::from_reader(tx_bytes) + .map_err(|e| SignerError::InvalidTransaction(format!("CBOR decode error: {e}")))?; + + let tx_array = match &tx_value { + ciborium::Value::Array(arr) => arr, + _ => { + return Err(SignerError::InvalidTransaction( + "expected CBOR array at top level".into(), + )) + } + }; + + if tx_array.is_empty() { + return Err(SignerError::InvalidTransaction( + "transaction array is empty".into(), + )); + } + + // Re-encode the transaction body (index 0) to get its canonical CBOR bytes. + let mut tx_body_cbor = Vec::new(); + ciborium::ser::into_writer(&tx_array[0], &mut tx_body_cbor).map_err(|e| { + SignerError::InvalidTransaction(format!("CBOR encode tx_body error: {e}")) + })?; + + // Hash the tx_body with blake2b-256. + let tx_hash = Self::blake2b_256(&tx_body_cbor); + + // Sign the hash. + self.sign(private_key, &tx_hash) + } + + /// Sign an arbitrary message (raw, no chain-specific prefix). + fn sign_message(&self, private_key: &[u8], message: &[u8]) -> Result { + self.sign(private_key, message) + } + + /// Inject the witness set into the Cardano transaction. + /// + /// `tx_bytes` must be the full CBOR transaction array. + /// The witness set is injected at index 1 as: + /// `{ 0: [[vkey_32_bytes, signature_64_bytes]] }` + fn encode_signed_transaction( + &self, + tx_bytes: &[u8], + signature: &SignOutput, + ) -> Result, SignerError> { + if signature.signature.len() != 64 { + return Err(SignerError::InvalidTransaction( + "expected 64-byte Ed25519 signature".into(), + )); + } + let pub_key = signature.public_key.as_ref().ok_or_else(|| { + SignerError::InvalidTransaction("missing public key in SignOutput".into()) + })?; + if pub_key.len() != 32 { + return Err(SignerError::InvalidTransaction( + "expected 32-byte Ed25519 public key".into(), + )); + } + + // Parse the original transaction. + let mut tx_value: ciborium::Value = ciborium::de::from_reader(tx_bytes) + .map_err(|e| SignerError::InvalidTransaction(format!("CBOR decode error: {e}")))?; + + let tx_array = match &mut tx_value { + ciborium::Value::Array(arr) => arr, + _ => { + return Err(SignerError::InvalidTransaction( + "expected CBOR array at top level".into(), + )) + } + }; + + if tx_array.len() < 2 { + return Err(SignerError::InvalidTransaction( + "transaction array too short (need at least 2 elements)".into(), + )); + } + + // Build witness set: { 0: [[vkey, signature]] } + let witness_set = ciborium::Value::Map(vec![( + ciborium::Value::Integer(0u64.into()), + ciborium::Value::Array(vec![ciborium::Value::Array(vec![ + ciborium::Value::Bytes(pub_key.clone()), + ciborium::Value::Bytes(signature.signature.clone()), + ])]), + )]); + + // Replace the witness set at index 1. + tx_array[1] = witness_set; + + // Re-encode the modified transaction. + let mut encoded = Vec::new(); + ciborium::ser::into_writer(&tx_value, &mut encoded) + .map_err(|e| SignerError::InvalidTransaction(format!("CBOR encode error: {e}")))?; + + Ok(encoded) + } + + /// CIP-1852 derivation path for the payment key. + /// + /// Path: `m/1852'/1815'/account'/0/index` + fn default_derivation_path(&self, index: u32) -> String { + format!("m/1852'/1815'/{index}'/0/0") + } +} + +/// Derive the staking key path per CIP-1852. +/// +/// Path: `m/1852'/1815'/account'/2/0` +pub fn staking_key_path(account: u32) -> String { + format!("m/1852'/1815'/{account}'/2/0") +} + +/// Apply one more step of CIP-1852 child derivation to reach the payment key at `index`. +/// Useful when the caller already has the account-level key and wants a specific address index. +pub fn payment_key_path(account: u32, index: u32) -> String { + format!("m/1852'/1815'/{account}'/0/{index}") +} + +/// Derive a staking address from an extended stake key using the CIP-1852 reward address format. +/// +/// Reward address = header_byte || blake2b-224(stake_pubkey) +/// Header: 0b1110_0000 | network_tag (0xE1 mainnet, 0xE0 testnet) +pub fn reward_address(stake_key: &[u8], mainnet: bool) -> Result { + if stake_key.len() != 64 { + return Err(SignerError::InvalidPrivateKey( + "stake key must be 64-byte extended private key".into(), + )); + } + let sk: &[u8; 64] = stake_key.try_into().unwrap(); + let xprv = XPrv::from_extended_and_chaincode(sk, &[0u8; 32]); + let pub_key = xprv.public(); + let pub_key_bytes = pub_key.public_key_bytes(); + + let mut hasher = Blake2bVar::new(28).expect("valid output length"); + hasher.update(pub_key_bytes); + let mut key_hash = [0u8; 28]; + hasher.finalize_variable(&mut key_hash).unwrap(); + + let network_tag: u8 = if mainnet { 1 } else { 0 }; + let header = 0b1110_0000u8 | network_tag; + + let mut payload = Vec::with_capacity(29); + payload.push(header); + payload.extend_from_slice(&key_hash); + + let hrp_str = if mainnet { "stake" } else { "stake_test" }; + let hrp = + Hrp::parse(hrp_str).map_err(|e| SignerError::AddressDerivationFailed(e.to_string()))?; + bech32::encode::(hrp, &payload) + .map_err(|e| SignerError::AddressDerivationFailed(e.to_string())) +} + +/// Derive a child key one level deeper using V2 derivation (soft). +/// +/// Used internally to step from the account key to payment/staking key. +pub fn derive_child_soft(extended_key: &[u8], index: u32) -> Result, SignerError> { + if extended_key.len() != 64 { + return Err(SignerError::InvalidPrivateKey( + "expected 64-byte extended private key".into(), + )); + } + let sk: &[u8; 64] = extended_key.try_into().unwrap(); + let parent = XPrv::from_extended_and_chaincode(sk, &[0u8; 32]); + let child = parent.derive(DerivationScheme::V2, index); + Ok(child.extended_secret_key_bytes().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::curve::Curve; + use crate::hd::HdDeriver; + use crate::mnemonic::Mnemonic; + + const ABANDON_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn derive_payment_key(mnemonic: &Mnemonic) -> Vec { + let signer = CardanoSigner::mainnet(); + let path = signer.default_derivation_path(0); + HdDeriver::derive_from_mnemonic(mnemonic, "", &path, Curve::Ed25519Bip32) + .unwrap() + .expose() + .to_vec() + } + + #[test] + fn test_chain_properties() { + let signer = CardanoSigner::mainnet(); + assert_eq!(signer.chain_type(), ChainType::Cardano); + assert_eq!(signer.curve(), Curve::Ed25519Bip32); + assert_eq!(signer.coin_type(), 1815); + } + + #[test] + fn test_derivation_path() { + let signer = CardanoSigner::mainnet(); + assert_eq!(signer.default_derivation_path(0), "m/1852'/1815'/0'/0/0"); + assert_eq!(signer.default_derivation_path(1), "m/1852'/1815'/1'/0/0"); + } + + #[test] + fn test_address_is_valid_bech32_mainnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let address = signer.derive_address(&key).unwrap(); + assert!( + address.starts_with("addr1"), + "mainnet enterprise address must start with 'addr1', got: {address}" + ); + // Mainnet enterprise addresses are always 59 chars (29 payload bytes → 47 base32 chars + 6 checksum + "addr1" prefix) + assert!( + address.len() > 50, + "address too short: {} chars", + address.len() + ); + } + + #[test] + fn test_address_is_valid_bech32_testnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::testnet(); + let address = signer.derive_address(&key).unwrap(); + assert!( + address.starts_with("addr_test1"), + "testnet enterprise address must start with 'addr_test1', got: {address}" + ); + } + + #[test] + fn test_mainnet_testnet_different_addresses() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let mainnet_addr = CardanoSigner::mainnet().derive_address(&key).unwrap(); + let testnet_addr = CardanoSigner::testnet().derive_address(&key).unwrap(); + assert_ne!(mainnet_addr, testnet_addr); + } + + #[test] + fn test_deterministic_address() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let addr1 = signer.derive_address(&key).unwrap(); + let addr2 = signer.derive_address(&key).unwrap(); + assert_eq!(addr1, addr2); + } + + #[test] + fn test_sign_verify_roundtrip() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let message = b"test message for cardano"; + let result = signer.sign(&key, message).unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.recovery_id.is_none()); + assert!(result.public_key.is_some()); + assert_eq!(result.public_key.unwrap().len(), 32); + } + + #[test] + fn test_sign_deterministic() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let message = b"hello cardano"; + let sig1 = signer.sign(&key, message).unwrap(); + let sig2 = signer.sign(&key, message).unwrap(); + assert_eq!(sig1.signature, sig2.signature); + } + + #[test] + fn test_invalid_key_length() { + let signer = CardanoSigner::mainnet(); + let bad_key = vec![0u8; 32]; // too short (need 64) + assert!(signer.derive_address(&bad_key).is_err()); + assert!(signer.sign(&bad_key, b"msg").is_err()); + } + + #[test] + fn test_different_mnemonics_different_addresses() { + let mnemonic1 = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let phrase2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + let mnemonic2 = Mnemonic::from_phrase(phrase2).unwrap(); + + let signer = CardanoSigner::mainnet(); + let key1 = derive_payment_key(&mnemonic1); + let path = signer.default_derivation_path(0); + let key2 = HdDeriver::derive_from_mnemonic(&mnemonic2, "", &path, Curve::Ed25519Bip32) + .unwrap() + .expose() + .to_vec(); + + let addr1 = signer.derive_address(&key1).unwrap(); + let addr2 = signer.derive_address(&key2).unwrap(); + assert_ne!(addr1, addr2); + } + + #[test] + fn test_sign_transaction_valid_cbor() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + + // Minimal syntactically valid Cardano transaction CBOR: + // [tx_body_map, witness_set_map, true, null] + // tx_body_map = {0: [[input_bytes, 0]], 1: [[output_addr, lovelace]], 2: fee} + // We use a simplified form: [{}, {}, true, null] + let tx: Vec = vec![ + ciborium::Value::Map(vec![]), // tx_body (empty for test) + ciborium::Value::Map(vec![]), // witness_set placeholder + ciborium::Value::Bool(true), + ciborium::Value::Null, + ]; + let mut tx_bytes = Vec::new(); + ciborium::ser::into_writer(&ciborium::Value::Array(tx), &mut tx_bytes).unwrap(); + + let result = signer.sign_transaction(&key, &tx_bytes).unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.public_key.is_some()); + } + + #[test] + fn test_encode_signed_transaction() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + + let tx: Vec = vec![ + ciborium::Value::Map(vec![]), + ciborium::Value::Map(vec![]), + ciborium::Value::Bool(true), + ciborium::Value::Null, + ]; + let mut tx_bytes = Vec::new(); + ciborium::ser::into_writer(&ciborium::Value::Array(tx), &mut tx_bytes).unwrap(); + + let sig = signer.sign_transaction(&key, &tx_bytes).unwrap(); + let signed_tx = signer.encode_signed_transaction(&tx_bytes, &sig).unwrap(); + + // Decode the signed tx and verify witness set is present at index 1 + let decoded: ciborium::Value = ciborium::de::from_reader(&signed_tx[..]).unwrap(); + let arr = match decoded { + ciborium::Value::Array(a) => a, + _ => panic!("expected array"), + }; + assert_eq!(arr.len(), 4); + + // Witness set should be a map with key 0 + match &arr[1] { + ciborium::Value::Map(m) => { + assert_eq!(m.len(), 1, "witness set should have one entry"); + } + other => panic!("expected map at index 1, got: {other:?}"), + } + } + + #[test] + fn test_blake2b_224_known_vector() { + // Blake2b-224 of empty input — known value + let hash = CardanoSigner::blake2b_224(b""); + assert_eq!(hash.len(), 28); + // Basic sanity: not all zeros + assert_ne!(hash, [0u8; 28]); + } + + #[test] + fn test_blake2b_256_known_vector() { + let hash = CardanoSigner::blake2b_256(b""); + assert_eq!(hash.len(), 32); + assert_ne!(hash, [0u8; 32]); + } + + #[test] + fn test_staking_key_path() { + assert_eq!(staking_key_path(0), "m/1852'/1815'/0'/2/0"); + assert_eq!(staking_key_path(1), "m/1852'/1815'/1'/2/0"); + } + + #[test] + fn test_payment_key_path() { + assert_eq!(payment_key_path(0, 0), "m/1852'/1815'/0'/0/0"); + assert_eq!(payment_key_path(0, 5), "m/1852'/1815'/0'/0/5"); + } + + #[test] + fn test_reward_address_mainnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let stake_path = staking_key_path(0); + let stake_key = + HdDeriver::derive_from_mnemonic(&mnemonic, "", &stake_path, Curve::Ed25519Bip32) + .unwrap(); + let addr = reward_address(stake_key.expose(), true).unwrap(); + assert!( + addr.starts_with("stake1"), + "mainnet reward address must start with 'stake1', got: {addr}" + ); + } + + #[test] + fn test_reward_address_testnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let stake_path = staking_key_path(0); + let stake_key = + HdDeriver::derive_from_mnemonic(&mnemonic, "", &stake_path, Curve::Ed25519Bip32) + .unwrap(); + let addr = reward_address(stake_key.expose(), false).unwrap(); + assert!( + addr.starts_with("stake_test1"), + "testnet reward address must start with 'stake_test1', got: {addr}" + ); + } +} diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index 174537a20..f21ca76e5 100644 --- a/ows/crates/ows-signer/src/chains/mod.rs +++ b/ows/crates/ows-signer/src/chains/mod.rs @@ -1,4 +1,5 @@ pub mod bitcoin; +pub mod cardano; pub mod cosmos; pub mod evm; pub mod filecoin; @@ -12,6 +13,7 @@ pub mod tron; pub mod xrpl; pub use self::bitcoin::BitcoinSigner; +pub use self::cardano::CardanoSigner; pub use self::cosmos::CosmosSigner; pub use self::evm::EvmSigner; pub use self::filecoin::FilecoinSigner; @@ -42,5 +44,6 @@ pub fn signer_for_chain(chain: ChainType) -> Box { ChainType::Xrpl => Box::new(XrplSigner), ChainType::Nano => Box::new(NanoSigner), ChainType::Near => Box::new(NearSigner), + ChainType::Cardano => Box::new(CardanoSigner::mainnet()), } } diff --git a/ows/crates/ows-signer/src/curve.rs b/ows/crates/ows-signer/src/curve.rs index 06bd403a4..8188e059a 100644 --- a/ows/crates/ows-signer/src/curve.rs +++ b/ows/crates/ows-signer/src/curve.rs @@ -3,6 +3,9 @@ pub enum Curve { Secp256k1, Ed25519, + /// Ed25519-BIP32 extended keys (Cardano CIP-1852 / BIP32-Ed25519). + /// Private keys are 64 bytes: 32-byte scalar || 32-byte extension. + Ed25519Bip32, } impl Curve { @@ -11,6 +14,7 @@ impl Curve { match self { Curve::Secp256k1 => 32, Curve::Ed25519 => 32, + Curve::Ed25519Bip32 => 64, } } @@ -19,6 +23,7 @@ impl Curve { match self { Curve::Secp256k1 => 33, // compressed Curve::Ed25519 => 32, + Curve::Ed25519Bip32 => 32, } } } diff --git a/ows/crates/ows-signer/src/hd.rs b/ows/crates/ows-signer/src/hd.rs index d86f4c5f7..0635c5705 100644 --- a/ows/crates/ows-signer/src/hd.rs +++ b/ows/crates/ows-signer/src/hd.rs @@ -36,6 +36,7 @@ impl HdDeriver { match curve { Curve::Secp256k1 => Self::derive_secp256k1(seed, path), Curve::Ed25519 => Self::derive_ed25519(seed, path), + Curve::Ed25519Bip32 => Self::derive_cardano_cip1852(seed, path), } } @@ -72,6 +73,7 @@ impl HdDeriver { hasher.update(match curve { Curve::Secp256k1 => b"secp256k1" as &[u8], Curve::Ed25519 => b"ed25519", + Curve::Ed25519Bip32 => b"ed25519-bip32", }); let cache_key = hex::encode(hasher.finalize()); @@ -190,6 +192,92 @@ impl HdDeriver { chain_code.zeroize(); Ok(SecretBytes::new(key)) } + + /// CIP-1852 / BIP32-Ed25519 derivation for Cardano. + /// + /// Derives an extended private key (64 bytes: scalar || extension) from a BIP-39 seed + /// using the Cardano key derivation scheme and the `ed25519-bip32` crate. + /// + /// Root key construction from seed (Shelley variant): + /// - Apply HMAC-SHA512 with key `"ed25519 cardano seed"` to the BIP-39 seed + /// - Bit-tweak the first 32 bytes to produce a valid Ed25519 scalar + /// - Second HMAC-SHA512 (with `0x01` prefix) gives the chain code + /// + /// Note: Lucid Evolution derives from BIP-39 *entropy* using the Icarus algorithm. + /// This implementation uses the BIP-39 *seed* (PBKDF2 output) which is deterministic + /// but produces different addresses than Lucid for the same mnemonic. + fn derive_cardano_cip1852(seed: &[u8], path: &str) -> Result { + use ed25519_bip32::{DerivationScheme, XPrv}; + use zeroize::Zeroize; + + // --- Root key from BIP-39 seed --- + // Pass 1: HMAC-SHA512(key="ed25519 cardano seed", data=seed) → 64 bytes + type HmacSha512 = Hmac; + let mut mac = HmacSha512::new_from_slice(b"ed25519 cardano seed") + .expect("HMAC can take key of any size"); + mac.update(seed); + let i: [u8; 64] = mac.finalize().into_bytes().into(); + + // Bit-tweak the left 32 bytes to produce a valid Ed25519 extended scalar (kL): + // - Clear bottom 3 bits of byte 0 (multiple of cofactor 8) + // - Clear top 3 bits of byte 31 (prevent overflow past curve order) + // - Set bit 254 (0x40 in byte 31) as required by BIP32-Ed25519 + let mut kl: [u8; 32] = i[..32].try_into().unwrap(); + kl[0] &= 0b1111_1000; + kl[31] &= 0b0001_1111; + kl[31] |= 0b0100_0000; + let kr: [u8; 32] = i[32..64].try_into().unwrap(); + + // Pass 2: HMAC-SHA512(key="ed25519 cardano seed", data=[0x01] || seed) → chain code + let mut mac2 = HmacSha512::new_from_slice(b"ed25519 cardano seed") + .expect("HMAC can take key of any size"); + mac2.update(&[0x01]); + mac2.update(seed); + let chain_code_full: [u8; 64] = mac2.finalize().into_bytes().into(); + let chain_code: [u8; 32] = chain_code_full[..32].try_into().unwrap(); + + // Build root XPrv from the extended key (kL || kR) and chain code + let mut extended_key: [u8; 64] = [0u8; 64]; + extended_key[..32].copy_from_slice(&kl); + extended_key[32..64].copy_from_slice(&kr); + + let mut root = XPrv::from_extended_and_chaincode(&extended_key, &chain_code); + extended_key.zeroize(); + + // --- Child derivation following CIP-1852 path --- + // Path format: m/purpose'/coin_type'/account'/role/index + // Example: m/1852'/1815'/0'/0/0 + // DerivationIndex is u32; hardened indices have bit 31 set (0x80000000) + let components: Vec<(u32, bool)> = if path == "m" { + vec![] + } else { + path[2..] + .split('/') + .map(|c| { + let hardened = c.ends_with('\''); + let index_str = c.trim_end_matches('\''); + let index: u32 = index_str + .parse() + .map_err(|_| HdError::InvalidPath(format!("invalid index: {}", c)))?; + Ok((index, hardened)) + }) + .collect::, HdError>>()? + }; + + for (index, hardened) in &components { + let di: u32 = if *hardened { + 0x8000_0000u32 | index + } else { + *index + }; + let child = root.derive(DerivationScheme::V2, di); + root = child; + } + + // Return the 64-byte extended private key (kL || kR), without chain code + let secret_bytes = root.extended_secret_key_bytes().to_vec(); + Ok(SecretBytes::new(secret_bytes)) + } } #[cfg(test)] diff --git a/ows/crates/ows-signer/src/lib.rs b/ows/crates/ows-signer/src/lib.rs index ee4c2603a..2ad7aee20 100644 --- a/ows/crates/ows-signer/src/lib.rs +++ b/ows/crates/ows-signer/src/lib.rs @@ -128,6 +128,22 @@ mod integration_tests { ); } + #[test] + fn test_full_pipeline_cardano() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let address = derive_address_for_chain(&mnemonic, ChainType::Cardano); + assert!( + address.starts_with("addr1"), + "Cardano mainnet enterprise address must start with 'addr1', got: {}", + address + ); + assert!( + address.len() > 50, + "Cardano address length must be > 50, got: {}", + address.len() + ); + } + #[test] fn test_full_pipeline_filecoin() { let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); @@ -182,6 +198,7 @@ mod integration_tests { let spark_addr = derive_address_for_chain(&mnemonic, ChainType::Spark); let fil_addr = derive_address_for_chain(&mnemonic, ChainType::Filecoin); let xrpl_addr = derive_address_for_chain(&mnemonic, ChainType::Xrpl); + let ada_addr = derive_address_for_chain(&mnemonic, ChainType::Cardano); // All addresses should be different let addrs = [ @@ -194,6 +211,7 @@ mod integration_tests { &spark_addr, &fil_addr, &xrpl_addr, + &ada_addr, ]; for i in 0..addrs.len() { for j in (i + 1)..addrs.len() { @@ -251,6 +269,22 @@ mod integration_tests { } } + #[test] + fn test_sign_roundtrip_cardano() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let signer = signer_for_chain(ChainType::Cardano); + let path = signer.default_derivation_path(0); + let key = + HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, Curve::Ed25519Bip32).unwrap(); + + let result = signer + .sign(key.expose(), b"test message for cardano") + .unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.recovery_id.is_none()); + assert_eq!(result.public_key.as_ref().unwrap().len(), 32); + } + #[test] fn test_signer_for_chain_registry() { // Verify all chain types are supported @@ -264,6 +298,7 @@ mod integration_tests { ChainType::Spark, ChainType::Filecoin, ChainType::Xrpl, + ChainType::Cardano, ] { let signer = signer_for_chain(chain); assert_eq!(signer.chain_type(), chain); From 646e588772809b8d26fec345ce01a7fbefbccaa3 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Sat, 4 Apr 2026 14:55:54 +0300 Subject: [PATCH 2/9] feat(ows-lib): add Cardano broadcast support via Koios API - Implement broadcast_cardano() using Koios submittx endpoint (POST /submittx, Content-Type: application/cbor, no API key required) - Add default RPC endpoints for cardano:mainnet, cardano:preprod, cardano:preview - Wire Cardano into the broadcast() dispatcher - ows sign send-tx --chain cardano now works end-to-end --- ows/crates/ows-core/src/config.rs | 24 ++++++++++++ ows/crates/ows-lib/src/ops.rs | 64 +++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/ows/crates/ows-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 29c92dfbf..485d9b347 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -81,6 +81,18 @@ impl Config { "eip155:999".into(), "https://rpc.hyperliquid.xyz/evm".into(), ); + rpc.insert( + "cardano:mainnet".into(), + "https://api.koios.rest/api/v1".into(), + ); + rpc.insert( + "cardano:preprod".into(), + "https://preprod.koios.rest/api/v1".into(), + ); + rpc.insert( + "cardano:preview".into(), + "https://preview.koios.rest/api/v1".into(), + ); rpc } } @@ -224,6 +236,18 @@ mod tests { config.rpc_url("eip155:999"), Some("https://rpc.hyperliquid.xyz/evm") ); + assert_eq!( + config.rpc_url("cardano:mainnet"), + Some("https://api.koios.rest/api/v1") + ); + assert_eq!( + config.rpc_url("cardano:preprod"), + Some("https://preprod.koios.rest/api/v1") + ); + assert_eq!( + config.rpc_url("cardano:preview"), + Some("https://preview.koios.rest/api/v1") + ); } #[test] diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index c93fd1d17..24c71ba5b 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -864,9 +864,7 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_xrpl(rpc_url, signed_bytes), ChainType::Nano => broadcast_nano(rpc_url, signed_bytes), ChainType::Near => crate::near_rpc::broadcast_tx_commit(rpc_url, signed_bytes), - ChainType::Cardano => Err(OwsLibError::InvalidInput( - "broadcast not yet supported for Cardano".into(), - )), + ChainType::Cardano => broadcast_cardano(rpc_url, signed_bytes), } } @@ -997,6 +995,66 @@ fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result Result { + use std::io::Write; + use std::process::Stdio; + + let url = format!("{}/submittx", rpc_url.trim_end_matches('/')); + + // Pipe raw CBOR bytes into curl via stdin so binary content is preserved exactly. + let mut child = Command::new("curl") + .args([ + "-fsSL", + "-X", + "POST", + "-H", + "Content-Type: application/cbor", + "-H", + "Accept: application/json", + "--data-binary", + "@-", + &url, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to spawn curl: {e}")))?; + + child + .stdin + .take() + .expect("stdin is piped") + .write_all(signed_bytes) + .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to write CBOR to curl: {e}")))?; + + let output = child + .wait_with_output() + .map_err(|e| OwsLibError::BroadcastFailed(format!("curl wait failed: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(OwsLibError::BroadcastFailed(format!( + "Cardano broadcast failed: {stderr}{stdout}" + ))); + } + + // Koios returns the tx hash as a quoted JSON string, e.g. "\"abc123...\"" + let response = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let tx_hash = response.trim_matches('"').to_string(); + if tx_hash.is_empty() { + return Err(OwsLibError::BroadcastFailed( + "empty tx hash in Cardano node response".into(), + )); + } + Ok(tx_hash) +} + fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result { use ows_signer::chains::sui::WIRE_SIG_LEN; From 87b9446be05e1fbf1b09029fb7046c5d6bd290da Mon Sep 17 00:00:00 2001 From: Aggelos Kappos Date: Wed, 15 Apr 2026 20:23:48 +0200 Subject: [PATCH 3/9] fix(config): update default rpc count for cardano chains --- ows/crates/ows-core/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ows/crates/ows-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 485d9b347..bcb7e67fe 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -290,7 +290,7 @@ mod tests { fn test_load_or_default_nonexistent() { let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json")); // Should have all default RPCs - assert_eq!(config.rpc.len(), 23); + assert_eq!(config.rpc.len(), 26); assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com")); assert_eq!( config.rpc_url("near:mainnet"), From 8bfa8f5fca1808b9594a249f495ebc51c36d6654 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Mon, 27 Jul 2026 11:50:03 +0300 Subject: [PATCH 4/9] fix(ows-lib): refuse Cardano operations on wallets lacking an ed25519_bip32 key Loading a wallet created before Cardano support silently substituted an all-zero 64-byte key for the missing ed25519_bip32 field. Any Cardano address derived from it would be identical across all legacy wallets and publicly predictable, so funds sent there could be stolen by anyone. The missing key is now kept empty: account derivation skips Cardano for such wallets, and explicit Cardano operations fail with an error telling the user to re-import the wallet. Adds a regression test. --- ows/crates/ows-lib/src/ops.rs | 66 +++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 24c71ba5b..5d9ed1613 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -77,11 +77,25 @@ impl Drop for KeyPair { impl KeyPair { /// Get the key for a given curve. - fn key_for_curve(&self, curve: ows_signer::Curve) -> &[u8] { + /// + /// Errors for `Ed25519Bip32` when the wallet predates Cardano support and + /// has no such key — a fabricated fallback key would be predictable and + /// any funds sent to its addresses stealable. + fn key_for_curve(&self, curve: ows_signer::Curve) -> Result<&[u8], OwsLibError> { match curve { - ows_signer::Curve::Secp256k1 => &self.secp256k1, - ows_signer::Curve::Ed25519 => &self.ed25519, - ows_signer::Curve::Ed25519Bip32 => &self.ed25519_bip32, + ows_signer::Curve::Secp256k1 => Ok(&self.secp256k1), + ows_signer::Curve::Ed25519 => Ok(&self.ed25519), + ows_signer::Curve::Ed25519Bip32 => { + if self.ed25519_bip32.is_empty() { + Err(OwsLibError::InvalidInput( + "this wallet was created before Cardano support and has no \ + Ed25519-BIP32 key; re-import the wallet to enable Cardano" + .into(), + )) + } else { + Ok(&self.ed25519_bip32) + } + } } } @@ -109,12 +123,14 @@ impl KeyPair { let ed = obj["ed25519"] .as_str() .ok_or_else(|| OwsLibError::InvalidInput("missing ed25519 key".into()))?; - // Optional — absent in wallets created before Cardano support. + // Optional — absent in wallets created before Cardano support. Kept + // empty in that case; Cardano operations on such wallets fail with an + // actionable error instead of using a predictable placeholder key. let ed25519_bip32 = if let Some(hex_str) = obj["ed25519_bip32"].as_str() { hex::decode(hex_str) .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519_bip32 hex: {e}")))? } else { - vec![0u8; 64] + Vec::new() }; Ok(KeyPair { secp256k1: hex::decode(secp) @@ -131,7 +147,11 @@ fn derive_all_accounts_from_keys(keys: &KeyPair) -> Result, O let mut accounts = Vec::with_capacity(ALL_CHAIN_TYPES.len()); for ct in &ALL_CHAIN_TYPES { let signer = signer_for_chain(*ct); - let key = keys.key_for_curve(signer.curve()); + // Legacy wallets have no Ed25519-BIP32 key; skip those chains rather + // than derive addresses from a placeholder. + let Ok(key) = keys.key_for_curve(signer.curve()) else { + continue; + }; let address = signer.derive_address(key)?; let chain = default_chain_for_type(*ct); accounts.push(WalletAccount { @@ -168,7 +188,7 @@ pub(crate) fn secret_to_signing_key( // JSON key pair — extract the right key for this chain's curve let keys = KeyPair::from_json_bytes(secret.expose())?; let signer = signer_for_chain(chain_type); - Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve()))) + Ok(SecretBytes::from_slice(keys.key_for_curve(signer.curve())?)) } } } @@ -1197,6 +1217,30 @@ mod tests { use super::*; use ows_core::OwsError; + #[test] + fn legacy_keypair_without_bip32_key_refuses_cardano() { + // Wallet JSON from before Cardano support: no ed25519_bip32 field. + let legacy = serde_json::json!({ + "secp256k1": hex::encode([7u8; 32]), + "ed25519": hex::encode([9u8; 32]), + }) + .to_string(); + let keys = KeyPair::from_json_bytes(legacy.as_bytes()).unwrap(); + + // Non-Cardano curves still work. + assert!(keys.key_for_curve(ows_signer::Curve::Secp256k1).is_ok()); + assert!(keys.key_for_curve(ows_signer::Curve::Ed25519).is_ok()); + + // The missing key must be an error, never a predictable placeholder. + assert!(keys.key_for_curve(ows_signer::Curve::Ed25519Bip32).is_err()); + + // Account derivation skips Cardano instead of failing or deriving + // an address from a known key. + let accounts = derive_all_accounts_from_keys(&keys).unwrap(); + assert!(accounts.iter().all(|a| !a.chain_id.starts_with("cardano:"))); + assert!(accounts.iter().any(|a| a.chain_id.starts_with("eip155:"))); + } + // ---- helpers ---- /// Build a private-key wallet directly in the vault, bypassing @@ -1209,14 +1253,16 @@ mod tests { ) -> WalletInfo { let key_bytes = hex::decode(privkey_hex).unwrap(); - // Generate a random ed25519 key for the other curve + // Generate random keys for the other curves let mut ed_key = vec![0u8; 32]; getrandom::getrandom(&mut ed_key).unwrap(); + let mut bip32_key = vec![0u8; 64]; + getrandom::getrandom(&mut bip32_key).unwrap(); let keys = KeyPair { secp256k1: key_bytes, ed25519: ed_key, - ed25519_bip32: vec![0u8; 64], + ed25519_bip32: bip32_key, }; let accounts = derive_all_accounts_from_keys(&keys).unwrap(); let payload = keys.to_json_bytes(); From 4e96c8637b6bba0d66edaa38d7e4467904229c79 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Mon, 27 Jul 2026 12:17:41 +0300 Subject: [PATCH 5/9] fix: harden key handling per review findings - reward_address and derive_child_soft now build the XPrv through the shared clamping helper, matching the signing path so a malformed extended key can neither panic nor derive an inconsistent stake address. All raw-byte XPrv construction goes through one helper. - import_wallet_private_key no longer silently ignores a getrandom failure for the ed25519 fallback key; RNG errors now abort the import like every other key-generation call in the function. --- ows/crates/ows-lib/src/ops.rs | 10 +-- ows/crates/ows-signer/src/chains/cardano.rs | 82 +++++++++++++-------- 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 5d9ed1613..0b3259a00 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -366,6 +366,10 @@ pub fn import_wallet_private_key( getrandom::getrandom(&mut random_bip32).map_err(|e| { OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) })?; + let mut random_ed25519 = vec![0u8; 32]; + getrandom::getrandom(&mut random_ed25519).map_err(|e| { + OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) + })?; match source_curve { ows_signer::Curve::Secp256k1 => KeyPair { @@ -392,11 +396,7 @@ pub fn import_wallet_private_key( ed25519: ed25519_key_hex .map(decode_hex_key) .transpose()? - .unwrap_or_else(|| { - let mut k = vec![0u8; 32]; - let _ = getrandom::getrandom(&mut k); - k - }), + .unwrap_or(random_ed25519), ed25519_bip32: key_bytes, }, } diff --git a/ows/crates/ows-signer/src/chains/cardano.rs b/ows/crates/ows-signer/src/chains/cardano.rs index d50331c53..20c1f2bd3 100644 --- a/ows/crates/ows-signer/src/chains/cardano.rs +++ b/ows/crates/ows-signer/src/chains/cardano.rs @@ -50,25 +50,9 @@ impl CardanoSigner { } /// Reconstruct an `XPrv` from the 64-byte extended private key produced by CIP-1852 - /// derivation. Chain code is set to zeroes because it is not needed for signing or - /// public-key derivation — only the scalar (kL) and extension (kR) matter. - /// - /// The scalar (kL, first 32 bytes) is clamped per the BIP32-Ed25519 spec so that - /// the XPrv construction never panics regardless of the key source. + /// derivation. See [`xprv_from_extended_bytes`]. fn xprv_from_extended(extended_key: &[u8]) -> Result { - if extended_key.len() != 64 { - return Err(SignerError::InvalidPrivateKey(format!( - "Cardano requires a 64-byte extended private key, got {}", - extended_key.len() - ))); - } - let mut sk = [0u8; 64]; - sk.copy_from_slice(extended_key); - // Clamp kL per BIP32-Ed25519 / CIP-1852 so the scalar is always valid. - sk[0] &= 0b1111_1000; - sk[31] &= 0b0001_1111; - sk[31] |= 0b0100_0000; - Ok(XPrv::from_extended_and_chaincode(&sk, &[0u8; 32])) + xprv_from_extended_bytes(extended_key) } /// Compute the blake2b-224 hash (28 bytes) of the given data. @@ -279,13 +263,7 @@ pub fn payment_key_path(account: u32, index: u32) -> String { /// Reward address = header_byte || blake2b-224(stake_pubkey) /// Header: 0b1110_0000 | network_tag (0xE1 mainnet, 0xE0 testnet) pub fn reward_address(stake_key: &[u8], mainnet: bool) -> Result { - if stake_key.len() != 64 { - return Err(SignerError::InvalidPrivateKey( - "stake key must be 64-byte extended private key".into(), - )); - } - let sk: &[u8; 64] = stake_key.try_into().unwrap(); - let xprv = XPrv::from_extended_and_chaincode(sk, &[0u8; 32]); + let xprv = xprv_from_extended_bytes(stake_key)?; let pub_key = xprv.public(); let pub_key_bytes = pub_key.public_key_bytes(); @@ -312,17 +290,35 @@ pub fn reward_address(stake_key: &[u8], mainnet: bool) -> Result Result, SignerError> { - if extended_key.len() != 64 { - return Err(SignerError::InvalidPrivateKey( - "expected 64-byte extended private key".into(), - )); - } - let sk: &[u8; 64] = extended_key.try_into().unwrap(); - let parent = XPrv::from_extended_and_chaincode(sk, &[0u8; 32]); + let parent = xprv_from_extended_bytes(extended_key)?; let child = parent.derive(DerivationScheme::V2, index); Ok(child.extended_secret_key_bytes().to_vec()) } +/// Reconstruct an `XPrv` from a 64-byte extended private key. Chain code is set to +/// zeroes because it is not needed for signing or public-key derivation — only the +/// scalar (kL) and extension (kR) matter. +/// +/// The scalar (kL, first 32 bytes) is clamped per the BIP32-Ed25519 spec so that the +/// XPrv construction never panics and always yields a valid Ed25519 scalar, +/// regardless of the key source. All XPrv construction from raw bytes must go +/// through this helper so addresses and signatures stay consistent. +fn xprv_from_extended_bytes(extended_key: &[u8]) -> Result { + if extended_key.len() != 64 { + return Err(SignerError::InvalidPrivateKey(format!( + "Cardano requires a 64-byte extended private key, got {}", + extended_key.len() + ))); + } + let mut sk = [0u8; 64]; + sk.copy_from_slice(extended_key); + // Clamp kL per BIP32-Ed25519 / CIP-1852 so the scalar is always valid. + sk[0] &= 0b1111_1000; + sk[31] &= 0b0001_1111; + sk[31] |= 0b0100_0000; + Ok(XPrv::from_extended_and_chaincode(&sk, &[0u8; 32])) +} + #[cfg(test)] mod tests { use super::*; @@ -571,4 +567,26 @@ mod tests { "testnet reward address must start with 'stake_test1', got: {addr}" ); } + + #[test] + fn test_reward_address_clamps_malformed_key() { + // A key with no BIP32-Ed25519 clamp bits set must not panic and must + // produce the same address as its pre-clamped equivalent, keeping + // address derivation consistent with the (clamping) signing path. + let unclamped = [0xFFu8; 64]; + let mut clamped = unclamped; + clamped[0] &= 0b1111_1000; + clamped[31] &= 0b0001_1111; + clamped[31] |= 0b0100_0000; + assert_eq!( + reward_address(&unclamped, true).unwrap(), + reward_address(&clamped, true).unwrap() + ); + } + + #[test] + fn test_derive_child_soft_rejects_bad_length() { + assert!(derive_child_soft(&[0u8; 32], 0).is_err()); + assert!(reward_address(&[0u8; 32], true).is_err()); + } } From b48c82b9cbc107f1134884d375734563f309d14b Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Mon, 27 Jul 2026 15:34:42 +0300 Subject: [PATCH 6/9] fix: remove derive_child_soft, which derived with a zero chain code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BIP32-Ed25519 child derivation is keyed by the chain code; deriving with a hardcoded zero chain code makes sibling public keys enumerable from a single exposed public key, breaking the HD wallet privacy guarantee. The function had no callers — real path derivation happens in HdDeriver, which threads the proper chain code — so the unsound public API is removed rather than patched. --- ows/crates/ows-signer/src/chains/cardano.rs | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/ows/crates/ows-signer/src/chains/cardano.rs b/ows/crates/ows-signer/src/chains/cardano.rs index 20c1f2bd3..1022a765a 100644 --- a/ows/crates/ows-signer/src/chains/cardano.rs +++ b/ows/crates/ows-signer/src/chains/cardano.rs @@ -3,7 +3,7 @@ use crate::traits::{ChainSigner, SignOutput, SignerError}; use bech32::{Bech32, Hrp}; use blake2::digest::{Update, VariableOutput}; use blake2::Blake2bVar; -use ed25519_bip32::{DerivationScheme, XPrv}; +use ed25519_bip32::XPrv; use ows_core::ChainType; /// Cardano network tag used in address header byte. @@ -286,18 +286,13 @@ pub fn reward_address(stake_key: &[u8], mainnet: bool) -> Result Result, SignerError> { - let parent = xprv_from_extended_bytes(extended_key)?; - let child = parent.derive(DerivationScheme::V2, index); - Ok(child.extended_secret_key_bytes().to_vec()) -} - /// Reconstruct an `XPrv` from a 64-byte extended private key. Chain code is set to /// zeroes because it is not needed for signing or public-key derivation — only the -/// scalar (kL) and extension (kR) matter. +/// scalar (kL) and extension (kR) matter. Child derivation is deliberately NOT +/// offered on keys reconstructed this way: BIP32-Ed25519 child derivation is keyed +/// by the chain code, and a zero chain code would make sibling public keys +/// enumerable from a single exposed public key. Path derivation happens in +/// `HdDeriver`, which carries the real chain code. /// /// The scalar (kL, first 32 bytes) is clamped per the BIP32-Ed25519 spec so that the /// XPrv construction never panics and always yields a valid Ed25519 scalar, @@ -585,8 +580,7 @@ mod tests { } #[test] - fn test_derive_child_soft_rejects_bad_length() { - assert!(derive_child_soft(&[0u8; 32], 0).is_err()); + fn test_reward_address_rejects_bad_length() { assert!(reward_address(&[0u8; 32], true).is_err()); } } From d2df67da69b35e69369b29e2261cd6645f695302 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Tue, 28 Jul 2026 18:06:06 +0300 Subject: [PATCH 7/9] fix: address message-signing and key-import review findings - sign_message now prepends a domain-separation prefix, so a signed message can never be byte-identical to a blake2b-256 transaction hash and message signatures are structurally unusable as transaction witnesses. Full CIP-8/COSE_Sign1 enveloping is left as a follow-up. - import_wallet_private_key no longer fabricates a random ed25519_bip32 key for secp256k1/ed25519 imports. Such a key has no derivation relationship to any user-held secret, so ADA sent to its address would be unrecoverable if the vault were lost. Cardano accounts are now simply skipped for these wallets (same path as legacy wallets). --- ows/crates/ows-lib/src/ops.rs | 46 +++++++++++---------- ows/crates/ows-signer/src/chains/cardano.rs | 41 +++++++++++++++++- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 0b3259a00..d9898916c 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -332,18 +332,16 @@ pub fn import_wallet_private_key( } let keys = match (secp256k1_key_hex, ed25519_key_hex) { - // Both curve keys explicitly provided — use them directly - (Some(secp_hex), Some(ed_hex)) => { - let mut random_bip32 = vec![0u8; 64]; - getrandom::getrandom(&mut random_bip32).map_err(|e| { - OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) - })?; - KeyPair { - secp256k1: decode_hex_key(secp_hex)?, - ed25519: decode_hex_key(ed_hex)?, - ed25519_bip32: random_bip32, - } - } + // Both curve keys explicitly provided — use them directly. No Cardano + // key is fabricated: a random ed25519_bip32 key would have no + // derivation relationship to any user-held secret, so ADA sent to its + // address would be unrecoverable if the vault were lost. Leaving it + // empty makes account derivation skip Cardano for this wallet. + (Some(secp_hex), Some(ed_hex)) => KeyPair { + secp256k1: decode_hex_key(secp_hex)?, + ed25519: decode_hex_key(ed_hex)?, + ed25519_bip32: Vec::new(), + }, // Existing single-key behavior _ => { let key_bytes = decode_hex_key(private_key_hex)?; @@ -357,15 +355,14 @@ pub fn import_wallet_private_key( None => ows_signer::Curve::Secp256k1, }; - // Build key pair: provided key for its curve, random bytes for the others. + // Build key pair: provided key for its curve, random bytes for the + // others. No Cardano key is fabricated unless one was supplied — + // a random ed25519_bip32 key would be unrecoverable from any + // user-held secret, so Cardano is skipped for these wallets. let mut other_key_32 = vec![0u8; 32]; getrandom::getrandom(&mut other_key_32).map_err(|e| { OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) })?; - let mut random_bip32 = vec![0u8; 64]; - getrandom::getrandom(&mut random_bip32).map_err(|e| { - OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) - })?; let mut random_ed25519 = vec![0u8; 32]; getrandom::getrandom(&mut random_ed25519).map_err(|e| { OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) @@ -378,7 +375,7 @@ pub fn import_wallet_private_key( .map(decode_hex_key) .transpose()? .unwrap_or(other_key_32), - ed25519_bip32: random_bip32, + ed25519_bip32: Vec::new(), }, ows_signer::Curve::Ed25519 => KeyPair { secp256k1: secp256k1_key_hex @@ -386,7 +383,7 @@ pub fn import_wallet_private_key( .transpose()? .unwrap_or(other_key_32), ed25519: key_bytes, - ed25519_bip32: random_bip32, + ed25519_bip32: Vec::new(), }, ows_signer::Curve::Ed25519Bip32 => KeyPair { secp256k1: secp256k1_key_hex @@ -1639,8 +1636,15 @@ mod tests { assert_eq!( info.accounts.len(), - ALL_CHAIN_TYPES.len(), - "should have one account per chain type" + ALL_CHAIN_TYPES.len() - 1, + "one account per chain type except Cardano, which is skipped \ + because no recoverable ed25519_bip32 key exists for key imports" + ); + assert!( + info.accounts + .iter() + .all(|a| !a.chain_id.starts_with("cardano:")), + "no Cardano account may be fabricated from an unrecoverable random key" ); // Sign on EVM (secp256k1) diff --git a/ows/crates/ows-signer/src/chains/cardano.rs b/ows/crates/ows-signer/src/chains/cardano.rs index 1022a765a..201df6718 100644 --- a/ows/crates/ows-signer/src/chains/cardano.rs +++ b/ows/crates/ows-signer/src/chains/cardano.rs @@ -6,6 +6,13 @@ use blake2::Blake2bVar; use ed25519_bip32::XPrv; use ows_core::ChainType; +/// Domain-separation prefix for `sign_message`. Ensures a signed message can +/// never be byte-identical to a blake2b-256 transaction hash (the prefixed +/// payload is always longer than 32 bytes and starts with ASCII that a raw +/// hash context cannot require), so message signatures are structurally +/// unusable as transaction witnesses. +pub const CARDANO_MESSAGE_PREFIX: &[u8] = b"Cardano Signed Message:\n"; + /// Cardano network tag used in address header byte. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Network { @@ -169,9 +176,18 @@ impl ChainSigner for CardanoSigner { self.sign(private_key, &tx_hash) } - /// Sign an arbitrary message (raw, no chain-specific prefix). + /// Sign an arbitrary message with a domain-separation prefix. + /// + /// The prefix guarantees the signed payload can never equal a raw + /// blake2b-256 transaction hash, so a "sign this message" request can + /// never yield a valid Cardano transaction witness. Verifiers must + /// prepend the same prefix. (Full CIP-8 / COSE_Sign1 enveloping is a + /// planned follow-up for wallet interoperability.) fn sign_message(&self, private_key: &[u8], message: &[u8]) -> Result { - self.sign(private_key, message) + let mut prefixed = Vec::with_capacity(CARDANO_MESSAGE_PREFIX.len() + message.len()); + prefixed.extend_from_slice(CARDANO_MESSAGE_PREFIX); + prefixed.extend_from_slice(message); + self.sign(private_key, &prefixed) } /// Inject the witness set into the Cardano transaction. @@ -583,4 +599,25 @@ mod tests { fn test_reward_address_rejects_bad_length() { assert!(reward_address(&[0u8; 32], true).is_err()); } + + #[test] + fn test_sign_message_is_domain_separated_from_transaction_signing() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + + // A phishing "message" that is exactly a 32-byte transaction hash + // must not produce the same signature the transaction path would, + // otherwise the message signature is a valid transaction witness. + let fake_tx_hash = [0xABu8; 32]; + let message_sig = signer.sign_message(&key, &fake_tx_hash).unwrap(); + let witness_sig = signer.sign(&key, &fake_tx_hash).unwrap(); + assert_ne!(message_sig.signature, witness_sig.signature); + + // The prefixed payload is what actually gets signed. + let mut prefixed = CARDANO_MESSAGE_PREFIX.to_vec(); + prefixed.extend_from_slice(&fake_tx_hash); + let expected = signer.sign(&key, &prefixed).unwrap(); + assert_eq!(message_sig.signature, expected.signature); + } } From 3a5210e8cde0f38545010b1ce40e65bb2978415d Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Tue, 28 Jul 2026 18:17:42 +0300 Subject: [PATCH 8/9] fix(ows-signer): derive Cardano keys via Icarus (CIP-3) for ecosystem compatibility The previous root key construction hashed the BIP-39 seed, which is deterministic but derives different addresses than mainstream Cardano wallets. Cardano software wallets (Daedalus/Shelley, Yoroi, Eternl, Lucid) use the Icarus master key generation from CIP-3: PBKDF2-HMAC- SHA512 keyed on the mnemonic entropy (4096 iterations, 96-byte output). - Mnemonic gains to_entropy(), recovering the BIP-39 entropy bits - Ed25519Bip32 derivation now runs Icarus from entropy + passphrase; deriving Cardano keys from a bare seed is refused, since entropy cannot be recovered from the seed - Verified against the official CIP-3 Icarus test vectors (master key with and without passphrase) - End-to-end vectors cross-checked against @emurgo/cardano-serialization-lib: payment/enterprise and reward addresses now match byte-for-byte on mainnet and testnet for two mnemonics A mnemonic imported into OWS now yields the same Cardano addresses as any standard wallet, and vice versa. --- ows/Cargo.lock | 1 + ows/crates/ows-signer/Cargo.toml | 1 + ows/crates/ows-signer/src/chains/cardano.rs | 48 ++++++++ ows/crates/ows-signer/src/hd.rs | 128 +++++++++++++------- ows/crates/ows-signer/src/mnemonic.rs | 29 ++++- 5 files changed, 162 insertions(+), 45 deletions(-) diff --git a/ows/Cargo.lock b/ows/Cargo.lock index 710eaa3e3..578d77084 100644 --- a/ows/Cargo.lock +++ b/ows/Cargo.lock @@ -1750,6 +1750,7 @@ dependencies = [ "k256", "libc", "ows-core", + "pbkdf2", "rand 0.8.5", "ripemd", "scrypt", diff --git a/ows/crates/ows-signer/Cargo.toml b/ows/crates/ows-signer/Cargo.toml index e031e61c5..f4feac942 100644 --- a/ows/crates/ows-signer/Cargo.toml +++ b/ows/crates/ows-signer/Cargo.toml @@ -30,6 +30,7 @@ thiserror = "2" hex = "0.4" hkdf = "0.12" hmac = "0.12" +pbkdf2 = "0.12" rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/ows/crates/ows-signer/src/chains/cardano.rs b/ows/crates/ows-signer/src/chains/cardano.rs index 201df6718..29ef374bc 100644 --- a/ows/crates/ows-signer/src/chains/cardano.rs +++ b/ows/crates/ows-signer/src/chains/cardano.rs @@ -600,6 +600,54 @@ mod tests { assert!(reward_address(&[0u8; 32], true).is_err()); } + // === Cross-implementation vectors === + // Generated with @emurgo/cardano-serialization-lib-nodejs 12.x: + // Bip32PrivateKey.from_bip39_entropy(entropy, "") → 1852'/1815'/0' → role/index, + // EnterpriseAddress / RewardAddress. Any mismatch here means addresses are + // incompatible with mainstream Cardano wallets (Daedalus, Yoroi, Eternl, Lucid). + + #[test] + fn test_addresses_match_cardano_serialization_lib() { + let cases = [ + ( + ABANDON_PHRASE, + "addr1vy8ac7qqy0vtulyl7wntmsxc6wex80gvcyjy33qffrhm7ss7lxrqp", + "addr_test1vq8ac7qqy0vtulyl7wntmsxc6wex80gvcyjy33qffrhm7ss9hjl0y", + "stake1u8j40zgr2gy4788kl54h6x3gu0pukq5lfr8nflufpg5dzaskqlx2l", + ), + ( + "eight country switch draw meat scout mystery blade tip drift \ + useless good keep usage title", + "addr1vyv7qlaucathxkwkc503ujw0rv9lfj2rkj96feyst2rs9eqmvfvmx", + "addr_test1vqv7qlaucathxkwkc503ujw0rv9lfj2rkj96feyst2rs9eqqyas5r", + "stake1ux2436tfe25727kul3qtnyr7k72rvw6ep7h59ll53suwhzq05v5j9", + ), + ]; + for (phrase, mainnet_addr, testnet_addr, stake_addr) in cases { + let mnemonic = Mnemonic::from_phrase(phrase).unwrap(); + let key = derive_payment_key(&mnemonic); + assert_eq!( + CardanoSigner::mainnet().derive_address(&key).unwrap(), + mainnet_addr + ); + assert_eq!( + CardanoSigner::testnet().derive_address(&key).unwrap(), + testnet_addr + ); + let stake_key = HdDeriver::derive_from_mnemonic( + &mnemonic, + "", + &staking_key_path(0), + Curve::Ed25519Bip32, + ) + .unwrap(); + assert_eq!( + reward_address(stake_key.expose(), true).unwrap(), + stake_addr + ); + } + } + #[test] fn test_sign_message_is_domain_separated_from_transaction_signing() { let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); diff --git a/ows/crates/ows-signer/src/hd.rs b/ows/crates/ows-signer/src/hd.rs index 0635c5705..6f3f0b5aa 100644 --- a/ows/crates/ows-signer/src/hd.rs +++ b/ows/crates/ows-signer/src/hd.rs @@ -36,7 +36,15 @@ impl HdDeriver { match curve { Curve::Secp256k1 => Self::derive_secp256k1(seed, path), Curve::Ed25519 => Self::derive_ed25519(seed, path), - Curve::Ed25519Bip32 => Self::derive_cardano_cip1852(seed, path), + // Icarus (CIP-3) is keyed on the mnemonic ENTROPY, which cannot be + // recovered from the BIP-39 seed. Deriving Cardano keys from the + // seed would produce addresses incompatible with the rest of the + // Cardano ecosystem, so it is refused here. + Curve::Ed25519Bip32 => Err(HdError::DerivationFailed( + "Cardano (Icarus/CIP-3) derivation requires the mnemonic; \ + use derive_from_mnemonic" + .into(), + )), } } @@ -47,8 +55,17 @@ impl HdDeriver { path: &str, curve: Curve, ) -> Result { - let seed = mnemonic.to_seed(passphrase); - Self::derive(seed.expose(), path, curve) + match curve { + Curve::Ed25519Bip32 => { + Self::validate_path(path)?; + let entropy = mnemonic.to_entropy(); + Self::derive_cardano_icarus(entropy.expose(), passphrase, path) + } + _ => { + let seed = mnemonic.to_seed(passphrase); + Self::derive(seed.expose(), path, curve) + } + } } /// Like `derive_from_mnemonic`, but checks the global key cache first. @@ -193,56 +210,41 @@ impl HdDeriver { Ok(SecretBytes::new(key)) } - /// CIP-1852 / BIP32-Ed25519 derivation for Cardano. + /// CIP-1852 / BIP32-Ed25519 derivation for Cardano using the Icarus + /// master key generation scheme (CIP-3), the standard used by Cardano + /// software wallets (Daedalus/Shelley, Yoroi, Eternl, Lucid, ...). /// - /// Derives an extended private key (64 bytes: scalar || extension) from a BIP-39 seed - /// using the Cardano key derivation scheme and the `ed25519-bip32` crate. + /// Root key construction (CIP-3 "Icarus"): + /// - `PBKDF2-HMAC-SHA512(password = passphrase, salt = mnemonic entropy, + /// iterations = 4096, output = 96 bytes)` + /// - Bit-tweak the first 32 bytes to a valid Ed25519 extended scalar + /// - Bytes 64..96 are the chain code /// - /// Root key construction from seed (Shelley variant): - /// - Apply HMAC-SHA512 with key `"ed25519 cardano seed"` to the BIP-39 seed - /// - Bit-tweak the first 32 bytes to produce a valid Ed25519 scalar - /// - Second HMAC-SHA512 (with `0x01` prefix) gives the chain code - /// - /// Note: Lucid Evolution derives from BIP-39 *entropy* using the Icarus algorithm. - /// This implementation uses the BIP-39 *seed* (PBKDF2 output) which is deterministic - /// but produces different addresses than Lucid for the same mnemonic. - fn derive_cardano_cip1852(seed: &[u8], path: &str) -> Result { + /// Verified against the official CIP-3 test vectors (see tests below). + fn derive_cardano_icarus( + entropy: &[u8], + passphrase: &str, + path: &str, + ) -> Result { use ed25519_bip32::{DerivationScheme, XPrv}; use zeroize::Zeroize; - // --- Root key from BIP-39 seed --- - // Pass 1: HMAC-SHA512(key="ed25519 cardano seed", data=seed) → 64 bytes - type HmacSha512 = Hmac; - let mut mac = HmacSha512::new_from_slice(b"ed25519 cardano seed") - .expect("HMAC can take key of any size"); - mac.update(seed); - let i: [u8; 64] = mac.finalize().into_bytes().into(); + let mut master = [0u8; 96]; + pbkdf2::pbkdf2_hmac::(passphrase.as_bytes(), entropy, 4096, &mut master); - // Bit-tweak the left 32 bytes to produce a valid Ed25519 extended scalar (kL): + // Tweak the scalar (kL) per CIP-3: // - Clear bottom 3 bits of byte 0 (multiple of cofactor 8) - // - Clear top 3 bits of byte 31 (prevent overflow past curve order) - // - Set bit 254 (0x40 in byte 31) as required by BIP32-Ed25519 - let mut kl: [u8; 32] = i[..32].try_into().unwrap(); - kl[0] &= 0b1111_1000; - kl[31] &= 0b0001_1111; - kl[31] |= 0b0100_0000; - let kr: [u8; 32] = i[32..64].try_into().unwrap(); - - // Pass 2: HMAC-SHA512(key="ed25519 cardano seed", data=[0x01] || seed) → chain code - let mut mac2 = HmacSha512::new_from_slice(b"ed25519 cardano seed") - .expect("HMAC can take key of any size"); - mac2.update(&[0x01]); - mac2.update(seed); - let chain_code_full: [u8; 64] = mac2.finalize().into_bytes().into(); - let chain_code: [u8; 32] = chain_code_full[..32].try_into().unwrap(); - - // Build root XPrv from the extended key (kL || kR) and chain code - let mut extended_key: [u8; 64] = [0u8; 64]; - extended_key[..32].copy_from_slice(&kl); - extended_key[32..64].copy_from_slice(&kr); + // - Clear the highest and third-highest bits of byte 31 + // - Set the second-highest bit of byte 31 + master[0] &= 0b1111_1000; + master[31] &= 0b0001_1111; + master[31] |= 0b0100_0000; + + let extended_key: [u8; 64] = master[..64].try_into().unwrap(); + let chain_code: [u8; 32] = master[64..96].try_into().unwrap(); let mut root = XPrv::from_extended_and_chaincode(&extended_key, &chain_code); - extended_key.zeroize(); + master.zeroize(); // --- Child derivation following CIP-1852 path --- // Path format: m/purpose'/coin_type'/account'/role/index @@ -339,6 +341,44 @@ mod tests { assert_eq!(key1.expose(), key2.expose()); } + // === CIP-3 Icarus master key test vectors === + // Source: https://github.com/cardano-foundation/CIPs/blob/master/CIP-0003/Icarus.md + + const ICARUS_PHRASE: &str = "eight country switch draw meat scout mystery blade tip drift \ + useless good keep usage title"; + + #[test] + fn test_cip3_icarus_master_key_no_passphrase() { + let mnemonic = Mnemonic::from_phrase(ICARUS_PHRASE).unwrap(); + let key = HdDeriver::derive_from_mnemonic(&mnemonic, "", "m", Curve::Ed25519Bip32).unwrap(); + // First 64 bytes of the official 96-byte master key vector (kL || kR). + assert_eq!( + hex::encode(key.expose()), + "c065afd2832cd8b087c4d9ab7011f481ee1e0721e78ea5dd609f3ab3f156d245\ + d176bd8fd4ec60b4731c3918a2a72a0226c0cd119ec35b47e4d55884667f552a" + ); + } + + #[test] + fn test_cip3_icarus_master_key_with_passphrase() { + let mnemonic = Mnemonic::from_phrase(ICARUS_PHRASE).unwrap(); + let key = + HdDeriver::derive_from_mnemonic(&mnemonic, "foo", "m", Curve::Ed25519Bip32).unwrap(); + assert_eq!( + hex::encode(key.expose()), + "70531039904019351e1afb361cd1b312a4d0565d4ff9f8062d38acf4b15cce41\ + d7b5738d9c893feea55512a3004acb0d222c35d3e3d5cde943a15a9824cbac59" + ); + } + + #[test] + fn test_cardano_derive_from_seed_is_refused() { + let seed = test_seed(); + assert!( + HdDeriver::derive(seed.expose(), "m/1852'/1815'/0'/0/0", Curve::Ed25519Bip32).is_err() + ); + } + #[test] fn test_path_validation_valid() { assert!(HdDeriver::validate_path("m/44'/60'/0'/0/0").is_ok()); diff --git a/ows/crates/ows-signer/src/mnemonic.rs b/ows/crates/ows-signer/src/mnemonic.rs index 4171413d7..b061f0a04 100644 --- a/ows/crates/ows-signer/src/mnemonic.rs +++ b/ows/crates/ows-signer/src/mnemonic.rs @@ -1,5 +1,5 @@ use crate::zeroizing::SecretBytes; -use coins_bip39::{English, Mnemonic as Bip39Mnemonic}; +use coins_bip39::{English, Mnemonic as Bip39Mnemonic, Wordlist}; /// Mnemonic strength / word count. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,6 +57,33 @@ impl Mnemonic { SecretBytes::new(seed.to_vec()) } + /// Recover the raw BIP-39 entropy bytes encoded by this mnemonic. + /// + /// Each word encodes 11 bits; the trailing `word_count / 3` bits are the + /// checksum and are discarded. Needed by Cardano's Icarus (CIP-3) master + /// key generation, which is keyed on the entropy rather than the seed. + pub fn to_entropy(&self) -> SecretBytes { + let mut phrase = self.inner.to_phrase(); + let words: Vec<&str> = phrase.split_whitespace().collect(); + let entropy_bits = words.len() * 11 - words.len() / 3; + let mut entropy = vec![0u8; entropy_bits / 8]; + let mut bit = 0usize; + for word in &words { + let index = English::get_index(word).expect("phrase was validated on construction"); + for i in (0..11).rev() { + if bit == entropy_bits { + break; + } + if (index >> i) & 1 == 1 { + entropy[bit / 8] |= 1 << (7 - (bit % 8)); + } + bit += 1; + } + } + zeroize::Zeroize::zeroize(unsafe { phrase.as_mut_vec() }); + SecretBytes::new(entropy) + } + /// Returns the number of words in this mnemonic. pub fn word_count(&self) -> usize { let mut phrase = self.inner.to_phrase(); From 6ce0d62eb1aaeb0c1411101a3f301ce47e5d1f8c Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Tue, 28 Jul 2026 21:22:59 +0300 Subject: [PATCH 9/9] fix(ows-lib): restrict broadcast_cardano curl to http/https schemes Adds --proto/--proto-redir '-all,http,https' so a hostile rpc_url cannot route the spawned curl to non-HTTP schemes (file://, ftp://, dict://, ...), including via redirects. Spawning curl itself matches the existing broadcast convention (curl_post_json) used by every other chain. --- ows/crates/ows-lib/src/ops.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index d9898916c..d6b72d641 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -1026,6 +1026,12 @@ fn broadcast_cardano(rpc_url: &str, signed_bytes: &[u8]) -> Result