Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions bindings/node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
vault_path_opt: Option<String>,
) -> Result<String> {
ows_lib::get_public_key(
&wallet,
&chain,
index,
vault_path(vault_path_opt).as_deref(),
)
.map_err(map_err)
}
17 changes: 17 additions & 0 deletions bindings/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
vault_path_opt: Option<String>,
) -> PyResult<String> {
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)?)?;
Expand All @@ -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(())
}
16 changes: 16 additions & 0 deletions ows/crates/ows-cli/src/commands/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
18 changes: 18 additions & 0 deletions ows/crates/ows-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down
109 changes: 109 additions & 0 deletions ows/crates/ows-lib/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
vault_path: Option<&std::path::Path>,
) -> Result<String, OwsLibError> {
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());
}
}
}
7 changes: 7 additions & 0 deletions ows/crates/ows-signer/src/chains/bitcoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ impl ChainSigner for BitcoinSigner {

Ok(address)
}
fn derive_public_key(&self, private_key: &[u8]) -> Result<Vec<u8>, 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<SignOutput, SignerError> {
if message.len() != 32 {
Expand Down
7 changes: 7 additions & 0 deletions ows/crates/ows-signer/src/chains/cosmos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ impl ChainSigner for CosmosSigner {

Ok(address)
}
fn derive_public_key(&self, private_key: &[u8]) -> Result<Vec<u8>, 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<SignOutput, SignerError> {
if message.len() != 32 {
Expand Down
7 changes: 7 additions & 0 deletions ows/crates/ows-signer/src/chains/evm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ impl ChainSigner for EvmSigner {

Ok(Self::eip55_checksum(&address_hex))
}
fn derive_public_key(&self, private_key: &[u8]) -> Result<Vec<u8>, 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<SignOutput, SignerError> {
if message.len() != 32 {
Expand Down
7 changes: 7 additions & 0 deletions ows/crates/ows-signer/src/chains/filecoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>, 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<SignOutput, SignerError> {
if message.len() != 32 {
Expand Down
6 changes: 6 additions & 0 deletions ows/crates/ows-signer/src/chains/solana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>, 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<SignOutput, SignerError> {
let signing_key = Self::signing_key(private_key)?;
Expand Down
7 changes: 7 additions & 0 deletions ows/crates/ows-signer/src/chains/spark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ impl ChainSigner for SparkSigner {
hex::encode(pubkey_compressed.as_bytes())
))
}
fn derive_public_key(&self, private_key: &[u8]) -> Result<Vec<u8>, 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<SignOutput, SignerError> {
if message.len() != 32 {
Expand Down
6 changes: 6 additions & 0 deletions ows/crates/ows-signer/src/chains/sui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ impl ChainSigner for SuiSigner {

Ok(format!("0x{}", hex::encode(hash)))
}
fn derive_public_key(&self, private_key: &[u8]) -> Result<Vec<u8>, 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<SignOutput, SignerError> {
let signing_key = Self::signing_key(private_key)?;
Expand Down
6 changes: 6 additions & 0 deletions ows/crates/ows-signer/src/chains/ton.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>, 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<SignOutput, SignerError> {
let signing_key = Self::signing_key(private_key)?;
Expand Down
7 changes: 7 additions & 0 deletions ows/crates/ows-signer/src/chains/tron.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ impl ChainSigner for TronSigner {

Ok(address)
}
fn derive_public_key(&self, private_key: &[u8]) -> Result<Vec<u8>, 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<SignOutput, SignerError> {
if message.len() != 32 {
Expand Down
7 changes: 7 additions & 0 deletions ows/crates/ows-signer/src/chains/xrpl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ impl ChainSigner for XrplSigner {
public_key: None,
})
}
fn derive_public_key(&self, private_key: &[u8]) -> Result<Vec<u8>, 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.
///
Expand Down
9 changes: 9 additions & 0 deletions ows/crates/ows-signer/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, SignerError>;

/// 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<Vec<u8>, SignerError>;

/// Sign a pre-hashed message (32 bytes for secp256k1, raw message for ed25519).
fn sign(&self, private_key: &[u8], message: &[u8]) -> Result<SignOutput, SignerError>;

Expand Down
Loading