use std::path::{Path, PathBuf};
use crate::signer::DevSigner;
use super::{Signer, SignerError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyFileFormat {
Hex64,
Raw32,
}
pub fn parse_key_file(bytes: &[u8]) -> Result<([u8; 32], KeyFileFormat), SignerError> {
let trimmed: Vec<u8> = bytes
.iter()
.copied()
.filter(|b| !b.is_ascii_whitespace())
.collect();
let trimmed_slice: &[u8] = if trimmed.starts_with(b"0x") || trimmed.starts_with(b"0X") {
&trimmed[2..]
} else {
&trimmed
};
if trimmed_slice.len() == 64 && trimmed_slice.iter().all(|b| b.is_ascii_hexdigit()) {
let decoded = hex::decode(trimmed_slice)?;
let arr: [u8; 32] = decoded
.try_into()
.expect("64 hex chars decode to exactly 32 bytes");
return Ok((arr, KeyFileFormat::Hex64));
}
if bytes.len() == 32 {
let arr: [u8; 32] = bytes
.try_into()
.expect("len 32 array conversion infallible");
return Ok((arr, KeyFileFormat::Raw32));
}
Err(SignerError::Kms(format!(
"key file must be 64 hex chars or 32 raw bytes; got {} bytes (post-trim {} chars)",
bytes.len(),
trimmed_slice.len()
)))
}
#[derive(Debug)]
pub struct LocalFileSigner {
inner: DevSigner,
role: String,
file_path: PathBuf,
file_format: KeyFileFormat,
}
impl LocalFileSigner {
pub fn from_env(role: &str) -> Result<Self, SignerError> {
let upper = role.to_uppercase();
let path_env = format!("SBO3L_LOCAL_FILE_PATH_{upper}");
let path_str = std::env::var(&path_env).map_err(|_| {
SignerError::Kms(format!(
"local_file 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!("local_file read {} failed: {e}", path.display()))
})?;
let (seed, format) = parse_key_file(&bytes)?;
let key_id = key_id_for(role, &path);
Ok(Self {
inner: DevSigner::from_seed(key_id, seed),
role: role.to_string(),
file_path: path,
file_format: format,
})
}
pub fn role(&self) -> &str {
&self.role
}
pub fn file_path(&self) -> &PathBuf {
&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_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("local_file");
format!("{role}-local-file-{basename}")
}
impl Signer for LocalFileSigner {
fn sign_hex(&self, message: &[u8]) -> Result<String, SignerError> {
Ok(self.inner.sign_hex(message))
}
fn verifying_key_hex(&self) -> Result<String, SignerError> {
Ok(self.inner.verifying_key_hex())
}
fn key_id(&self) -> &str {
&self.inner.key_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signer::verify_hex;
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 parse_hex64_with_prefix_and_newline() {
let mut bytes = Vec::from(b"0x".as_slice());
bytes.extend_from_slice(&[b'a'; 64]);
bytes.push(b'\n');
let (arr, fmt) = parse_key_file(&bytes).unwrap();
assert_eq!(arr, [0xaa; 32]);
assert_eq!(fmt, KeyFileFormat::Hex64);
}
#[test]
fn parse_hex64_without_prefix() {
let bytes = vec![b'b'; 64];
let (arr, fmt) = parse_key_file(&bytes).unwrap();
assert_eq!(arr, [0xbb; 32]);
assert_eq!(fmt, KeyFileFormat::Hex64);
}
#[test]
fn parse_raw32_bytes() {
let bytes = [0x42u8; 32];
let (arr, fmt) = parse_key_file(&bytes).unwrap();
assert_eq!(arr, [0x42; 32]);
assert_eq!(fmt, KeyFileFormat::Raw32);
}
#[test]
fn parse_rejects_short_input() {
let bytes = [0x42u8; 16];
assert!(parse_key_file(&bytes).is_err());
}
#[test]
fn parse_rejects_invalid_hex() {
let bytes = vec![b'g'; 64];
assert!(parse_key_file(&bytes).is_err());
}
#[test]
fn from_path_round_trip_signs_and_verifies() {
let f = write_temp(b"0x0101010101010101010101010101010101010101010101010101010101010101");
let signer = LocalFileSigner::from_path("audit", f.path().to_path_buf()).unwrap();
let msg = b"hello world";
let sig_hex = signer.sign_hex(msg).unwrap();
let pk_hex = signer.verifying_key_hex().unwrap();
assert!(verify_hex(&pk_hex, msg, &sig_hex).is_ok());
assert_eq!(signer.role(), "audit");
assert_eq!(signer.file_format(), KeyFileFormat::Hex64);
}
#[test]
fn from_path_raw32_works() {
let raw = [0x77u8; 32];
let f = write_temp(&raw);
let signer = LocalFileSigner::from_path("receipt", f.path().to_path_buf()).unwrap();
assert_eq!(signer.file_format(), KeyFileFormat::Raw32);
let sig = signer.sign_hex(b"x").unwrap();
let pk = signer.verifying_key_hex().unwrap();
assert!(verify_hex(&pk, b"x", &sig).is_ok());
}
#[test]
fn key_id_defaults_to_role_and_basename() {
let f = write_temp(&[0x99u8; 32]);
let signer = LocalFileSigner::from_path("decision", f.path().to_path_buf()).unwrap();
let id = signer.key_id();
assert!(id.starts_with("decision-local-file-"));
}
#[test]
fn missing_path_returns_clear_error() {
let path = PathBuf::from("/nonexistent/sbo3l/key.hex");
let err = LocalFileSigner::from_path("audit", path).expect_err("must error");
match err {
SignerError::Kms(msg) => {
assert!(msg.contains("local_file read"), "got: {msg}");
}
other => panic!("expected Kms error, got {other:?}"),
}
}
#[test]
fn signature_byte_identical_across_two_constructions_with_same_seed() {
let f1 = write_temp(&[0x10u8; 32]);
let f2 = write_temp(&[0x10u8; 32]);
let s1 = LocalFileSigner::from_path("audit", f1.path().to_path_buf()).unwrap();
let s2 = LocalFileSigner::from_path("audit", f2.path().to_path_buf()).unwrap();
let msg = b"the same message";
assert_eq!(s1.sign_hex(msg).unwrap(), s2.sign_hex(msg).unwrap());
assert_eq!(
s1.verifying_key_hex().unwrap(),
s2.verifying_key_hex().unwrap()
);
}
}