use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::ledger::Record;
pub const NAMESPACE: &str = "comindly-ledger";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signature {
pub key: String,
pub sig: String,
}
pub fn canonical(rec: &Record) -> Vec<u8> {
let mut r = rec.clone();
r.signature = None;
serde_json::to_vec(&r).unwrap_or_default()
}
pub fn signing_key() -> Option<PathBuf> {
if let Some(k) =
env("COMINDLY_SIGNING_KEY").or_else(|| crate::config::Config::load().signing_key)
{
return Some(expand(&k));
}
let cfg = git2::Config::open_default().ok()?;
if cfg.get_string("gpg.format").ok()? != "ssh" {
return None;
}
let k = cfg.get_string("user.signingkey").ok()?;
(!k.starts_with("key::")).then(|| expand(&k))
}
pub fn allowed_signers() -> Option<PathBuf> {
let p = env("COMINDLY_ALLOWED_SIGNERS")
.or_else(|| crate::config::Config::load().allowed_signers)
.or_else(|| {
git2::Config::open_default()
.ok()?
.get_string("gpg.ssh.allowedSignersFile")
.ok()
})?;
Some(expand(&p)).filter(|p| p.is_file())
}
pub fn sign(rec: &mut Record) -> Result<()> {
match signing_key() {
Some(key) => sign_with(rec, &key),
None => Ok(()),
}
}
pub fn sign_with(rec: &mut Record, key: &Path) -> Result<()> {
let public = public_key(key)?;
let dir = scratch()?;
let data = dir.join("record.json");
std::fs::write(&data, canonical(rec))?;
let ok = Command::new("ssh-keygen")
.args(["-Y", "sign", "-n", NAMESPACE, "-f"])
.arg(key)
.arg(&data)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.context("run ssh-keygen")?;
if !ok.status.success() {
let _ = std::fs::remove_dir_all(&dir);
bail!(
"ssh-keygen could not sign: {}",
String::from_utf8_lossy(&ok.stderr).trim()
);
}
let sig = std::fs::read_to_string(dir.join("record.json.sig"))?;
let _ = std::fs::remove_dir_all(&dir);
rec.signature = Some(Signature { key: public, sig });
Ok(())
}
pub fn verify(rec: &Record) -> Option<bool> {
rec.signature.as_ref()?;
verify_with(rec, &allowed_signers()?)
}
pub fn verify_with(rec: &Record, allowed: &Path) -> Option<bool> {
let s = rec.signature.as_ref()?;
let allowed_text = std::fs::read(allowed).ok()?;
let mut h = blake3::Hasher::new();
h.update(&canonical(rec));
h.update(s.sig.as_bytes());
h.update(&allowed_text);
let cache = cache_dir().map(|d| d.join(h.finalize().to_hex().as_str()));
if let Some(v) = cache.as_ref().and_then(|c| std::fs::read_to_string(c).ok()) {
return Some(v == "1");
}
let ok = check(rec, s, allowed).unwrap_or(false);
if let Some(c) = cache {
let _ = std::fs::create_dir_all(c.parent().unwrap_or(Path::new(".")));
let _ = std::fs::write(c, if ok { "1" } else { "0" });
}
Some(ok)
}
fn check(rec: &Record, s: &Signature, allowed: &Path) -> Result<bool> {
let dir = scratch()?;
let sig = dir.join("record.sig");
std::fs::write(&sig, &s.sig)?;
let found = Command::new("ssh-keygen")
.args(["-Y", "find-principals", "-s"])
.arg(&sig)
.arg("-f")
.arg(allowed)
.output()?;
let principal = String::from_utf8_lossy(&found.stdout)
.lines()
.next()
.unwrap_or("")
.to_string();
if !found.status.success() || principal.is_empty() {
let _ = std::fs::remove_dir_all(&dir);
return Ok(false);
}
let mut child = Command::new("ssh-keygen")
.args(["-Y", "verify", "-n", NAMESPACE, "-I", &principal, "-f"])
.arg(allowed)
.arg("-s")
.arg(&sig)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
{
use std::io::Write;
let mut stdin = child.stdin.take().context("stdin")?;
stdin.write_all(&canonical(rec))?;
}
let ok = child.wait()?.success();
let _ = std::fs::remove_dir_all(&dir);
Ok(ok)
}
fn public_key(key: &Path) -> Result<String> {
let pubfile = if key.extension().is_some_and(|e| e == "pub") {
key.to_path_buf()
} else {
PathBuf::from(format!("{}.pub", key.display()))
};
let text = std::fs::read_to_string(&pubfile)
.with_context(|| format!("read public key {}", pubfile.display()))?;
Ok(text
.split_whitespace()
.take(2)
.collect::<Vec<_>>()
.join(" "))
}
fn scratch() -> Result<PathBuf> {
let d = std::env::temp_dir().join(format!("comindly-sign-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&d)?;
Ok(d)
}
fn cache_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/comindly/verified"))
}
fn env(k: &str) -> Option<String> {
std::env::var(k).ok().filter(|v| !v.is_empty())
}
fn expand(p: &str) -> PathBuf {
match (p.strip_prefix("~/"), std::env::var_os("HOME")) {
(Some(rest), Some(h)) => PathBuf::from(h).join(rest),
_ => PathBuf::from(p),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ledger::{AuthorKind, Kind, Provenance};
#[test]
fn sign_verify_and_reject_tampering() {
if Command::new("ssh-keygen").arg("-?").output().is_err() {
return; }
let dir = scratch().unwrap();
let key = dir.join("k");
let made = Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "t", "-f"])
.arg(&key)
.status()
.unwrap();
assert!(made.success());
let public = public_key(&key).unwrap();
let allowed = dir.join("allowed");
std::fs::write(&allowed, format!("dev@example {public}\n")).unwrap();
let mut rec = Record::new(
Kind::Gotcha,
"ids are customer-scoped",
Vec::new(),
Provenance {
author: AuthorKind::Human,
actor: "a".into(),
source: "t".into(),
agent: None,
session: None,
name: None,
},
);
sign_with(&mut rec, &key).unwrap();
assert_eq!(rec.signature.as_ref().unwrap().key, public);
assert_eq!(verify_with(&rec, &allowed), Some(true));
let mut forged = rec.clone();
forged.title = "ids are global".into();
assert_eq!(verify_with(&forged, &allowed), Some(false));
std::fs::write(&allowed, "").unwrap();
assert_eq!(verify_with(&rec, &allowed), Some(false), "unknown key");
std::fs::remove_dir_all(&dir).ok();
}
}