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