From 651ab73974c0af023c35afb3f4c71fdf422fd9aa Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Sun, 5 Apr 2026 03:32:24 +0300 Subject: [PATCH] feat: add getPublicKey across all chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds derive_public_key() to ChainSigner trait and wires it through ows-lib, Node binding, Python binding, and CLI. Closes #157 Problem ------- TON wallet contract initialization (WalletContractV5R1) requires the raw Ed25519 public key. The only workaround was exportWallet() to get the mnemonic, re-derive externally, then zero the secret material — partially undermining key isolation. Solution -------- Add get_public_key(wallet, chain, index) that returns the raw public key hex without exposing any secret material. Return format: - Ed25519 chains (TON, Solana, Sui): 32-byte hex (64 chars) - secp256k1 chains (EVM, Bitcoin, Cosmos, Tron, XRPL, Filecoin, Spark): 33-byte compressed SEC1 hex (66 chars, starts with 02 or 03) Changes ------- - ows-signer: derive_public_key() added to ChainSigner trait - ows-signer: implemented for all 10 chains - ows-lib: get_public_key() with 8 unit tests (144 existing pass) - bindings/node: getPublicKey() exported via NAPI - bindings/python: get_public_key() exported via PyO3 - ows-cli: ows wallet public-key --wallet --chain [--index] [--json] --- bindings/node/src/lib.rs | 16 +++ bindings/python/src/lib.rs | 17 +++ ows/crates/ows-cli/src/commands/wallet.rs | 16 +++ ows/crates/ows-cli/src/main.rs | 18 +++ ows/crates/ows-lib/src/ops.rs | 109 +++++++++++++++++++ ows/crates/ows-signer/src/chains/bitcoin.rs | 7 ++ ows/crates/ows-signer/src/chains/cosmos.rs | 7 ++ ows/crates/ows-signer/src/chains/evm.rs | 7 ++ ows/crates/ows-signer/src/chains/filecoin.rs | 7 ++ ows/crates/ows-signer/src/chains/solana.rs | 6 + ows/crates/ows-signer/src/chains/spark.rs | 7 ++ ows/crates/ows-signer/src/chains/sui.rs | 6 + ows/crates/ows-signer/src/chains/ton.rs | 6 + ows/crates/ows-signer/src/chains/tron.rs | 7 ++ ows/crates/ows-signer/src/chains/xrpl.rs | 7 ++ ows/crates/ows-signer/src/traits.rs | 9 ++ 16 files changed, 252 insertions(+) diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index dfbe6508..d29476a6 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -399,3 +399,19 @@ pub fn sign_and_send( .map(|r| SendResult { tx_hash: r.tx_hash }) .map_err(map_err) } + +#[napi] +pub fn get_public_key( + wallet: String, + chain: String, + index: Option, + vault_path_opt: Option, +) -> Result { + ows_lib::get_public_key( + &wallet, + &chain, + index, + vault_path(vault_path_opt).as_deref(), + ) + .map_err(map_err) +} diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 56a3c934..70be5d7e 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -411,6 +411,22 @@ fn wallet_info_to_dict_inner<'py>( } /// Python module definition. +#[pyfunction] +fn get_public_key( + wallet: &str, + chain: &str, + index: Option, + vault_path_opt: Option, +) -> PyResult { + ows_lib::get_public_key( + wallet, + chain, + index, + vault_path(vault_path_opt).as_deref(), + ) + .map_err(map_err) +} + #[pymodule] fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(generate_mnemonic, m)?)?; @@ -433,6 +449,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(delete_policy, m)?)?; m.add_function(wrap_pyfunction!(create_api_key, m)?)?; m.add_function(wrap_pyfunction!(list_api_keys, m)?)?; + m.add_function(wrap_pyfunction!(get_public_key, m)?)?; m.add_function(wrap_pyfunction!(revoke_api_key, m)?)?; Ok(()) } diff --git a/ows/crates/ows-cli/src/commands/wallet.rs b/ows/crates/ows-cli/src/commands/wallet.rs index fd3c56ac..c38c6188 100644 --- a/ows/crates/ows-cli/src/commands/wallet.rs +++ b/ows/crates/ows-cli/src/commands/wallet.rs @@ -185,3 +185,19 @@ pub fn list() -> Result<(), CliError> { Ok(()) } + +pub fn public_key(wallet_name: &str, chain: &str, index: u32, json_output: bool) -> Result<(), CliError> { + let pubkey = ows_lib::get_public_key(wallet_name, chain, Some(index), None)?; + if json_output { + let obj = serde_json::json!({ + "wallet": wallet_name, + "chain": chain, + "index": index, + "public_key": pubkey, + }); + println!("{}", serde_json::to_string_pretty(&obj)?); + } else { + println!("{pubkey}"); + } + Ok(()) +} diff --git a/ows/crates/ows-cli/src/main.rs b/ows/crates/ows-cli/src/main.rs index 2de08354..38d0ea99 100644 --- a/ows/crates/ows-cli/src/main.rs +++ b/ows/crates/ows-cli/src/main.rs @@ -132,6 +132,21 @@ enum WalletCommands { List, /// Show vault path and supported chains Info, + /// Show the raw public key for a wallet on a given chain + PublicKey { + /// Wallet name or ID + #[arg(long, env = "OWS_WALLET")] + wallet: String, + /// Chain name or CAIP-2 ID (e.g. "ton", "evm", "solana") + #[arg(long)] + chain: String, + /// Account index + #[arg(long, default_value = "0")] + index: u32, + /// Output structured JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -419,6 +434,9 @@ fn run(cli: Cli) -> Result<(), CliError> { } WalletCommands::List => commands::wallet::list(), WalletCommands::Info => commands::info::run(), + WalletCommands::PublicKey { wallet, chain, index, json } => { + commands::wallet::public_key(&wallet, &chain, index, json) + } }, Commands::Sign { subcommand } => match subcommand { SignCommands::Message { diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index d0b4d190..75f9f8f5 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -2911,3 +2911,112 @@ mod tests { } } } + +/// Return the raw public key bytes for a wallet on a given chain. +/// +/// Public keys are not secret — exposing them does not weaken the security +/// model. This eliminates the need to export the mnemonic just to derive +/// the public key externally (e.g. for TON wallet contract initialization). +/// +/// Returns: +/// - secp256k1 chains (EVM, Bitcoin, Cosmos, Tron, XRPL, Filecoin, Spark): +/// 33-byte compressed SEC1 public key, hex-encoded. +/// - Ed25519 chains (TON, Solana, Sui): +/// 32-byte public key, hex-encoded. +/// +/// # Example +/// ```no_run +/// # use ows_lib::get_public_key; +/// let pubkey = get_public_key("my-wallet", "ton", None, None).unwrap(); +/// // pubkey = "a1b2c3..." (32-byte hex for Ed25519) +/// ``` +pub fn get_public_key( + wallet: &str, + chain: &str, + index: Option, + vault_path: Option<&std::path::Path>, +) -> Result { + let chain_parsed = parse_chain(chain)?; + let key = decrypt_signing_key(wallet, chain_parsed.chain_type, "", index, vault_path)?; + let signer = signer_for_chain(chain_parsed.chain_type); + let pubkey_bytes = signer.derive_public_key(key.expose())?; + Ok(hex::encode(pubkey_bytes)) +} + +#[cfg(test)] +mod pubkey_tests { + use super::*; + use tempfile::tempdir; + + fn make_wallet(name: &str, vault: &std::path::Path) -> WalletInfo { + create_wallet(name, Some(12), None, Some(vault)).unwrap() + } + + #[test] + fn get_public_key_evm_returns_33_bytes() { + let dir = tempdir().unwrap(); + make_wallet("pk-evm", dir.path()); + let hex = get_public_key("pk-evm", "evm", None, Some(dir.path())).unwrap(); + assert_eq!(hex.len(), 66, "33 bytes = 66 hex chars"); + assert!(hex.starts_with("02") || hex.starts_with("03"), "compressed secp256k1"); + } + + #[test] + fn get_public_key_ton_returns_32_bytes() { + let dir = tempdir().unwrap(); + make_wallet("pk-ton", dir.path()); + let hex = get_public_key("pk-ton", "ton", None, Some(dir.path())).unwrap(); + assert_eq!(hex.len(), 64, "32 bytes = 64 hex chars"); + } + + #[test] + fn get_public_key_solana_returns_32_bytes() { + let dir = tempdir().unwrap(); + make_wallet("pk-sol", dir.path()); + let hex = get_public_key("pk-sol", "solana", None, Some(dir.path())).unwrap(); + assert_eq!(hex.len(), 64); + } + + #[test] + fn get_public_key_bitcoin_returns_33_bytes() { + let dir = tempdir().unwrap(); + make_wallet("pk-btc", dir.path()); + let hex = get_public_key("pk-btc", "bitcoin", None, Some(dir.path())).unwrap(); + assert_eq!(hex.len(), 66); + assert!(hex.starts_with("02") || hex.starts_with("03")); + } + + #[test] + fn get_public_key_is_deterministic() { + let dir = tempdir().unwrap(); + make_wallet("pk-det", dir.path()); + let pk1 = get_public_key("pk-det", "evm", None, Some(dir.path())).unwrap(); + let pk2 = get_public_key("pk-det", "evm", None, Some(dir.path())).unwrap(); + assert_eq!(pk1, pk2); + } + + #[test] + fn get_public_key_different_index_yields_different_key() { + let dir = tempdir().unwrap(); + make_wallet("pk-idx", dir.path()); + let pk0 = get_public_key("pk-idx", "evm", Some(0), Some(dir.path())).unwrap(); + let pk1 = get_public_key("pk-idx", "evm", Some(1), Some(dir.path())).unwrap(); + assert_ne!(pk0, pk1); + } + + #[test] + fn get_public_key_fails_for_nonexistent_wallet() { + let dir = tempdir().unwrap(); + assert!(get_public_key("no-such-wallet", "evm", None, Some(dir.path())).is_err()); + } + + #[test] + fn get_public_key_all_chains() { + let dir = tempdir().unwrap(); + make_wallet("pk-all", dir.path()); + for chain in &["evm", "solana", "ton", "bitcoin", "cosmos", "tron", "sui", "filecoin"] { + let hex = get_public_key("pk-all", chain, None, Some(dir.path())).unwrap(); + assert!(hex.len() == 64 || hex.len() == 66, "chain {chain}: unexpected key length {}", hex.len()); + } + } +} diff --git a/ows/crates/ows-signer/src/chains/bitcoin.rs b/ows/crates/ows-signer/src/chains/bitcoin.rs index 6e38693d..2d0dbd61 100644 --- a/ows/crates/ows-signer/src/chains/bitcoin.rs +++ b/ows/crates/ows-signer/src/chains/bitcoin.rs @@ -88,6 +88,13 @@ impl ChainSigner for BitcoinSigner { Ok(address) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + Ok(compressed.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { if message.len() != 32 { diff --git a/ows/crates/ows-signer/src/chains/cosmos.rs b/ows/crates/ows-signer/src/chains/cosmos.rs index 026a9588..17a952f0 100644 --- a/ows/crates/ows-signer/src/chains/cosmos.rs +++ b/ows/crates/ows-signer/src/chains/cosmos.rs @@ -66,6 +66,13 @@ impl ChainSigner for CosmosSigner { Ok(address) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + Ok(compressed.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { if message.len() != 32 { diff --git a/ows/crates/ows-signer/src/chains/evm.rs b/ows/crates/ows-signer/src/chains/evm.rs index dd170187..680b0411 100644 --- a/ows/crates/ows-signer/src/chains/evm.rs +++ b/ows/crates/ows-signer/src/chains/evm.rs @@ -88,6 +88,13 @@ impl ChainSigner for EvmSigner { Ok(Self::eip55_checksum(&address_hex)) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + Ok(compressed.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { if message.len() != 32 { diff --git a/ows/crates/ows-signer/src/chains/filecoin.rs b/ows/crates/ows-signer/src/chains/filecoin.rs index 674e1eee..d7adeeda 100644 --- a/ows/crates/ows-signer/src/chains/filecoin.rs +++ b/ows/crates/ows-signer/src/chains/filecoin.rs @@ -97,6 +97,13 @@ impl ChainSigner for FilecoinSigner { Ok(format!("f1{}", Self::base32_encode(&addr_bytes))) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + Ok(compressed.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { if message.len() != 32 { diff --git a/ows/crates/ows-signer/src/chains/solana.rs b/ows/crates/ows-signer/src/chains/solana.rs index 85fa6bc7..f0d00ee9 100644 --- a/ows/crates/ows-signer/src/chains/solana.rs +++ b/ows/crates/ows-signer/src/chains/solana.rs @@ -55,6 +55,12 @@ impl ChainSigner for SolanaSigner { let verifying_key: VerifyingKey = signing_key.verifying_key(); Ok(bs58::encode(verifying_key.as_bytes()).into_string()) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + Ok(verifying_key.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { let signing_key = Self::signing_key(private_key)?; diff --git a/ows/crates/ows-signer/src/chains/spark.rs b/ows/crates/ows-signer/src/chains/spark.rs index 46316b4a..803a00e6 100644 --- a/ows/crates/ows-signer/src/chains/spark.rs +++ b/ows/crates/ows-signer/src/chains/spark.rs @@ -39,6 +39,13 @@ impl ChainSigner for SparkSigner { hex::encode(pubkey_compressed.as_bytes()) )) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + Ok(compressed.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { if message.len() != 32 { diff --git a/ows/crates/ows-signer/src/chains/sui.rs b/ows/crates/ows-signer/src/chains/sui.rs index ffaeb34b..e2e05fdf 100644 --- a/ows/crates/ows-signer/src/chains/sui.rs +++ b/ows/crates/ows-signer/src/chains/sui.rs @@ -74,6 +74,12 @@ impl ChainSigner for SuiSigner { Ok(format!("0x{}", hex::encode(hash))) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + Ok(verifying_key.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { let signing_key = Self::signing_key(private_key)?; diff --git a/ows/crates/ows-signer/src/chains/ton.rs b/ows/crates/ows-signer/src/chains/ton.rs index 29f5b8a5..65290fe0 100644 --- a/ows/crates/ows-signer/src/chains/ton.rs +++ b/ows/crates/ows-signer/src/chains/ton.rs @@ -174,6 +174,12 @@ impl ChainSigner for TonSigner { Ok(Self::encode_address(0, &state_hash, false)) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + Ok(verifying_key.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { let signing_key = Self::signing_key(private_key)?; diff --git a/ows/crates/ows-signer/src/chains/tron.rs b/ows/crates/ows-signer/src/chains/tron.rs index 733a1ec2..384c3f6e 100644 --- a/ows/crates/ows-signer/src/chains/tron.rs +++ b/ows/crates/ows-signer/src/chains/tron.rs @@ -52,6 +52,13 @@ impl ChainSigner for TronSigner { Ok(address) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + Ok(compressed.as_bytes().to_vec()) + } + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { if message.len() != 32 { diff --git a/ows/crates/ows-signer/src/chains/xrpl.rs b/ows/crates/ows-signer/src/chains/xrpl.rs index bf10cf6e..ac78f587 100644 --- a/ows/crates/ows-signer/src/chains/xrpl.rs +++ b/ows/crates/ows-signer/src/chains/xrpl.rs @@ -70,6 +70,13 @@ impl ChainSigner for XrplSigner { public_key: None, }) } + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError> { + let signing_key = SigningKey::from_slice(private_key) + .map_err(|e| SignerError::InvalidPrivateKey(e.to_string()))?; + let compressed = signing_key.verifying_key().to_encoded_point(true); + Ok(compressed.as_bytes().to_vec()) + } + /// Sign a binary-encoded unsigned XRPL transaction. /// diff --git a/ows/crates/ows-signer/src/traits.rs b/ows/crates/ows-signer/src/traits.rs index 9ffe5eb3..8468c8fb 100644 --- a/ows/crates/ows-signer/src/traits.rs +++ b/ows/crates/ows-signer/src/traits.rs @@ -29,6 +29,15 @@ pub trait ChainSigner: Send + Sync { /// Derive an on-chain address from a private key. fn derive_address(&self, private_key: &[u8]) -> Result; + /// Derive the raw public key bytes from a private key. + /// + /// Returns the canonical public key encoding for this chain's curve: + /// - secp256k1 chains: 33-byte compressed public key (SEC1) + /// - Ed25519 chains: 32-byte public key + /// + /// Public keys are not secret — exposing them does not weaken the security model. + fn derive_public_key(&self, private_key: &[u8]) -> Result, SignerError>; + /// Sign a pre-hashed message (32 bytes for secp256k1, raw message for ed25519). fn sign(&self, private_key: &[u8], message: &[u8]) -> Result;