a73x

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");
        }
    }
}