1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
10use std::collections::HashMap;
11
12pub type TrustedKeys = HashMap<String, VerifyingKey>;
15
16pub fn valid_kid(id: &str) -> bool {
22 !id.is_empty()
23 && id
24 .chars()
25 .all(|c| !c.is_control() && c != '"' && c != '\\' && c != '\'' && c != '#')
26}
27
28pub fn load_trusted_keys(text: &str) -> Result<HashMap<String, VerifyingKey>, String> {
32 let doc: toml::Value =
33 toml::from_str(text).map_err(|e| format!("invalid trusted-keys file: {e}"))?;
34 let keys = doc
35 .get("keys")
36 .and_then(|v| v.as_array())
37 .ok_or_else(|| "trusted-keys file needs a [[keys]] array".to_string())?;
38 let mut out = HashMap::new();
39 for (i, entry) in keys.iter().enumerate() {
40 let id = entry
41 .get("id")
42 .and_then(|v| v.as_str())
43 .ok_or_else(|| format!("trusted-keys entry {i} needs a string `id`"))?;
44 if !valid_kid(id) {
45 return Err(format!("trusted-keys entry {i} has an invalid id"));
46 }
47 let public = entry
48 .get("public")
49 .and_then(|v| v.as_str())
50 .ok_or_else(|| format!("trusted-keys entry '{id}' needs a hex `public` key"))?;
51 let bytes = hex_bytes(public).ok_or_else(|| {
52 format!("trusted-keys entry '{id}' has a malformed public key (want 64 hex chars)")
53 })?;
54 let key = VerifyingKey::from_bytes(&bytes)
55 .map_err(|_| format!("trusted-keys entry '{id}' is not a valid Ed25519 public key"))?;
56 if out.insert(id.to_string(), key).is_some() {
57 return Err(format!("duplicate trusted-keys id '{id}'"));
58 }
59 }
60 if out.is_empty() {
61 return Err("trusted-keys file contains no [[keys]] entries".to_string());
62 }
63 Ok(out)
64}
65
66pub fn load_secret_key(text: &str) -> Result<SigningKey, String> {
68 let bytes = hex_bytes(text.trim())
69 .ok_or_else(|| "secret key file must hold 64 hex chars (32-byte seed)".to_string())?;
70 Ok(SigningKey::from_bytes(&bytes))
71}
72
73pub fn generate_secret_key() -> Result<SigningKey, String> {
75 let mut seed = [0u8; 32];
76 getrandom::getrandom(&mut seed).map_err(|e| format!("OS randomness unavailable: {e}"))?;
77 Ok(SigningKey::from_bytes(&seed))
78}
79
80pub fn secret_hex(key: &SigningKey) -> String {
82 hex_encode(&key.to_bytes())
83}
84
85pub fn public_hex(key: &VerifyingKey) -> String {
87 hex_encode(key.as_bytes())
88}
89
90pub fn sign_bytes(key: &SigningKey, kid: &str, bytes: &[u8]) -> Result<String, String> {
94 if !valid_kid(kid) {
95 return Err("refusing to sign under an invalid key id".to_string());
96 }
97 let signature = key.sign(bytes);
98 Ok(format!(
99 "kid = \"{kid}\"\nsig = \"{}\"\n",
100 hex_encode(&signature.to_bytes())
101 ))
102}
103
104pub struct Sidecar {
106 pub kid: String,
107 pub signature: Signature,
108}
109
110fn parse_sidecar(text: &str) -> Result<Sidecar, String> {
115 let doc: toml::Value =
116 toml::from_str(text).map_err(|_| "unparseable signature sidecar".to_string())?;
117 let kid = doc
118 .get("kid")
119 .and_then(|v| v.as_str())
120 .ok_or_else(|| "signature sidecar needs a string `kid`".to_string())?;
121 if !valid_kid(kid) {
122 return Err("signature sidecar holds an invalid key id".to_string());
123 }
124 let sig = doc
125 .get("sig")
126 .and_then(|v| v.as_str())
127 .ok_or_else(|| "signature sidecar needs a hex `sig`".to_string())?;
128 let bytes = hex_bytes::<64>(sig.trim())
129 .ok_or_else(|| "signature sidecar holds a malformed signature".to_string())?;
130 let signature = Signature::from_slice(&bytes)
131 .map_err(|_| "signature sidecar holds a malformed signature".to_string())?;
132 Ok(Sidecar {
133 kid: kid.to_string(),
134 signature,
135 })
136}
137
138pub fn verify_bytes(
142 file_label: &str,
143 bytes: &[u8],
144 sidecar_text: &str,
145 trusted: &HashMap<String, VerifyingKey>,
146) -> Result<String, String> {
147 let sidecar = parse_sidecar(sidecar_text)?;
148 let key = trusted
149 .get(&sidecar.kid)
150 .ok_or_else(|| format!("{file_label}: unknown signing key '{}'", sidecar.kid))?;
151 key.verify(bytes, &sidecar.signature)
152 .map_err(|_| format!("{file_label}: signature mismatch (key '{}')", sidecar.kid))?;
153 Ok(sidecar.kid)
154}
155
156pub fn sidecar_path_for(path: &std::path::Path) -> std::path::PathBuf {
160 let mut named = path.as_os_str().to_owned();
161 named.push(".sig");
162 std::path::PathBuf::from(named)
163}
164
165pub fn verify_file(
171 path: &std::path::Path,
172 trusted: &HashMap<String, VerifyingKey>,
173) -> Result<String, String> {
174 let label = path.to_string_lossy().into_owned();
175 let bytes =
176 std::fs::read(path).map_err(|e| format!("cannot read '{label}' for verification: {e}"))?;
177 let sidecar = read_sidecar(path, &label)?;
178 verify_bytes(&label, &bytes, &sidecar, trusted)
179}
180
181pub fn read_sidecar(path: &std::path::Path, label: &str) -> Result<String, String> {
184 let sidecar_path = sidecar_path_for(path);
185 let sidecar_label = sidecar_path.to_string_lossy().into_owned();
186 std::fs::read_to_string(&sidecar_path)
187 .map_err(|_| format!("{label}: unsigned (no signature sidecar at '{sidecar_label}')"))
188}
189
190fn hex_bytes<const N: usize>(text: &str) -> Option<[u8; N]> {
193 let text = text.trim();
194 if text.len() != 2 * N || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
195 return None;
196 }
197 let mut out = [0u8; N];
198 for (i, chunk) in out.iter_mut().enumerate() {
199 *chunk = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).ok()?;
200 }
201 Some(out)
202}
203
204fn hex_encode(bytes: &[u8]) -> String {
205 bytes.iter().map(|b| format!("{b:02x}")).collect()
206}