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
45 changes: 22 additions & 23 deletions src/crypto/address.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
//! Keeta address encoding: `keeta_{base32(key_type || pubkey || checksum[0..5])}`
//! Keeta address encoding: `keeta_{base32(key_type || raw_key || checksum)}`.
//! Mirrors node-rs `format_public_key_string(key_data, key_type)`.

use super::kdf::sha3_impl::Sha3Hasher;
use super::params::Algorithm;
use base32ct::{Base32Unpadded, Encoding};

pub const ADDRESS_PREFIX: &[u8] = b"keeta_";
pub const CHECKSUM_LEN: usize = 5;
pub const MAX_ADDRESS_LEN: usize = 72;

/// Returns the length of the generated address.
pub fn generate_address(pubkey: &[u8], algorithm: Algorithm, output: &mut [u8]) -> usize {
// Key type for address encoding matches Algorithm discriminant values
let key_type = algorithm as u8;

// Build data to encode: key_type || pubkey || checksum[0..5]
pub fn generate_address(raw_key: &[u8], key_type: u8, output: &mut [u8]) -> usize {
// Build data to encode: key_type || raw_key || checksum[0..CHECKSUM_LEN]
let mut data = [0u8; 64];
data[0] = key_type;
let pubkey_len = pubkey.len();
data[1..1 + pubkey_len].copy_from_slice(pubkey);

// Calculate checksum: SHA3-256(key_type || pubkey)
let checksum = Sha3Hasher::hash(&data[..1 + pubkey_len]);
let key_len = raw_key.len();
data[1..1 + key_len].copy_from_slice(raw_key);

// Calculate checksum: SHA3-256(key_type || raw_key)
let checksum = Sha3Hasher::hash(&data[..1 + key_len]);

// Append first 5 bytes of checksum
let data_len = 1 + pubkey_len + 5;
data[1 + pubkey_len..data_len].copy_from_slice(&checksum[..5]);
// Append the truncated checksum
let data_len = 1 + key_len + CHECKSUM_LEN;
data[1 + key_len..data_len].copy_from_slice(&checksum[..CHECKSUM_LEN]);

// Write prefix
let prefix = b"keeta_";
output[..6].copy_from_slice(prefix);
let prefix_len = ADDRESS_PREFIX.len();
output[..prefix_len].copy_from_slice(ADDRESS_PREFIX);

// Base32 encode the data (RFC4648 lowercase, no padding)
let encoded = Base32Unpadded::encode(&data[..data_len], &mut output[6..]).unwrap();

6 + encoded.len()
let encoded = Base32Unpadded::encode(&data[..data_len], &mut output[prefix_len..]).unwrap();
prefix_len + encoded.len()
}

/// Compress secp pubkey to 33-byte format.
Expand Down Expand Up @@ -76,7 +75,7 @@ mod tests {
let pubkey = hex!("02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
let mut output = [0u8; MAX_ADDRESS_LEN];

let len = generate_address(&pubkey, Algorithm::Secp256k1, &mut output);
let len = generate_address(&pubkey, 0x00, &mut output);
let address = core::str::from_utf8(&output[..len]).unwrap();

// Format: keeta_ prefix + base32
Expand All @@ -86,9 +85,9 @@ mod tests {
.skip(6)
.all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)));

// Different algorithms produce different addresses
// Different key types produce different addresses
let mut output_r1 = [0u8; MAX_ADDRESS_LEN];
let len_r1 = generate_address(&pubkey, Algorithm::Secp256r1, &mut output_r1);
let len_r1 = generate_address(&pubkey, 0x06, &mut output_r1);
assert_ne!(&output[..len], &output_r1[..len_r1]);
}

Expand All @@ -97,7 +96,7 @@ mod tests {
let pubkey = [0x03u8; 32];
let mut output = [0u8; MAX_ADDRESS_LEN];

let len = generate_address(&pubkey, Algorithm::Ed25519, &mut output);
let len = generate_address(&pubkey, 0x01, &mut output);

assert!(output[..len].starts_with(b"keeta_"));
// Ed25519 (32-byte key) produces shorter address than secp (33-byte)
Expand Down
17 changes: 0 additions & 17 deletions src/crypto/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,6 @@ impl TryFrom<u8> for Algorithm {
}
}

impl Algorithm {
/// Determine algorithm from public key prefix byte.
/// Keeta public keys are prefixed with the algorithm type byte.
/// Falls back to Secp256k1 for unknown prefixes.
pub fn from_pubkey_prefix(pubkey: &[u8]) -> Self {
if pubkey.is_empty() {
return Algorithm::Secp256k1;
}
match pubkey[0] {
0x00 => Algorithm::Secp256k1,
0x01 => Algorithm::Ed25519,
0x06 => Algorithm::Secp256r1,
_ => Algorithm::Secp256k1,
}
}
}

