use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use std::collections::HashMap;
pub type TrustedKeys = HashMap<String, VerifyingKey>;
pub fn valid_kid(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|c| !c.is_control() && c != '"' && c != '\\' && c != '\'' && c != '#')
}
pub fn load_trusted_keys(text: &str) -> Result<HashMap<String, VerifyingKey>, String> {
let doc: toml::Value =
toml::from_str(text).map_err(|e| format!("invalid trusted-keys file: {e}"))?;
let keys = doc
.get("keys")
.and_then(|v| v.as_array())
.ok_or_else(|| "trusted-keys file needs a [[keys]] array".to_string())?;
let mut out = HashMap::new();
for (i, entry) in keys.iter().enumerate() {
let id = entry
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("trusted-keys entry {i} needs a string `id`"))?;
if !valid_kid(id) {
return Err(format!("trusted-keys entry {i} has an invalid id"));
}
let public = entry
.get("public")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("trusted-keys entry '{id}' needs a hex `public` key"))?;
let bytes = hex_bytes(public).ok_or_else(|| {
format!("trusted-keys entry '{id}' has a malformed public key (want 64 hex chars)")
})?;
let key = VerifyingKey::from_bytes(&bytes)
.map_err(|_| format!("trusted-keys entry '{id}' is not a valid Ed25519 public key"))?;
if out.insert(id.to_string(), key).is_some() {
return Err(format!("duplicate trusted-keys id '{id}'"));
}
}
if out.is_empty() {
return Err("trusted-keys file contains no [[keys]] entries".to_string());
}
Ok(out)
}
pub fn load_secret_key(text: &str) -> Result<SigningKey, String> {
let bytes = hex_bytes(text.trim())
.ok_or_else(|| "secret key file must hold 64 hex chars (32-byte seed)".to_string())?;
Ok(SigningKey::from_bytes(&bytes))
}
pub fn generate_secret_key() -> Result<SigningKey, String> {
let mut seed = [0u8; 32];
getrandom::getrandom(&mut seed).map_err(|e| format!("OS randomness unavailable: {e}"))?;
Ok(SigningKey::from_bytes(&seed))
}
pub fn secret_hex(key: &SigningKey) -> String {
hex_encode(&key.to_bytes())
}
pub fn public_hex(key: &VerifyingKey) -> String {
hex_encode(key.as_bytes())
}
pub fn sign_bytes(key: &SigningKey, kid: &str, bytes: &[u8]) -> Result<String, String> {
if !valid_kid(kid) {
return Err("refusing to sign under an invalid key id".to_string());
}
let signature = key.sign(bytes);
Ok(format!(
"kid = \"{kid}\"\nsig = \"{}\"\n",
hex_encode(&signature.to_bytes())
))
}
pub struct Sidecar {
pub kid: String,
pub signature: Signature,
}
fn parse_sidecar(text: &str) -> Result<Sidecar, String> {
let doc: toml::Value =
toml::from_str(text).map_err(|_| "unparseable signature sidecar".to_string())?;
let kid = doc
.get("kid")
.and_then(|v| v.as_str())
.ok_or_else(|| "signature sidecar needs a string `kid`".to_string())?;
if !valid_kid(kid) {
return Err("signature sidecar holds an invalid key id".to_string());
}
let sig = doc
.get("sig")
.and_then(|v| v.as_str())
.ok_or_else(|| "signature sidecar needs a hex `sig`".to_string())?;
let bytes = hex_bytes::<64>(sig.trim())
.ok_or_else(|| "signature sidecar holds a malformed signature".to_string())?;
let signature = Signature::from_slice(&bytes)
.map_err(|_| "signature sidecar holds a malformed signature".to_string())?;
Ok(Sidecar {
kid: kid.to_string(),
signature,
})
}
pub fn verify_bytes(
file_label: &str,
bytes: &[u8],
sidecar_text: &str,
trusted: &HashMap<String, VerifyingKey>,
) -> Result<String, String> {
let sidecar = parse_sidecar(sidecar_text)?;
let key = trusted
.get(&sidecar.kid)
.ok_or_else(|| format!("{file_label}: unknown signing key '{}'", sidecar.kid))?;
key.verify(bytes, &sidecar.signature)
.map_err(|_| format!("{file_label}: signature mismatch (key '{}')", sidecar.kid))?;
Ok(sidecar.kid)
}
pub fn sidecar_path_for(path: &std::path::Path) -> std::path::PathBuf {
let mut named = path.as_os_str().to_owned();
named.push(".sig");
std::path::PathBuf::from(named)
}
pub fn verify_file(
path: &std::path::Path,
trusted: &HashMap<String, VerifyingKey>,
) -> Result<String, String> {
let label = path.to_string_lossy().into_owned();
let bytes =
std::fs::read(path).map_err(|e| format!("cannot read '{label}' for verification: {e}"))?;
let sidecar = read_sidecar(path, &label)?;
verify_bytes(&label, &bytes, &sidecar, trusted)
}
pub fn read_sidecar(path: &std::path::Path, label: &str) -> Result<String, String> {
let sidecar_path = sidecar_path_for(path);
let sidecar_label = sidecar_path.to_string_lossy().into_owned();
std::fs::read_to_string(&sidecar_path)
.map_err(|_| format!("{label}: unsigned (no signature sidecar at '{sidecar_label}')"))
}
fn hex_bytes<const N: usize>(text: &str) -> Option<[u8; N]> {
let text = text.trim();
if text.len() != 2 * N || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let mut out = [0u8; N];
for (i, chunk) in out.iter_mut().enumerate() {
*chunk = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}