Skip to main content

active_core/
sign.rs

1//! Signed overlays (C-052, spec/threat-model.md §4).
2//!
3//! Detached Ed25519 signatures over raw file bytes: `<file>.sig`
4//! sidecars naming the signing key (`kid`) plus the hex signature.
5//! Verification resolves `kid` against a trusted-keys set; enforcement
6//! hooks into include loading via `ParseOptions` (E032). Key files
7//! are bearer credentials (0600, never committed, never logged).
8
9use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
10use std::collections::HashMap;
11
12/// A trusted key set: key id → public key. Cloned into the parser
13/// when overlay enforcement is on (small maps; no lifetimes).
14pub type TrustedKeys = HashMap<String, VerifyingKey>;
15
16/// Key-id charset: printable, no TOML metacharacters, no controls,
17/// non-empty. Enforced when writing sidecars, when parsing them,
18/// and when loading trusted sets — an id that cannot round-trip
19/// byte-identical through TOML is rejected everywhere (injection
20/// and log-forging resistance).
21pub 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
28/// Parse a trusted-keys TOML document (`[[keys]] id/public`) into an
29/// id-keyed map. Malformed entries are hard errors naming the entry,
30/// never key material.
31pub 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
66/// Parse a 64-hex-char Ed25519 secret seed (a `<name>.secret` file).
67pub 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
73/// Generate a fresh secret seed from OS randomness.
74pub 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
80/// Hex of a signing key's seed (for `<name>.secret` files).
81pub fn secret_hex(key: &SigningKey) -> String {
82    hex_encode(&key.to_bytes())
83}
84
85/// Hex of a verifying key (for the trusted-keys TOML).
86pub fn public_hex(key: &VerifyingKey) -> String {
87    hex_encode(key.as_bytes())
88}
89
90/// Sign raw bytes: the sidecar body for `<file>.sig`. The key id is
91/// validated first — raw interpolation into TOML would otherwise
92/// admit injection via quotes, escapes, or newlines.
93pub 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
104/// Parsed sidecar: the claiming key id plus the raw signature.
105pub struct Sidecar {
106    pub kid: String,
107    pub signature: Signature,
108}
109
110/// Parse a sidecar document. Malformed sidecars are hard errors
111/// (fail closed — never treated as unsigned). Key ids obey the
112/// same charset as written sidecars, so echoed ids in errors are
113/// log-safe by construction.
114fn 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
138/// Verify file bytes against sidecar text and a trusted-keys map.
139/// Returns the signing key id on success; every failure names the
140/// file and the reason, never key material.
141pub 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
156/// Sidecar path for a file: `.sig` appended to the full filename
157/// (`foo.active` ↔ `foo.active.sig`). Detached, so signatures
158/// survive formatters, and distinct from other tools' habits.
159pub 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
165/// Verify a file on disk plus its sibling sidecar: point-in-time
166/// attestation for operators (TOCTOU between check and use is the
167/// caller's concern). Enforcement paths that go on to PARSE the
168/// file must read once and call `verify_bytes` on the buffer
169/// instead — see the include hook.
170pub 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
181/// Read a sibling sidecar or report it missing (unsigned). Public
182/// so enforcement paths share the exact unsigned wording.
183pub 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
190/// Parse 2×N hex chars into N bytes (N=32 for keys, N=64 for
191/// signatures). Anything else is malformed — never guessed.
192fn 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}