// Curve orders (n)
pub const SECP256K1_ORDER: [u8; 32] = [
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0xBA, 0xAE, 0xDC,
Expand Down
5 changes: 2 additions & 3 deletions src/handlers/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,8 @@ pub fn derive_pubkey_and_address(
}
};

// Generate address
let address_len = generate_address(&pubkey_bytes[..pubkey_len], algorithm, address_buf);

// Generate address (key type = algorithm discriminant)
let address_len = generate_address(&pubkey_bytes[..pubkey_len], algorithm as u8, address_buf);
Ok((pubkey_bytes, pubkey_len, address_len))
}

Expand Down
25 changes: 18 additions & 7 deletions src/handlers/sign_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1582,12 +1582,18 @@ fn format_external_permissions(external: u64, buf: &mut [u8]) -> usize {
pos
}

/// Format a public key to an address string using a static buffer.
/// Returns a string reference to the formatted address.
fn format_address_to_str<'a>(pubkey: &[u8], buf: &'a mut [u8]) -> &'a str {
unsafe {
let len = generate_address(pubkey, Algorithm::from_pubkey_prefix(pubkey), buf);
core::str::from_utf8_unchecked(&buf[..len])
/// Format a raw key + key type to an address string using a static buffer.
fn address_to_str<'a>(raw_key: &[u8], key_type: u8, buf: &'a mut [u8]) -> &'a str {
let len = generate_address(raw_key, key_type, buf);
unsafe { core::str::from_utf8_unchecked(&buf[..len]) }
}

/// Format an on-chain account value (`keyType || rawKey`) to an address
/// string, mirroring node-rs `to_public_key_string`.
fn format_address_to_str<'a>(account: &[u8], buf: &'a mut [u8]) -> &'a str {
match account.split_first() {
Some((key_type, raw_key)) => address_to_str(raw_key, *key_type, buf),
None => "",
}
}

Expand Down Expand Up @@ -1618,7 +1624,12 @@ fn format_token_to_str<'a>(token_address: &[u8], buf: &'a mut [u8]) -> &'a str {
}
}

// Not in cache - fall back to full address
// Not in cache - fall back to full address.
// 33-byte tokens carry no key type; secp256k1 (0x00) is implied.
if token_address.len() == 33 {
return address_to_str(token_address, Algorithm::Secp256k1 as u8, buf);
}

format_address_to_str(token_address, buf)
}

