diff --git a/ows/Cargo.lock b/ows/Cargo.lock index 0c0892826..578d77084 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", @@ -1689,6 +1750,7 @@ dependencies = [ "k256", "libc", "ows-core", + "pbkdf2", "rand 0.8.5", "ripemd", "scrypt", 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-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 29c92dfbf..bcb7e67fe 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] @@ -266,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"), diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 92be1b6e1..d6b72d641 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,15 +71,31 @@ impl Drop for KeyPair { use zeroize::Zeroize; self.secp256k1.zeroize(); self.ed25519.zeroize(); + self.ed25519_bip32.zeroize(); } } 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::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) + } + } } } @@ -86,11 +104,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 +123,21 @@ 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. 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::new() + }; 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, }) } } @@ -115,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 { @@ -152,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())?)) } } } @@ -296,10 +332,15 @@ pub fn import_wallet_private_key( } let keys = match (secp256k1_key_hex, ed25519_key_hex) { - // Both curve keys explicitly provided — use them directly + // 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 _ => { @@ -314,9 +355,16 @@ 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. 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_ed25519 = vec![0u8; 32]; + getrandom::getrandom(&mut random_ed25519).map_err(|e| { OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) })?; @@ -326,14 +374,27 @@ 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: Vec::new(), }, 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: Vec::new(), + }, + 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(random_ed25519), + ed25519_bip32: key_bytes, }, } } @@ -820,6 +881,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 => broadcast_cardano(rpc_url, signed_bytes), } } @@ -950,6 +1012,72 @@ 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", + // Restrict curl to HTTP(S) (including across redirects) so a + // hostile rpc_url cannot smuggle other schemes (file://, ftp://, ...). + "--proto", + "-all,http,https", + "--proto-redir", + "-all,http,https", + "-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; @@ -1092,6 +1220,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 @@ -1104,13 +1256,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: bip32_key, }; let accounts = derive_all_accounts_from_keys(&keys).unwrap(); let payload = keys.to_json_bytes(); @@ -1487,8 +1642,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/Cargo.toml b/ows/crates/ows-signer/Cargo.toml index 81d980264..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" @@ -39,5 +40,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..29ef374bc --- /dev/null +++ b/ows/crates/ows-signer/src/chains/cardano.rs @@ -0,0 +1,671 @@ +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::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 { + 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. See [`xprv_from_extended_bytes`]. + fn xprv_from_extended(extended_key: &[u8]) -> Result { + xprv_from_extended_bytes(extended_key) + } + + /// 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 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 { + 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. + /// + /// `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 { + let xprv = xprv_from_extended_bytes(stake_key)?; + 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())) +} + +/// 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. 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, +/// 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::*; + 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}" + ); + } + + #[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_reward_address_rejects_bad_length() { + 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(); + 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); + } +} 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..6f3f0b5aa 100644 --- a/ows/crates/ows-signer/src/hd.rs +++ b/ows/crates/ows-signer/src/hd.rs @@ -36,6 +36,15 @@ impl HdDeriver { match curve { Curve::Secp256k1 => Self::derive_secp256k1(seed, path), Curve::Ed25519 => Self::derive_ed25519(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(), + )), } } @@ -46,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. @@ -72,6 +90,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 +209,77 @@ impl HdDeriver { chain_code.zeroize(); Ok(SecretBytes::new(key)) } + + /// 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, ...). + /// + /// 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 + /// + /// 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; + + let mut master = [0u8; 96]; + pbkdf2::pbkdf2_hmac::(passphrase.as_bytes(), entropy, 4096, &mut master); + + // Tweak the scalar (kL) per CIP-3: + // - Clear bottom 3 bits of byte 0 (multiple of cofactor 8) + // - 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); + master.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)] @@ -251,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/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); 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();