use ripemd::Ripemd160;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetCurve {
Ed25519,
Secp256k1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetChain {
StellarPubnet,
XrplLivenet,
XrplEvm,
HyperEvm,
Ethereum,
Base,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DerivationError {
InvalidPublicKey(String),
InvalidDerivedKey(String),
UnsupportedCombination { curve: TargetCurve, chain: TargetChain },
Encoding(String),
}
impl std::fmt::Display for DerivationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPublicKey(m) => write!(f, "invalid public key: {m}"),
Self::InvalidDerivedKey(m) => write!(f, "invalid derived key: {m}"),
Self::UnsupportedCombination { curve, chain } => {
write!(f, "unsupported curve/chain combination: {curve:?}/{chain:?}")
}
Self::Encoding(m) => write!(f, "encoding error: {m}"),
}
}
}
impl std::error::Error for DerivationError {}
pub const EPSILON_PREFIX: &str = "tenzro chain-derivation v1 epsilon:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainDerivation {
pub home_did: String,
pub path: String,
}
impl ChainDerivation {
pub fn new(home_did: impl Into<String>, path: impl Into<String>) -> Self {
Self {
home_did: home_did.into(),
path: path.into(),
}
}
fn epsilon_preimage(&self) -> Vec<u8> {
format!("{}{},{}", EPSILON_PREFIX, self.home_did, self.path).into_bytes()
}
pub fn derive_target_pubkey(
&self,
home_pk: &[u8],
curve: TargetCurve,
) -> Result<Vec<u8>, DerivationError> {
match curve {
TargetCurve::Secp256k1 => self.derive_secp256k1_pubkey(home_pk),
TargetCurve::Ed25519 => Err(DerivationError::InvalidPublicKey(
"Ed25519 additive point derivation is unsafe (cofactor 8 + EdDSA \
clamping); use derive_ed25519_keypair(seed) instead"
.to_string(),
)),
}
}
fn derive_secp256k1_pubkey(&self, home_pk: &[u8]) -> Result<Vec<u8>, DerivationError> {
use k256::elliptic_curve::sec1::{FromSec1Point, ToSec1Point};
use k256::elliptic_curve::Group;
use k256::{ProjectivePoint, PublicKey as K256PublicKey, Scalar, Sec1Point};
let enc = Sec1Point::from_bytes(home_pk)
.map_err(|e| DerivationError::InvalidPublicKey(format!("sec1 decode: {e}")))?;
let home = K256PublicKey::from_sec1_point(&enc);
let home: K256PublicKey = Option::from(home).ok_or_else(|| {
DerivationError::InvalidPublicKey("point not on secp256k1 curve".to_string())
})?;
let home_point = ProjectivePoint::from(*home.as_affine());
let digest = Sha256::digest(self.epsilon_preimage());
let epsilon = reduce_be_bytes_to_scalar(&digest);
if epsilon == Scalar::ZERO {
return Err(DerivationError::InvalidDerivedKey(
"epsilon reduced to zero scalar".to_string(),
));
}
let target_point = home_point + (ProjectivePoint::GENERATOR * epsilon);
if bool::from(target_point.is_identity()) {
return Err(DerivationError::InvalidDerivedKey(
"derived point is the identity".to_string(),
));
}
let affine = target_point.to_affine();
Ok(affine.to_sec1_point(false).as_bytes().to_vec())
}
pub fn derive_ed25519_keypair(
&self,
home_seed: &[u8; 32],
) -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) {
let i = hmac_sha512(b"ed25519 seed", home_seed);
let (mut k, mut chaincode) = split32(&i);
let path_hash = Sha256::digest(self.epsilon_preimage());
let idx_raw = u32::from_be_bytes([path_hash[0], path_hash[1], path_hash[2], path_hash[3]]);
let index = idx_raw | 0x8000_0000;
let mut data = Vec::with_capacity(1 + 32 + 4);
data.push(0x00);
data.extend_from_slice(&k);
data.extend_from_slice(&index.to_be_bytes());
let i_child = hmac_sha512(&chaincode, &data);
let (k_child, _cc_child) = split32(&i_child);
let signing = ed25519_dalek::SigningKey::from_bytes(&k_child);
let verifying = signing.verifying_key();
k.iter_mut().for_each(|b| *b = 0);
chaincode.iter_mut().for_each(|b| *b = 0);
(signing, verifying)
}
pub fn derive_target_address(
&self,
target_pk: &[u8],
chain: TargetChain,
) -> Result<String, DerivationError> {
match chain {
TargetChain::StellarPubnet => stellar_strkey(target_pk),
TargetChain::XrplLivenet => {
let curve = match target_pk.len() {
32 => TargetCurve::Ed25519,
33 if target_pk[0] == 0xED => TargetCurve::Ed25519,
33 => TargetCurve::Secp256k1,
_ => {
return Err(DerivationError::InvalidPublicKey(format!(
"XRPL pubkey must be 32 (ed25519) or 33 (sec1) bytes, got {}",
target_pk.len()
)))
}
};
xrpl_classic_address(target_pk, curve)
}
TargetChain::XrplEvm | TargetChain::HyperEvm | TargetChain::Ethereum | TargetChain::Base => {
evm_address(target_pk)
}
}
}
}
fn reduce_be_bytes_to_scalar(bytes: &[u8]) -> k256::Scalar {
use k256::elliptic_curve::ops::Reduce;
use k256::U256;
let n = U256::from_be_slice(bytes);
<k256::Scalar as Reduce<U256>>::reduce(&n)
}
fn hmac_sha512(key: &[u8], data: &[u8]) -> [u8; 64] {
use sha2::Sha512;
const BLOCK: usize = 128; let mut k = [0u8; BLOCK];
if key.len() > BLOCK {
let h = Sha512::digest(key);
k[..64].copy_from_slice(&h);
} else {
k[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= k[i];
opad[i] ^= k[i];
}
let mut inner = Sha512::new();
inner.update(ipad);
inner.update(data);
let inner = inner.finalize();
let mut outer = Sha512::new();
outer.update(opad);
outer.update(inner);
outer.finalize().into()
}
fn split32(i: &[u8; 64]) -> ([u8; 32], [u8; 32]) {
let mut a = [0u8; 32];
let mut b = [0u8; 32];
a.copy_from_slice(&i[..32]);
b.copy_from_slice(&i[32..]);
(a, b)
}
const STRKEY_VERSION_ED25519_PUBLIC: u8 = 6 << 3;
pub fn stellar_strkey(ed25519_pub: &[u8]) -> Result<String, DerivationError> {
if ed25519_pub.len() != 32 {
return Err(DerivationError::InvalidPublicKey(format!(
"Stellar ed25519 key must be 32 bytes, got {}",
ed25519_pub.len()
)));
}
let mut payload = Vec::with_capacity(1 + 32 + 2);
payload.push(STRKEY_VERSION_ED25519_PUBLIC);
payload.extend_from_slice(ed25519_pub);
let crc = crc16_xmodem(&payload);
payload.push((crc & 0xff) as u8);
payload.push((crc >> 8) as u8);
Ok(base32_rfc4648_nopad(&payload))
}
fn crc16_xmodem(data: &[u8]) -> u16 {
let mut crc: u16 = 0x0000;
for &b in data {
crc ^= (b as u16) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
crc
}
fn base32_rfc4648_nopad(data: &[u8]) -> String {
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut out = String::new();
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &byte in data {
buffer = (buffer << 8) | byte as u32;
bits += 8;
while bits >= 5 {
bits -= 5;
let idx = ((buffer >> bits) & 0x1f) as usize;
out.push(ALPHABET[idx] as char);
}
}
if bits > 0 {
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(ALPHABET[idx] as char);
}
out
}
const XRPL_ALPHABET: &[u8; 58] =
b"rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz";
const XRPL_ACCOUNT_PREFIX: u8 = 0x00;
const XRPL_ED25519_PREFIX: u8 = 0xED;
pub fn xrpl_classic_address(public_key: &[u8], curve: TargetCurve) -> Result<String, DerivationError> {
let canonical: Vec<u8> = match curve {
TargetCurve::Ed25519 => match public_key.len() {
32 => {
let mut v = Vec::with_capacity(33);
v.push(XRPL_ED25519_PREFIX);
v.extend_from_slice(public_key);
v
}
33 if public_key[0] == XRPL_ED25519_PREFIX => public_key.to_vec(),
n => {
return Err(DerivationError::InvalidPublicKey(format!(
"XRPL ed25519 key must be 32 bytes (or 33 with 0xED prefix), got {n}"
)))
}
},
TargetCurve::Secp256k1 => match public_key.len() {
33 => public_key.to_vec(), _ => {
return Err(DerivationError::InvalidPublicKey(
"XRPL secp256k1 key must be a 33-byte compressed SEC1 point".to_string(),
))
}
},
};
let sha = Sha256::digest(&canonical);
let account_id = Ripemd160::digest(sha);
let mut payload = Vec::with_capacity(1 + 20);
payload.push(XRPL_ACCOUNT_PREFIX);
payload.extend_from_slice(&account_id);
let check_full = Sha256::digest(Sha256::digest(&payload));
payload.extend_from_slice(&check_full[..4]);
let alphabet = bs58::Alphabet::new(XRPL_ALPHABET)
.map_err(|e| DerivationError::Encoding(format!("xrpl alphabet: {e}")))?;
Ok(bs58::encode(payload).with_alphabet(&alphabet).into_string())
}
pub fn evm_address(secp256k1_uncompressed_pub: &[u8]) -> Result<String, DerivationError> {
use sha3::Keccak256;
let xy: &[u8] = match secp256k1_uncompressed_pub.len() {
65 if secp256k1_uncompressed_pub[0] == 0x04 => &secp256k1_uncompressed_pub[1..],
64 => secp256k1_uncompressed_pub,
n => {
return Err(DerivationError::InvalidPublicKey(format!(
"EVM pubkey must be uncompressed (65 bytes with 0x04, or 64 X||Y), got {n}"
)))
}
};
let hash = Keccak256::digest(xy);
let addr = &hash[12..];
let lower_hex = hex::encode(addr);
let hash_of_hex = Keccak256::digest(lower_hex.as_bytes());
let mut out = String::with_capacity(2 + 40);
out.push_str("0x");
for (i, ch) in lower_hex.chars().enumerate() {
if ch.is_ascii_digit() {
out.push(ch);
} else {
let byte = hash_of_hex[i / 2];
let nibble = if i % 2 == 0 { byte >> 4 } else { byte & 0x0f };
if nibble >= 8 {
out.push(ch.to_ascii_uppercase());
} else {
out.push(ch);
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc16_xmodem_known_answer() {
assert_eq!(crc16_xmodem(b"123456789"), 0x31C3);
}
#[test]
fn base32_rfc4648_known_answers() {
assert_eq!(base32_rfc4648_nopad(b"f"), "MY");
assert_eq!(base32_rfc4648_nopad(b"fo"), "MZXQ");
assert_eq!(base32_rfc4648_nopad(b"foo"), "MZXW6");
assert_eq!(base32_rfc4648_nopad(b"foobar"), "MZXW6YTBOI");
}
#[test]
fn stellar_strkey_known_answer() {
let raw =
hex::decode("3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29")
.unwrap();
let g = stellar_strkey(&raw).unwrap();
assert_eq!(
g,
"GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH"
);
assert!(g.starts_with('G'));
assert_eq!(g.len(), 56);
}
#[test]
fn xrpl_ed25519_known_answer() {
let pubkey = hex::decode(
"ED9434799226374926EDA3B54B1B461B4ABF7237962EAE18528FEA67595397FA32",
)
.unwrap();
let r = xrpl_classic_address(&pubkey, TargetCurve::Ed25519).unwrap();
assert_eq!(r, "rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN");
let r2 = xrpl_classic_address(&pubkey[1..], TargetCurve::Ed25519).unwrap();
assert_eq!(r2, r);
}
#[test]
fn evm_address_eip55_known_answer() {
let mut pk = vec![0x04u8];
pk.extend_from_slice(
&hex::decode(
"79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798",
)
.unwrap(),
);
pk.extend_from_slice(
&hex::decode(
"483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8",
)
.unwrap(),
);
let addr = evm_address(&pk).unwrap();
assert_eq!(addr, "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
}
#[test]
fn secp256k1_additive_derivation_matches_manual_tweak() {
use k256::elliptic_curve::sec1::ToSec1Point;
use k256::{ProjectivePoint, PublicKey as K256PublicKey};
let mut home = vec![0x04u8];
home.extend_from_slice(
&hex::decode("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798")
.unwrap(),
);
home.extend_from_slice(
&hex::decode("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8")
.unwrap(),
);
let d = ChainDerivation::new("did:tenzro:machine:abc", "hyperevm:1");
let derived = d.derive_target_pubkey(&home, TargetCurve::Secp256k1).unwrap();
let derived2 = d.derive_target_pubkey(&home, TargetCurve::Secp256k1).unwrap();
assert_eq!(derived, derived2);
assert_eq!(derived.len(), 65);
assert_eq!(derived[0], 0x04);
let epsilon = reduce_be_bytes_to_scalar(&Sha256::digest(d.epsilon_preimage()));
let expected = ProjectivePoint::GENERATOR + (ProjectivePoint::GENERATOR * epsilon);
let expected_pk =
K256PublicKey::from_affine(expected.to_affine()).unwrap();
assert_eq!(
derived,
expected_pk.to_sec1_point(false).as_bytes().to_vec()
);
let d2 = ChainDerivation::new("did:tenzro:machine:abc", "xrpl_evm:1");
let other = d2.derive_target_pubkey(&home, TargetCurve::Secp256k1).unwrap();
assert_ne!(derived, other);
}
#[test]
fn ed25519_additive_path_is_rejected() {
let d = ChainDerivation::new("did:tenzro:machine:abc", "stellar:1");
let res = d.derive_target_pubkey(&[0u8; 32], TargetCurve::Ed25519);
assert!(matches!(res, Err(DerivationError::InvalidPublicKey(_))));
}
#[test]
fn ed25519_slip10_keypair_is_deterministic_and_path_distinct() {
let seed = [7u8; 32];
let d1 = ChainDerivation::new("did:tenzro:machine:abc", "stellar:1");
let (sk1, vk1) = d1.derive_ed25519_keypair(&seed);
let (sk1b, vk1b) = d1.derive_ed25519_keypair(&seed);
assert_eq!(sk1.to_bytes(), sk1b.to_bytes());
assert_eq!(vk1.to_bytes(), vk1b.to_bytes());
let d2 = ChainDerivation::new("did:tenzro:machine:abc", "stellar:2");
let (_sk2, vk2) = d2.derive_ed25519_keypair(&seed);
assert_ne!(vk1.to_bytes(), vk2.to_bytes());
let g = stellar_strkey(&vk1.to_bytes()).unwrap();
assert!(g.starts_with('G') && g.len() == 56);
}
#[test]
fn slip10_ed25519_master_and_hardened_child_known_answer() {
let seed = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
let i = hmac_sha512(b"ed25519 seed", &seed);
let (k, _cc) = split32(&i);
assert_eq!(
hex::encode(k),
"2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7"
);
let (k_master, cc_master) = split32(&i);
let mut data = vec![0x00u8];
data.extend_from_slice(&k_master);
data.extend_from_slice(&0x8000_0000u32.to_be_bytes());
let i_child = hmac_sha512(&cc_master, &data);
let (k_child, _) = split32(&i_child);
assert_eq!(
hex::encode(k_child),
"68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3"
);
}
#[test]
fn full_pipeline_stellar_and_evm() {
let seed = [0x11u8; 32];
let d = ChainDerivation::new("did:tenzro:machine:xyz", "stellar:1");
let (_sk, vk) = d.derive_ed25519_keypair(&seed);
let g = d
.derive_target_address(&vk.to_bytes(), TargetChain::StellarPubnet)
.unwrap();
assert!(g.starts_with('G'));
let mut home = vec![0x04u8];
home.extend_from_slice(
&hex::decode("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798")
.unwrap(),
);
home.extend_from_slice(
&hex::decode("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8")
.unwrap(),
);
let de = ChainDerivation::new("did:tenzro:machine:xyz", "hyperevm:1");
let tpk = de.derive_target_pubkey(&home, TargetCurve::Secp256k1).unwrap();
let addr = de.derive_target_address(&tpk, TargetChain::HyperEvm).unwrap();
assert!(addr.starts_with("0x") && addr.len() == 42);
}
}