use k256::ecdsa::signature::hazmat::PrehashSigner;
use k256::ecdsa::{RecoveryId, Signature, SigningKey};
use std::path::{Path, PathBuf};
use tiny_keccak::{Hasher as _, Keccak};
use crate::signers::local_file::{parse_key_file, KeyFileFormat};
use super::{eth::EthSigner, SignerError};
fn keccak256(bytes: &[u8]) -> [u8; 32] {
let mut h = Keccak::v256();
h.update(bytes);
let mut out = [0u8; 32];
h.finalize(&mut out);
out
}
pub fn eip55_checksum(addr: &[u8; 20]) -> String {
let lower = hex::encode(addr);
let hash = keccak256(lower.as_bytes());
let mut out = String::with_capacity(42);
out.push_str("0x");
for (i, c) in lower.chars().enumerate() {
if c.is_ascii_digit() {
out.push(c);
} else {
let nibble = if i % 2 == 0 {
hash[i / 2] >> 4
} else {
hash[i / 2] & 0x0f
};
if nibble >= 8 {
out.push(c.to_ascii_uppercase());
} else {
out.push(c);
}
}
}
out
}
#[derive(Debug)]
pub struct EthLocalFileSigner {
inner: SigningKey,
role: String,
file_path: PathBuf,
file_format: KeyFileFormat,
eth_address: String,
key_id: String,
}
impl EthLocalFileSigner {
pub fn from_env(role: &str) -> Result<Self, SignerError> {
let upper = role.to_uppercase();
let path_env = format!("SBO3L_ETH_LOCAL_FILE_PATH_{upper}");
let path_str = std::env::var(&path_env).map_err(|_| {
SignerError::Kms(format!(
"eth_local backend requires {path_env} to point at the role's secret key file"
))
})?;
Self::from_path(role, PathBuf::from(path_str))
}
pub fn from_path(role: &str, path: PathBuf) -> Result<Self, SignerError> {
let bytes = std::fs::read(&path).map_err(|e| {
SignerError::Kms(format!("eth_local read {} failed: {e}", path.display()))
})?;
let (secret, format) = parse_key_file(&bytes)?;
Self::from_secret_bytes(role, &path, secret, format)
}
fn from_secret_bytes(
role: &str,
path: &Path,
secret: [u8; 32],
format: KeyFileFormat,
) -> Result<Self, SignerError> {
let signing = SigningKey::from_bytes((&secret).into())
.map_err(|e| SignerError::Kms(format!("eth_local: invalid secp256k1 secret: {e}")))?;
let verifying = signing.verifying_key();
let encoded = verifying.to_encoded_point(false);
let pk_bytes = encoded.as_bytes();
debug_assert_eq!(pk_bytes.len(), 65);
debug_assert_eq!(pk_bytes[0], 0x04);
let hash = keccak256(&pk_bytes[1..]);
let mut addr = [0u8; 20];
addr.copy_from_slice(&hash[12..]);
let eth_address = eip55_checksum(&addr);
let key_id = key_id_for(role, path);
Ok(Self {
inner: signing,
role: role.to_string(),
file_path: path.to_path_buf(),
file_format: format,
eth_address,
key_id,
})
}
pub fn role(&self) -> &str {
&self.role
}
pub fn file_path(&self) -> &Path {
&self.file_path
}
pub fn file_format(&self) -> KeyFileFormat {
self.file_format
}
}
fn key_id_for(role: &str, path: &Path) -> String {
let upper = role.to_uppercase();
let env_name = format!("SBO3L_ETH_LOCAL_FILE_KEY_ID_{upper}");
if let Ok(explicit) = std::env::var(&env_name) {
if !explicit.is_empty() {
return explicit;
}
}
let basename = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("eth_local");
format!("{role}-eth-local-{basename}")
}
impl EthSigner for EthLocalFileSigner {
fn sign_digest_hex(&self, digest: &[u8; 32]) -> Result<String, SignerError> {
let (sig, recid): (Signature, RecoveryId) = self
.inner
.sign_prehash(digest)
.map_err(|e| SignerError::Kms(format!("eth_local sign_prehash: {e}")))?;
let mut out = Vec::with_capacity(65);
out.extend_from_slice(&sig.to_bytes());
out.push(recid.to_byte());
Ok(format!("0x{}", hex::encode(out)))
}
fn eth_address(&self) -> Result<String, SignerError> {
Ok(self.eth_address.clone())
}
fn key_id(&self) -> &str {
&self.key_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use k256::ecdsa::signature::hazmat::PrehashVerifier;
use std::io::Write;
fn write_temp(bytes: &[u8]) -> tempfile::NamedTempFile {
let f = tempfile::NamedTempFile::new().expect("temp");
let mut handle = f.reopen().expect("reopen");
handle.write_all(bytes).expect("write");
f
}
#[test]
fn from_path_round_trip_signs_and_verifies_against_recovered_pubkey() {
let f = write_temp(b"0x0101010101010101010101010101010101010101010101010101010101010101");
let signer = EthLocalFileSigner::from_path("audit", f.path().to_path_buf()).unwrap();
let digest: [u8; 32] = [0x42; 32];
let sig_hex = signer.sign_digest_hex(&digest).unwrap();
assert!(sig_hex.starts_with("0x"));
assert_eq!(sig_hex.len(), 132);
let raw = hex::decode(&sig_hex[2..]).unwrap();
assert_eq!(raw.len(), 65);
let mut sig_bytes = [0u8; 64];
sig_bytes.copy_from_slice(&raw[..64]);
let sig = Signature::from_slice(&sig_bytes).unwrap();
let recid = RecoveryId::try_from(raw[64]).unwrap();
let recovered =
k256::ecdsa::VerifyingKey::recover_from_prehash(&digest, &sig, recid).unwrap();
let encoded = recovered.to_encoded_point(false);
let hash = keccak256(&encoded.as_bytes()[1..]);
let mut addr = [0u8; 20];
addr.copy_from_slice(&hash[12..]);
let recovered_addr = eip55_checksum(&addr);
assert_eq!(recovered_addr, signer.eth_address().unwrap());
}
#[test]
fn signature_verifies_via_verifying_key_prehash_path() {
let f = write_temp(b"0x0202020202020202020202020202020202020202020202020202020202020202");
let signer = EthLocalFileSigner::from_path("audit", f.path().to_path_buf()).unwrap();
let digest: [u8; 32] = [0x77; 32];
let sig_hex = signer.sign_digest_hex(&digest).unwrap();
let raw = hex::decode(&sig_hex[2..]).unwrap();
let sig = Signature::from_slice(&raw[..64]).unwrap();
let vk = signer.inner.verifying_key();
vk.verify_prehash(&digest, &sig)
.expect("signature must verify against the local signer's verifying key");
}
#[test]
fn eip55_known_vector() {
let raw = hex::decode("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed").unwrap();
let mut addr = [0u8; 20];
addr.copy_from_slice(&raw);
assert_eq!(
eip55_checksum(&addr),
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
);
}
#[test]
fn raw32_bytes_path_works() {
let raw = [0x42u8; 32];
let f = write_temp(&raw);
let signer = EthLocalFileSigner::from_path("receipt", f.path().to_path_buf()).unwrap();
assert_eq!(signer.file_format(), KeyFileFormat::Raw32);
let sig = signer.sign_digest_hex(&[0u8; 32]).unwrap();
assert!(sig.starts_with("0x"));
assert_eq!(sig.len(), 132);
}
#[test]
fn key_id_defaults_to_role_and_basename() {
let f = write_temp(&[0x99u8; 32]);
let signer = EthLocalFileSigner::from_path("decision", f.path().to_path_buf()).unwrap();
assert!(signer.key_id().starts_with("decision-eth-local-"));
}
#[test]
fn missing_path_returns_clear_error() {
let path = PathBuf::from("/nonexistent/sbo3l/eth.hex");
let err = EthLocalFileSigner::from_path("audit", path).expect_err("must error");
match err {
SignerError::Kms(msg) => assert!(msg.contains("eth_local read"), "got: {msg}"),
other => panic!("expected Kms error, got {other:?}"),
}
}
#[test]
fn signature_byte_identical_across_two_constructions_with_same_seed() {
let secret = [0x10u8; 32];
let f1 = write_temp(&secret);
let f2 = write_temp(&secret);
let s1 = EthLocalFileSigner::from_path("audit", f1.path().to_path_buf()).unwrap();
let s2 = EthLocalFileSigner::from_path("audit", f2.path().to_path_buf()).unwrap();
let digest: [u8; 32] = [0x33; 32];
let sig1 = s1.sign_digest_hex(&digest).unwrap();
let sig2 = s2.sign_digest_hex(&digest).unwrap();
assert_eq!(
sig1, sig2,
"deterministic-k ECDSA must produce identical signatures"
);
assert_eq!(s1.eth_address().unwrap(), s2.eth_address().unwrap());
}
#[test]
fn eth_address_is_eip55_formatted() {
let f = write_temp(&[0x55u8; 32]);
let signer = EthLocalFileSigner::from_path("audit", f.path().to_path_buf()).unwrap();
let addr = signer.eth_address().unwrap();
assert!(addr.starts_with("0x"));
assert_eq!(addr.len(), 42);
let body = &addr[2..];
assert!(body
.chars()
.any(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
}
}