Expand Down
76 changes: 75 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Speculos fixtures and APDU constants."""
"""Speculos fixtures, APDU constants, and reference-verifier runners."""

import json
import os
import shutil
import socket
import subprocess
import threading
from pathlib import Path

import pytest
import requests
Expand Down Expand Up @@ -42,6 +46,76 @@
)


FIXTURES_DIR = Path(__file__).parent / "fixtures"

RUST_VERIFIER_BIN = (
FIXTURES_DIR / "verify_signature_rs" / "target" / "release" / "verify_signature"
)

TS_VERIFIER_DIR = FIXTURES_DIR / "verify_signature_ts"
TS_VERIFIER_SCRIPT = TS_VERIFIER_DIR / "verify_signature.ts"
TS_VERIFIER_TSX = TS_VERIFIER_DIR / "node_modules" / ".bin" / "tsx"
TS_VERIFIER_CLIENT = (
TS_VERIFIER_DIR / "node_modules" / "@keetanetwork" / "keetanet-client"
)


def run_rust_verifier(args: list[str]) -> dict:
"""Run the node-rs reference verifier and return its parsed JSON output."""
if not RUST_VERIFIER_BIN.exists():
pytest.fail(
f"Rust verifier not built at {RUST_VERIFIER_BIN}; "
f"run 'cargo build --release' in tests/fixtures/verify_signature_rs/"
)

result = subprocess.run(
[str(RUST_VERIFIER_BIN), *args],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
pytest.fail(
f"rust verifier failed for {' '.join(args[:2])}: "
f"{result.stderr.strip() or result.stdout.strip()}"
)

return json.loads(result.stdout)


def run_ts_verifier(args: list[str]) -> dict:
"""Run the keetanet-client reference verifier and return its parsed JSON output."""
if shutil.which("node") is None:
pytest.fail("node runtime not found on PATH; install Node.js 20")

if not TS_VERIFIER_CLIENT.exists():
pytest.fail(
f"@keetanetwork/keetanet-client not installed at {TS_VERIFIER_CLIENT}; "
f"run 'npm ci' in {TS_VERIFIER_DIR}"
)

if not TS_VERIFIER_TSX.exists():
pytest.fail(
f"tsx not installed at {TS_VERIFIER_TSX}; run 'npm ci' in {TS_VERIFIER_DIR}"
)

result = subprocess.run(
[str(TS_VERIFIER_TSX), str(TS_VERIFIER_SCRIPT), *args],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
pytest.fail(
f"ts verifier failed for {' '.join(args[:2])}: "
f"{result.stderr.strip() or result.stdout.strip()}"
)

return json.loads(result.stdout)


class SpeculosClient:
"""APDU client over raw TCP socket."""

Expand Down
74 changes: 53 additions & 21 deletions tests/fixtures/verify_signature_rs/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ impl Display for VerifyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongArgc => {
write!(f, "usage: verify_signature <mode> <algorithm> <pubkey_hex> <input_hex> <sig_hex>")
write!(
f,
"usage: verify_signature <message|block> <algorithm> <pubkey_hex> <input_hex> <sig_hex>\n verify_signature address <algorithm> <pubkey_hex>"
)
}
Self::UnknownMode(v) => write!(f, "unknown mode '{v}': expected 'message' or 'block'"),
Self::UnknownMode(v) => write!(f, "unknown mode '{v}': expected 'message', 'block', or 'address'"),
Self::UnknownAlgorithm(v) => {
write!(f, "unknown algorithm '{v}': expected 'secp256k1', 'ed25519', or 'secp256r1'")
}
Expand All @@ -54,6 +57,7 @@ impl From<AccountError> for VerifyError {
enum Mode {
Message,
Block,
Address,
}

impl FromStr for Mode {
Expand All @@ -63,6 +67,7 @@ impl FromStr for Mode {
match s {
"message" => Ok(Self::Message),
"block" => Ok(Self::Block),
"address" => Ok(Self::Address),
_ => Err(VerifyError::UnknownMode(s.into())),
}
}
Expand Down Expand Up @@ -125,38 +130,65 @@ fn verify(account: &GenericAccount, digest_input: &[u8], signature: &[u8]) -> bo
}
}

fn run(args: &[String]) -> Result<bool, VerifyError> {
if args.len() != 5 {
return Err(VerifyError::WrongArgc);
}

let mode: Mode = args[0].parse()?;
let algorithm: SupportedAlgorithm = args[1].parse()?;
let pubkey_bytes = decode_hex("pubkey_hex", &args[2])?;
let input_bytes = decode_hex("input_hex", &args[3])?;
let sig_bytes = decode_hex("sig_hex", &args[4])?;
enum Output {
Valid(bool),
Address(String),
}

fn account_from_args(algorithm_arg: &str, pubkey_arg: &str) -> Result<GenericAccount, VerifyError> {
let algorithm: SupportedAlgorithm = algorithm_arg.parse()?;
let pubkey_bytes = decode_hex("pubkey_hex", pubkey_arg)?;
let any_pubkey = parse_pubkey(&algorithm, &pubkey_bytes)?;
let account = GenericAccount::try_from(any_pubkey)?;

// Ledger signs SHA3(x). `verify` pre-hashes its input with SHA3.
// message: x = message. block: x = SHA3(block_bytes), so pass the block hash.
let digest_input: Vec<u8> = match mode {
Mode::Message => input_bytes,
Mode::Block => hash_default(&input_bytes).to_vec(),
};
Ok(GenericAccount::try_from(any_pubkey)?)
}

fn run(args: &[String]) -> Result<Output, VerifyError> {
let mode: Mode = args.first().ok_or(VerifyError::WrongArgc)?.parse()?;

match mode {
Mode::Address => {
if args.len() != 3 {
return Err(VerifyError::WrongArgc);
}

// `Display` for `GenericAccount` renders the canonical `keeta_...` address.
let account = account_from_args(&args[1], &args[2])?;
Ok(Output::Address(account.to_string()))
}
Mode::Message | Mode::Block => {
if args.len() != 5 {
return Err(VerifyError::WrongArgc);
}

let account = account_from_args(&args[1], &args[2])?;
let input_bytes = decode_hex("input_hex", &args[3])?;
let sig_bytes = decode_hex("sig_hex", &args[4])?;

// Ledger signs SHA3(x). `verify` pre-hashes its input with SHA3.
// message: x = message. block: x = SHA3(block_bytes), so pass the block hash.
let digest_input: Vec<u8> = match mode {
Mode::Block => hash_default(&input_bytes).to_vec(),
_ => input_bytes,
};

Ok(verify(&account, &digest_input, &sig_bytes))
Ok(Output::Valid(verify(&account, &digest_input, &sig_bytes)))
}
}
}

fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();

match run(&args) {
Ok(valid) => {
Ok(Output::Valid(valid)) => {
println!("{{\"valid\":{valid}}}");
ExitCode::SUCCESS
}
Ok(Output::Address(address)) => {
println!("{{\"address\":\"{address}\"}}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("verify_signature: {e}");
ExitCode::FAILURE
Expand Down
Loading