Skip to main content

comindly_core/
signing.rs

1//! Optional SSH signatures on ledger records, with the key developers already sign git commits
2//! with (`ssh-keygen -Y`, namespace `comindly-ledger`). A signature that does not verify against
3//! `allowed_signers` drops the record to T0; unsigned records are unaffected.
4
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8use anyhow::{bail, Context, Result};
9
10use crate::ledger::Record;
11
12pub const NAMESPACE: &str = "comindly-ledger";
13
14pub use comindly_api::ledger::Signature;
15
16/// The bytes a signature covers: the record without its signature.
17pub fn canonical(rec: &Record) -> Vec<u8> {
18    let mut r = rec.clone();
19    r.signature = None;
20    serde_json::to_vec(&r).unwrap_or_default()
21}
22
23pub fn sign_with(rec: &mut Record, key: &Path) -> Result<()> {
24    let public = public_key(key)?;
25    let dir = scratch()?;
26    let data = dir.join("record.json");
27    std::fs::write(&data, canonical(rec))?;
28    let ok = Command::new("ssh-keygen")
29        .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
30        .arg(key)
31        .arg(&data)
32        .stdin(Stdio::null())
33        .stdout(Stdio::null())
34        .stderr(Stdio::piped())
35        .output()
36        .context("run ssh-keygen")?;
37    if !ok.status.success() {
38        let _ = std::fs::remove_dir_all(&dir);
39        bail!(
40            "ssh-keygen could not sign: {}",
41            String::from_utf8_lossy(&ok.stderr).trim()
42        );
43    }
44    let sig = std::fs::read_to_string(dir.join("record.json.sig"))?;
45    let _ = std::fs::remove_dir_all(&dir);
46    rec.signature = Some(Signature { key: public, sig });
47    Ok(())
48}
49
50/// Checks record signatures against an `allowed_signers` file, when there is one.
51#[derive(Debug, Clone, Default)]
52pub struct Verifier {
53    allowed: Option<PathBuf>,
54    cache: Option<PathBuf>,
55}
56
57impl Verifier {
58    /// `cache`: a folder that remembers results, so each record is checked once.
59    pub fn new(allowed: Option<PathBuf>, cache: Option<PathBuf>) -> Self {
60        Verifier { allowed, cache }
61    }
62
63    /// `Some(true)` verified, `Some(false)` forged or tampered or by an unknown key, `None` when
64    /// the record is unsigned or there is no `allowed_signers`. Results are cached by content.
65    pub fn verify(&self, rec: &Record) -> Option<bool> {
66        rec.signature.as_ref()?;
67        verify_with(rec, self.allowed.as_deref()?, self.cache.as_deref())
68    }
69}
70
71pub fn verify_with(rec: &Record, allowed: &Path, cache: Option<&Path>) -> Option<bool> {
72    let s = rec.signature.as_ref()?;
73    let allowed_text = std::fs::read(allowed).ok()?;
74    let mut h = blake3::Hasher::new();
75    h.update(&canonical(rec));
76    h.update(s.sig.as_bytes());
77    h.update(&allowed_text);
78    let cache = cache.map(|d| d.join(h.finalize().to_hex().as_str()));
79    if let Some(v) = cache.as_ref().and_then(|c| std::fs::read_to_string(c).ok()) {
80        return Some(v == "1");
81    }
82    let ok = check(rec, s, allowed).unwrap_or(false);
83    if let Some(c) = cache {
84        let _ = std::fs::create_dir_all(c.parent().unwrap_or(Path::new(".")));
85        let _ = std::fs::write(c, if ok { "1" } else { "0" });
86    }
87    Some(ok)
88}
89
90fn check(rec: &Record, s: &Signature, allowed: &Path) -> Result<bool> {
91    let dir = scratch()?;
92    let sig = dir.join("record.sig");
93    std::fs::write(&sig, &s.sig)?;
94    // Which allowed principal holds the signing key (used transiently, never stored).
95    let found = Command::new("ssh-keygen")
96        .args(["-Y", "find-principals", "-s"])
97        .arg(&sig)
98        .arg("-f")
99        .arg(allowed)
100        .output()?;
101    let principal = String::from_utf8_lossy(&found.stdout)
102        .lines()
103        .next()
104        .unwrap_or("")
105        .to_string();
106    if !found.status.success() || principal.is_empty() {
107        let _ = std::fs::remove_dir_all(&dir);
108        return Ok(false);
109    }
110    let mut child = Command::new("ssh-keygen")
111        .args(["-Y", "verify", "-n", NAMESPACE, "-I", &principal, "-f"])
112        .arg(allowed)
113        .arg("-s")
114        .arg(&sig)
115        .stdin(Stdio::piped())
116        .stdout(Stdio::null())
117        .stderr(Stdio::null())
118        .spawn()?;
119    {
120        use std::io::Write;
121        let mut stdin = child.stdin.take().context("stdin")?;
122        stdin.write_all(&canonical(rec))?;
123    }
124    let ok = child.wait()?.success();
125    let _ = std::fs::remove_dir_all(&dir);
126    Ok(ok)
127}
128
129fn public_key(key: &Path) -> Result<String> {
130    let pubfile = if key.extension().is_some_and(|e| e == "pub") {
131        key.to_path_buf()
132    } else {
133        PathBuf::from(format!("{}.pub", key.display()))
134    };
135    let text = std::fs::read_to_string(&pubfile)
136        .with_context(|| format!("read public key {}", pubfile.display()))?;
137    Ok(text
138        .split_whitespace()
139        .take(2)
140        .collect::<Vec<_>>()
141        .join(" "))
142}
143
144fn scratch() -> Result<PathBuf> {
145    let d = std::env::temp_dir().join(format!("comindly-sign-{}", uuid::Uuid::now_v7()));
146    std::fs::create_dir_all(&d)?;
147    Ok(d)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::ledger::{AuthorKind, Kind, Provenance};
154
155    #[test]
156    fn sign_verify_and_reject_tampering() {
157        if Command::new("ssh-keygen").arg("-?").output().is_err() {
158            return; // no OpenSSH on this machine
159        }
160        let dir = scratch().unwrap();
161        let key = dir.join("k");
162        let made = Command::new("ssh-keygen")
163            .args(["-q", "-t", "ed25519", "-N", "", "-C", "t", "-f"])
164            .arg(&key)
165            .status()
166            .unwrap();
167        assert!(made.success());
168        let public = public_key(&key).unwrap();
169        let allowed = dir.join("allowed");
170        std::fs::write(&allowed, format!("dev@example {public}\n")).unwrap();
171        let mut rec = Record::new(
172            Kind::Gotcha,
173            "ids are customer-scoped",
174            Vec::new(),
175            Provenance {
176                author: AuthorKind::Human,
177                actor: "a".into(),
178                source: "t".into(),
179                agent: None,
180                session: None,
181                name: None,
182                teams: Vec::new(),
183            },
184        );
185        sign_with(&mut rec, &key).unwrap();
186        assert_eq!(rec.signature.as_ref().unwrap().key, public);
187        assert_eq!(verify_with(&rec, &allowed, None), Some(true));
188        let mut forged = rec.clone();
189        forged.title = "ids are global".into();
190        assert_eq!(verify_with(&forged, &allowed, None), Some(false));
191        std::fs::write(&allowed, "").unwrap();
192        assert_eq!(
193            verify_with(&rec, &allowed, None),
194            Some(false),
195            "unknown key"
196        );
197        std::fs::remove_dir_all(&dir).ok();
198    }
199}