use anyhow::{Context, Result};
use base64::{Engine, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::OsRng;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use crate::data::LedgerSig;
pub fn keys_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("cannot resolve home dir")?;
Ok(home.join(".agentdiff").join("keys"))
}
pub fn private_key_path() -> Result<PathBuf> {
Ok(keys_dir()?.join("private.key"))
}
pub fn public_key_path() -> Result<PathBuf> {
Ok(keys_dir()?.join("public.key"))
}
pub fn generate_keypair() -> Result<(PathBuf, PathBuf, String)> {
let priv_path = private_key_path()?;
if priv_path.exists() {
anyhow::bail!(
"signing key already exists at {}.\n\
Use 'agentdiff keys rotate' to rotate.",
priv_path.display()
);
}
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let dir = keys_dir()?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating keys dir {}", dir.display()))?;
let priv_b64 = STANDARD.encode(signing_key.to_bytes());
let pub_path = public_key_path()?;
std::fs::write(&priv_path, &priv_b64)
.with_context(|| format!("writing private key to {}", priv_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("setting permissions on {}", priv_path.display()))?;
}
let pub_b64 = STANDARD.encode(verifying_key.to_bytes());
std::fs::write(&pub_path, &pub_b64)
.with_context(|| format!("writing public key to {}", pub_path.display()))?;
let kid = compute_key_id(&verifying_key);
Ok((priv_path, pub_path, kid))
}
pub fn load_signing_key() -> Result<SigningKey> {
let path = private_key_path()?;
let b64 = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read private key at {}", path.display()))?;
let bytes = STANDARD
.decode(b64.trim())
.context("cannot base64-decode private key")?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("private key must be 32 bytes"))?;
Ok(SigningKey::from_bytes(&arr))
}
pub fn load_verifying_key() -> Result<VerifyingKey> {
let path = public_key_path()?;
let b64 = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read public key at {}", path.display()))?;
let bytes = STANDARD
.decode(b64.trim())
.context("cannot base64-decode public key")?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("public key must be 32 bytes"))?;
VerifyingKey::from_bytes(&arr).context("invalid ed25519 public key")
}
pub fn load_verifying_key_by_id(repo_root: &std::path::Path, key_id: &str) -> Result<VerifyingKey> {
let ref_path = format!("refs/agentdiff/keys/{}:pub.key", key_id);
let out = std::process::Command::new("git")
.args(["cat-file", "blob", &ref_path])
.current_dir(repo_root)
.output()
.context("git cat-file for key registry")?;
anyhow::ensure!(
out.status.success(),
"key '{}' not found in registry (refs/agentdiff/keys/{})",
key_id,
key_id
);
let b64 = String::from_utf8(out.stdout).context("key registry entry is not valid UTF-8")?;
let bytes = STANDARD
.decode(b64.trim())
.context("cannot base64-decode registry key")?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("registry public key must be 32 bytes"))?;
VerifyingKey::from_bytes(&arr).context("invalid ed25519 public key in registry")
}
pub fn generate_keypair_at(priv_path: &PathBuf, pub_path: &PathBuf) -> Result<(String, VerifyingKey)> {
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let priv_b64 = STANDARD.encode(signing_key.to_bytes());
std::fs::write(priv_path, &priv_b64)
.with_context(|| format!("writing private key to {}", priv_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(priv_path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("setting permissions on {}", priv_path.display()))?;
}
let pub_b64 = STANDARD.encode(verifying_key.to_bytes());
std::fs::write(pub_path, &pub_b64)
.with_context(|| format!("writing public key to {}", pub_path.display()))?;
let kid = compute_key_id(&verifying_key);
Ok((kid, verifying_key))
}
pub fn keys_exist() -> bool {
private_key_path().map(|p| p.exists()).unwrap_or(false)
&& public_key_path().map(|p| p.exists()).unwrap_or(false)
}
pub fn compute_key_id(vk: &VerifyingKey) -> String {
let mut hasher = Sha256::new();
hasher.update(vk.to_bytes());
let hash = hasher.finalize();
hash[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
}
pub fn sign_record(record: &serde_json::Value) -> Result<LedgerSig> {
let signing_key = load_signing_key().context("run 'agentdiff keys init' first")?;
let vk = signing_key.verifying_key();
let kid = compute_key_id(&vk);
let canonical = canonical_without_sig(record)?;
let sig: Signature = signing_key.sign(canonical.as_bytes());
Ok(LedgerSig {
alg: "ed25519".to_string(),
key_id: kid,
value: STANDARD.encode(sig.to_bytes()),
})
}
pub fn verify_record(record: &serde_json::Value, vk: &VerifyingKey) -> Result<()> {
let sig_obj = record
.get("sig")
.context("missing 'sig' field")?;
let sig_value = sig_obj
.get("value")
.and_then(|v| v.as_str())
.context("missing sig.value")?;
let sig_bytes = STANDARD
.decode(sig_value)
.context("cannot base64-decode sig.value")?;
let sig_arr: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| anyhow::anyhow!("signature must be 64 bytes"))?;
let sig = Signature::from_bytes(&sig_arr);
let canonical = canonical_without_sig(record)?;
vk.verify(canonical.as_bytes(), &sig)
.context("signature verification failed — entry may have been tampered with")
}
fn canonical_without_sig(record: &serde_json::Value) -> Result<String> {
let mut stripped = record.clone();
if let Some(obj) = stripped.as_object_mut() {
obj.remove("sig");
}
json_canon::to_string(&stripped).context("JCS canonicalization failed")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_signing_key() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
#[test]
fn test_sign_verify_round_trip() {
let signing_key = make_test_signing_key();
let vk = signing_key.verifying_key();
let record_json = serde_json::json!({
"sha": "abc123",
"ts": "2026-01-01T00:00:00Z",
"agent": "claude-code",
"model": "claude-opus-4-6",
"session_id": "sess-1",
"files_touched": ["src/main.rs"]
});
let canonical = canonical_without_sig(&record_json).unwrap();
let sig: Signature = signing_key.sign(canonical.as_bytes());
let kid = compute_key_id(&vk);
let mut signed = record_json.clone();
signed.as_object_mut().unwrap().insert(
"sig".to_string(),
serde_json::json!({
"alg": "ed25519",
"key_id": kid,
"value": STANDARD.encode(sig.to_bytes())
}),
);
assert!(verify_record(&signed, &vk).is_ok());
}
#[test]
fn test_tampered_sig_fails() {
let signing_key = make_test_signing_key();
let vk = signing_key.verifying_key();
let record_json = serde_json::json!({
"sha": "def456",
"agent": "cursor"
});
let canonical = canonical_without_sig(&record_json).unwrap();
let sig: Signature = signing_key.sign(canonical.as_bytes());
let mut sig_bytes = sig.to_bytes();
sig_bytes[0] ^= 0xff;
let mut signed = record_json.clone();
signed.as_object_mut().unwrap().insert(
"sig".to_string(),
serde_json::json!({
"alg": "ed25519",
"key_id": compute_key_id(&vk),
"value": STANDARD.encode(&sig_bytes)
}),
);
assert!(verify_record(&signed, &vk).is_err());
}
#[test]
fn test_jcs_determinism() {
let signing_key = make_test_signing_key();
let record = serde_json::json!({
"sha": "abc",
"z_field": "last",
"a_field": "first",
"agent": "claude-code"
});
let c1 = canonical_without_sig(&record).unwrap();
let c2 = canonical_without_sig(&record).unwrap();
assert_eq!(c1, c2);
let sig1: Signature = signing_key.sign(c1.as_bytes());
let sig2: Signature = signing_key.sign(c2.as_bytes());
assert_eq!(sig1.to_bytes(), sig2.to_bytes());
}
#[test]
fn test_canonical_excludes_sig_field() {
let record = serde_json::json!({
"sha": "abc",
"sig": {"alg": "ed25519", "key_id": "x", "value": "y"}
});
let without = serde_json::json!({ "sha": "abc" });
let c1 = canonical_without_sig(&record).unwrap();
let c2 = canonical_without_sig(&without).unwrap();
assert_eq!(c1, c2);
}
#[test]
fn test_compute_key_id_is_deterministic() {
let signing_key = make_test_signing_key();
let vk = signing_key.verifying_key();
let id1 = compute_key_id(&vk);
let id2 = compute_key_id(&vk);
assert_eq!(id1, id2);
assert_eq!(id1.len(), 16); }
}