src/signing.rs
Ref: Size: 12.7 KiB
use std::fs;
use std::path::{Path, PathBuf};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use git2::Oid;
use rand_core::OsRng;
use git2::{Repository, Sort};
use crate::error::Error;
use crate::event::Event;
/// Return the directory where signing keys are stored.
pub fn signing_key_dir() -> Result<PathBuf, Error> {
let config = dirs::config_dir().or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config"))
});
match config {
Some(dir) => Ok(dir.join("git-collab")),
None => Err(Error::Signing(
"cannot determine config directory: HOME is not set".to_string(),
)),
}
}
/// Detached signature data for an event commit.
/// Stored as separate blobs (`signature` and `pubkey`) in the commit tree,
/// alongside the plain `event.json`.
#[derive(Debug, Clone)]
pub struct DetachedSignature {
pub signature: String,
pub pubkey: String,
}
/// Result of verifying an event commit's signature.
#[derive(Debug, Clone, PartialEq)]
pub enum VerifyStatus {
/// Signature verified successfully against the embedded public key.
Valid,
/// Signature present but verification failed (tampered or wrong key).
Invalid,
/// No signature or pubkey field in event.json.
Missing,
/// Signature valid but key not in trusted set.
Untrusted,
}
/// Detailed verification result for a single commit.
#[derive(Debug, Clone)]
pub struct SignatureVerificationResult {
pub commit_id: Oid,
pub status: VerifyStatus,
pub pubkey: Option<String>,
pub error: Option<String>,
}
/// Generate an Ed25519 keypair and store it in `config_dir`.
///
/// Creates `config_dir` (with 0o700 permissions on Unix) if it doesn't exist.
/// Writes the private key (base64) to `{config_dir}/signing-key` with 0o600
/// permissions and the public key (base64) to `{config_dir}/signing-key.pub`.
pub fn generate_keypair(config_dir: &Path) -> Result<VerifyingKey, Error> {
// Create config dir if needed
if !config_dir.exists() {
fs::create_dir_all(config_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(config_dir, fs::Permissions::from_mode(0o700))?;
}
}
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
// Write private key
let sk_path = config_dir.join("signing-key");
let sk_b64 = STANDARD.encode(signing_key.to_bytes());
fs::write(&sk_path, &sk_b64)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&sk_path, fs::Permissions::from_mode(0o600))?;
}
// Write public key
let vk_path = config_dir.join("signing-key.pub");
let vk_b64 = STANDARD.encode(verifying_key.to_bytes());
fs::write(&vk_path, &vk_b64)?;
Ok(verifying_key)
}
/// Load the Ed25519 signing (private) key from `config_dir/signing-key`.
pub fn load_signing_key(config_dir: &Path) -> Result<SigningKey, Error> {
let path = config_dir.join("signing-key");
if !path.exists() {
return Err(Error::KeyNotFound);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path)?.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
eprintln!(
"warning: signing key {:?} has permissions {:04o} — should be 0600",
path, mode
);
}
}
let b64 = fs::read_to_string(&path)?;
let bytes = STANDARD
.decode(b64.trim())
.map_err(|e| Error::Signing(format!("invalid signing key base64: {}", e)))?;
let key_bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| Error::Signing("signing key must be 32 bytes".to_string()))?;
Ok(SigningKey::from_bytes(&key_bytes))
}
/// Load the Ed25519 verifying (public) key from `config_dir/signing-key.pub`.
pub fn load_verifying_key(config_dir: &Path) -> Result<VerifyingKey, Error> {
let path = config_dir.join("signing-key.pub");
if !path.exists() {
return Err(Error::KeyNotFound);
}
let b64 = fs::read_to_string(&path)?;
let bytes = STANDARD
.decode(b64.trim())
.map_err(|e| Error::Verification(format!("invalid verifying key base64: {}", e)))?;
let key_bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| Error::Verification("verifying key must be 32 bytes".to_string()))?;
VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::Verification(format!("invalid verifying key: {}", e)))
}
/// Serialize an Event to canonical JSON bytes with deterministic key ordering.
///
/// Relies on `serde_json::Map` being backed by `BTreeMap` (sorted keys) when
/// the `preserve_order` feature is **not** enabled. A test below verifies this
/// invariant so we catch breakage from transitive feature activation.
pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> {
let value = serde_json::to_value(event)?;
let json = serde_json::to_string(&value)?;
Ok(json.into_bytes())
}
/// Sign an Event with the given signing key, producing a detached signature.
pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<DetachedSignature, Error> {
let canonical = canonical_json(event)?;
let signature = signing_key.sign(&canonical);
let verifying_key = signing_key.verifying_key();
Ok(DetachedSignature {
signature: STANDARD.encode(signature.to_bytes()),
pubkey: STANDARD.encode(verifying_key.to_bytes()),
})
}
/// Verify a detached signature against an event and its public key.
///
/// Returns `Missing` if signature or pubkey fields are empty,
/// `Valid` if the signature checks out, `Invalid` otherwise.
pub fn verify_detached(
event: &Event,
sig: &DetachedSignature,
) -> Result<VerifyStatus, Error> {
if sig.signature.is_empty() || sig.pubkey.is_empty() {
return Ok(VerifyStatus::Missing);
}
let sig_bytes = match STANDARD.decode(&sig.signature) {
Ok(b) => b,
Err(_) => return Ok(VerifyStatus::Invalid),
};
let pubkey_bytes = match STANDARD.decode(&sig.pubkey) {
Ok(b) => b,
Err(_) => return Ok(VerifyStatus::Invalid),
};
let sig_array: [u8; 64] = match sig_bytes.try_into() {
Ok(a) => a,
Err(_) => return Ok(VerifyStatus::Invalid),
};
let key_array: [u8; 32] = match pubkey_bytes.try_into() {
Ok(a) => a,
Err(_) => return Ok(VerifyStatus::Invalid),
};
let signature = Signature::from_bytes(&sig_array);
let verifying_key = match VerifyingKey::from_bytes(&key_array) {
Ok(vk) => vk,
Err(_) => return Ok(VerifyStatus::Invalid),
};
let canonical = canonical_json(event)?;
match verifying_key.verify(&canonical, &signature) {
Ok(()) => Ok(VerifyStatus::Valid),
Err(_) => Ok(VerifyStatus::Invalid),
}
}
/// Walk the DAG for the given ref and verify every event commit's signature.
///
/// For each commit, reads `event.json`, `signature`, and `pubkey` blobs from the tree.
/// If signature/pubkey blobs are missing, marks as `Missing`.
///
/// Returns one `SignatureVerificationResult` per commit.
pub fn verify_ref(
repo: &Repository,
ref_name: &str,
) -> Result<Vec<SignatureVerificationResult>, Error> {
let tip = repo.refname_to_id(ref_name)?;
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
revwalk.push(tip)?;
let mut results = Vec::new();
for oid_result in revwalk {
let oid = oid_result?;
let commit = repo.find_commit(oid)?;
let tree = commit.tree()?;
// Read event.json
let event_entry = tree
.get_name("event.json")
.ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
let event_blob = repo.find_blob(event_entry.id())?;
let event: Event = serde_json::from_slice(event_blob.content())?;
// Read signature and pubkey blob OIDs
let sig_oid = tree.get_name("signature").map(|e| e.id());
let pubkey_oid = tree.get_name("pubkey").map(|e| e.id());
match (sig_oid, pubkey_oid) {
(Some(sid), Some(pid)) => {
let sig_blob = repo.find_blob(sid)?;
let pubkey_blob = repo.find_blob(pid)?;
let sig_str = std::str::from_utf8(sig_blob.content())
.unwrap_or("")
.trim()
.to_string();
let pubkey_str = std::str::from_utf8(pubkey_blob.content())
.unwrap_or("")
.trim()
.to_string();
let detached = DetachedSignature {
signature: sig_str.clone(),
pubkey: pubkey_str.clone(),
};
match verify_detached(&event, &detached)? {
VerifyStatus::Valid => {
results.push(SignatureVerificationResult {
commit_id: oid,
status: VerifyStatus::Valid,
pubkey: Some(pubkey_str),
error: None,
});
}
VerifyStatus::Invalid => {
results.push(SignatureVerificationResult {
commit_id: oid,
status: VerifyStatus::Invalid,
pubkey: Some(pubkey_str),
error: Some("invalid signature".to_string()),
});
}
VerifyStatus::Missing => {
results.push(SignatureVerificationResult {
commit_id: oid,
status: VerifyStatus::Missing,
pubkey: None,
error: Some("missing signature".to_string()),
});
}
VerifyStatus::Untrusted => {
results.push(SignatureVerificationResult {
commit_id: oid,
status: VerifyStatus::Untrusted,
pubkey: Some(pubkey_str),
error: Some("untrusted key".to_string()),
});
}
}
}
_ => {
results.push(SignatureVerificationResult {
commit_id: oid,
status: VerifyStatus::Missing,
pubkey: None,
error: Some("missing signature".to_string()),
});
}
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Action, Author};
#[test]
fn detached_signature_round_trip() {
let event = Event {
timestamp: "2026-03-21T00:00:00Z".to_string(),
author: Author {
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
},
action: Action::IssueOpen {
title: "Test".to_string(),
body: "Body".to_string(),
relates_to: None,
},
clock: 0,
};
let sk = SigningKey::generate(&mut rand_core::OsRng);
let sig = sign_event(&event, &sk).unwrap();
assert!(!sig.signature.is_empty());
assert!(!sig.pubkey.is_empty());
let status = verify_detached(&event, &sig).unwrap();
assert_eq!(status, VerifyStatus::Valid);
}
#[test]
fn canonical_json_keys_are_sorted() {
// Guard against serde_json's `preserve_order` feature being activated
// by a transitive dependency, which would break signature determinism.
let event = Event {
timestamp: "2026-03-21T00:00:00Z".to_string(),
author: Author {
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
},
action: Action::IssueOpen {
title: "Test".to_string(),
body: "Body".to_string(),
relates_to: None,
},
clock: 1,
};
let json = canonical_json(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&json).unwrap();
if let serde_json::Value::Object(map) = parsed {
let keys: Vec<&String> = map.keys().collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted, "top-level JSON keys must be alphabetically sorted");
} else {
panic!("expected JSON object");
}
}
}