a73x

924edb8c

Add key trust allowlist for sync verification

a73x   2026-03-21 07:49

Adds a trusted keys mechanism so sync only accepts events from
authorized collaborators. Without this, any valid Ed25519 key was
accepted — an attacker with remote write access could forge events.

- New `src/trust.rs` module for trusted key file management
- CLI commands: `collab key add/list/remove` with --self and --label
- Trust checking integrated into sync as post-verification filter
- Trusted keys stored at .git/collab/trusted-keys (project-local)
- SSH authorized_keys style format (base64-key space label)
- Graceful fallback: warns when no trust policy configured
- Whole-ref rejection if any commit is from an untrusted key
- 125 tests total (32 new trust-related tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/cli.rs b/src/cli.rs
index 506ce77..d38fde8 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -40,6 +40,10 @@ pub enum Commands {
        #[arg(long)]
        force: bool,
    },

    /// Manage trusted keys
    #[command(subcommand)]
    Key(KeyCmd),
}

#[derive(Subcommand)]
@@ -127,6 +131,28 @@ pub enum IssueCmd {
}

#[derive(Subcommand)]
pub enum KeyCmd {
    /// Add a trusted public key
    Add {
        /// Base64-encoded Ed25519 public key
        pubkey: Option<String>,
        /// Read public key from your own signing key
        #[arg(long = "self")]
        self_key: bool,
        /// Human-readable label for the key
        #[arg(long)]
        label: Option<String>,
    },
    /// List trusted public keys
    List,
    /// Remove a trusted public key
    Remove {
        /// Base64-encoded public key to remove
        pubkey: String,
    },
}

#[derive(Subcommand)]
pub enum PatchCmd {
    /// Create a new patch for review
    Create {
diff --git a/src/error.rs b/src/error.rs
index 5931733..2295ef2 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -22,4 +22,7 @@ pub enum Error {

    #[error("no signing key found — run 'collab init-key' to generate one")]
    KeyNotFound,

    #[error("untrusted key: {0}")]
    UntrustedKey(String),
}
diff --git a/src/lib.rs b/src/lib.rs
index 5a806e4..5f3d67a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,10 +8,11 @@ pub mod patch;
pub mod state;
pub mod signing;
pub mod sync;
pub mod trust;
pub mod tui;

use base64::Engine;
use cli::{Commands, IssueCmd, PatchCmd};
use cli::{Commands, IssueCmd, KeyCmd, PatchCmd};
use event::ReviewVerdict;
use git2::Repository;
use state::{IssueStatus, PatchStatus};
@@ -268,5 +269,75 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
            println!("Public key: {}", pubkey_b64);
            Ok(())
        }
        Commands::Key(cmd) => match cmd {
            KeyCmd::Add {
                pubkey,
                self_key,
                label,
            } => {
                if self_key && pubkey.is_some() {
                    return Err(error::Error::Cmd(
                        "cannot specify both --self and a public key argument".to_string(),
                    ));
                }
                let key = if self_key {
                    let config_dir = signing::signing_key_dir()?;
                    let vk = signing::load_verifying_key(&config_dir)?;
                    base64::engine::general_purpose::STANDARD.encode(vk.to_bytes())
                } else {
                    match pubkey {
                        Some(k) => k,
                        None => {
                            return Err(error::Error::Cmd(
                                "public key argument required (or use --self)".to_string(),
                            ));
                        }
                    }
                };
                trust::validate_pubkey(&key)?;
                let added = trust::save_trusted_key(repo, &key, label.as_deref())?;
                if added {
                    let label_display = label
                        .as_ref()
                        .map(|l| format!(" ({})", l))
                        .unwrap_or_default();
                    println!("Trusted key added: {}{}", key, label_display);
                } else {
                    println!("Key {} is already trusted.", key);
                }
                Ok(())
            }
            KeyCmd::List => {
                let policy = trust::load_trust_policy(repo)?;
                match policy {
                    trust::TrustPolicy::Unconfigured => {
                        println!("No trusted keys configured.");
                    }
                    trust::TrustPolicy::Configured(keys) => {
                        if keys.is_empty() {
                            println!("No trusted keys configured.");
                        } else {
                            for k in &keys {
                                match &k.label {
                                    Some(l) => println!("{}  {}", k.pubkey, l),
                                    None => println!("{}", k.pubkey),
                                }
                            }
                        }
                    }
                }
                Ok(())
            }
            KeyCmd::Remove { pubkey } => {
                let removed = trust::remove_trusted_key(repo, &pubkey)?;
                let label_display = removed
                    .label
                    .as_ref()
                    .map(|l| format!(" ({})", l))
                    .unwrap_or_default();
                println!("Removed trusted key: {}{}", removed.pubkey, label_display);
                Ok(())
            }
        },
    }
}
diff --git a/src/signing.rs b/src/signing.rs
index 9f41a64..bcc95bd 100644
--- a/src/signing.rs
+++ b/src/signing.rs
@@ -47,6 +47,8 @@ pub enum VerifyStatus {
    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.
@@ -266,6 +268,16 @@ pub fn verify_ref(
                            error: Some("missing signature".to_string()),
                        });
                    }
                    VerifyStatus::Untrusted => {
                        // verify_signed_event never returns Untrusted,
                        // but handle it for exhaustiveness
                        results.push(SignatureVerificationResult {
                            commit_id: oid,
                            status: VerifyStatus::Untrusted,
                            pubkey: Some(signed.pubkey),
                            error: Some("untrusted key".to_string()),
                        });
                    }
                }
            }
        } else {
diff --git a/src/sync.rs b/src/sync.rs
index 69192e5..8cd7db1 100644
--- a/src/sync.rs
+++ b/src/sync.rs
@@ -6,6 +6,7 @@ use crate::dag;
use crate::error::Error;
use crate::identity::get_author;
use crate::signing;
use crate::trust;

/// Add collab refspecs to all remotes.
pub fn init(repo: &Repository) -> Result<(), Error> {
@@ -125,10 +126,22 @@ fn reconcile_refs(
        .collect()
    };

    // Load trust policy once for all refs of this kind
    let trust_policy = trust::load_trust_policy(repo)?;
    let mut warned_unconfigured = false;

    for (remote_ref, id) in &sync_refs {
        // Verify all commits on the remote ref before reconciling
        match signing::verify_ref(repo, remote_ref) {
            Ok(results) => {
                // Apply trust checking
                let results = trust::check_trust(&results, &trust_policy);

                if matches!(trust_policy, trust::TrustPolicy::Unconfigured) && !warned_unconfigured {
                    eprintln!("warning: no trusted keys configured — all valid signatures accepted. Run 'collab key add --self' to start.");
                    warned_unconfigured = true;
                }

                let failures: Vec<_> = results
                    .iter()
                    .filter(|r| r.status != signing::VerifyStatus::Valid)
diff --git a/src/trust.rs b/src/trust.rs
new file mode 100644
index 0000000..c84ac8d
--- /dev/null
+++ b/src/trust.rs
@@ -0,0 +1,505 @@
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;

use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use ed25519_dalek::VerifyingKey;
use git2::Repository;

use crate::error::Error;
use crate::signing::{SignatureVerificationResult, VerifyStatus};

/// A single trusted key entry.
#[derive(Debug, Clone)]
pub struct TrustedKey {
    /// Base64-encoded Ed25519 public key.
    pub pubkey: String,
    /// Optional human-readable label.
    pub label: Option<String>,
}

/// Loaded trust policy.
#[derive(Debug)]
pub enum TrustPolicy {
    /// No trusted keys file exists. Fall back to accepting any valid signature.
    Unconfigured,
    /// Trusted keys file exists (possibly empty). Enforce allowlist.
    Configured(Vec<TrustedKey>),
}

/// Return the path to the trusted keys file for the given repo.
pub fn trusted_keys_path(repo: &Repository) -> PathBuf {
    repo.path().join("collab/trusted-keys")
}

/// Validate that a string is a valid base64-encoded 32-byte Ed25519 public key.
pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> {
    let bytes = STANDARD
        .decode(pubkey.trim())
        .map_err(|e| Error::Verification(format!("invalid public key: base64 decode failed: {}", e)))?;
    if bytes.len() != 32 {
        return Err(Error::Verification(format!(
            "invalid public key: expected 32 bytes, got {}",
            bytes.len()
        )));
    }
    let key_bytes: [u8; 32] = bytes.try_into().unwrap();
    VerifyingKey::from_bytes(&key_bytes)
        .map_err(|e| Error::Verification(format!("invalid public key: not a valid Ed25519 point: {}", e)))?;
    Ok(())
}

/// Load the trust policy from the trusted keys file.
///
/// Returns `TrustPolicy::Unconfigured` if the file does not exist.
/// Returns `TrustPolicy::Configured(keys)` if the file exists (even if empty).
/// Malformed lines are skipped with a warning to stderr.
/// Duplicate keys are deduplicated (last wins for label).
pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> {
    let path = trusted_keys_path(repo);
    if !path.exists() {
        return Ok(TrustPolicy::Unconfigured);
    }
    let content = fs::read_to_string(&path)?;
    let mut seen = HashSet::new();
    let mut keys = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        let (pubkey, label) = match trimmed.split_once(' ') {
            Some((k, l)) => (k.to_string(), Some(l.to_string())),
            None => (trimmed.to_string(), None),
        };
        // Validate the key; skip malformed entries
        if let Err(e) = validate_pubkey(&pubkey) {
            eprintln!("warning: skipping malformed trusted key: {}: {}", pubkey, e);
            continue;
        }
        // Deduplicate: remove earlier entry if present
        if seen.contains(&pubkey) {
            keys.retain(|k: &TrustedKey| k.pubkey != pubkey);
        }
        seen.insert(pubkey.clone());
        keys.push(TrustedKey { pubkey, label });
    }

    Ok(TrustPolicy::Configured(keys))
}

/// Check whether a pubkey is in the trusted set.
pub fn is_key_trusted(keys: &[TrustedKey], pubkey: &str) -> bool {
    keys.iter().any(|k| k.pubkey == pubkey)
}

/// Save (append) a trusted key to the trusted keys file.
///
/// Creates the `.git/collab/` directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already trusted.
pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
    let path = trusted_keys_path(repo);
    // Ensure directory exists
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    // Check for duplicates
    if path.exists() {
        let policy = load_trust_policy(repo)?;
        if let TrustPolicy::Configured(ref keys) = policy {
            if is_key_trusted(keys, pubkey) {
                return Ok(false);
            }
        }
    }
    // Append key
    let line = match label {
        Some(l) => format!("{} {}\n", pubkey, l),
        None => format!("{}\n", pubkey),
    };
    use std::io::Write;
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)?;
    file.write_all(line.as_bytes())?;
    Ok(true)
}

/// Remove a trusted key from the file. Returns the removed entry.
pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> {
    let path = trusted_keys_path(repo);
    let policy = load_trust_policy(repo)?;
    match policy {
        TrustPolicy::Unconfigured => {
            return Err(Error::Cmd(format!(
                "key {} is not in the trusted keys list",
                pubkey
            )));
        }
        TrustPolicy::Configured(keys) => {
            let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned();
            match removed {
                None => {
                    return Err(Error::Cmd(format!(
                        "key {} is not in the trusted keys list",
                        pubkey
                    )));
                }
                Some(removed_key) => {
                    // Rewrite file without the removed key
                    let content = fs::read_to_string(&path)?;
                    let mut new_lines = Vec::new();
                    for line in content.lines() {
                        let trimmed = line.trim();
                        if trimmed.is_empty() || trimmed.starts_with('#') {
                            new_lines.push(line.to_string());
                            continue;
                        }
                        let key_part = trimmed.split_once(' ')
                            .map(|(k, _)| k)
                            .unwrap_or(trimmed);
                        if key_part != pubkey {
                            new_lines.push(line.to_string());
                        }
                    }
                    let new_content = if new_lines.is_empty() {
                        String::new()
                    } else {
                        new_lines.join("\n") + "\n"
                    };
                    fs::write(&path, new_content)?;
                    Ok(removed_key)
                }
            }
        }
    }
}

/// Check trust for a set of signature verification results.
///
/// If `TrustPolicy::Unconfigured`, returns the results unchanged.
/// If `TrustPolicy::Configured`, changes `Valid` results to `Untrusted` when the
/// pubkey is not in the trusted set. All other statuses pass through unchanged.
pub fn check_trust(
    results: &[SignatureVerificationResult],
    policy: &TrustPolicy,
) -> Vec<SignatureVerificationResult> {
    match policy {
        TrustPolicy::Unconfigured => results.to_vec(),
        TrustPolicy::Configured(keys) => {
            let trusted_set: HashSet<&str> = keys.iter().map(|k| k.pubkey.as_str()).collect();
            results
                .iter()
                .map(|r| {
                    if r.status == VerifyStatus::Valid {
                        if let Some(ref pk) = r.pubkey {
                            if !trusted_set.contains(pk.as_str()) {
                                return SignatureVerificationResult {
                                    commit_id: r.commit_id,
                                    status: VerifyStatus::Untrusted,
                                    pubkey: r.pubkey.clone(),
                                    error: Some(format!("untrusted key: {}", pk)),
                                };
                            }
                        }
                    }
                    r.clone()
                })
                .collect()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    use git2::Oid;

    fn test_repo() -> (TempDir, Repository) {
        let dir = TempDir::new().unwrap();
        let repo = Repository::init(dir.path()).unwrap();
        (dir, repo)
    }

    /// Generate a valid Ed25519 public key in base64 for testing.
    fn valid_test_pubkey() -> String {
        use ed25519_dalek::SigningKey;
        use rand_core::OsRng;
        let sk = SigningKey::generate(&mut OsRng);
        STANDARD.encode(sk.verifying_key().to_bytes())
    }

    // =====================================================
    // T004: Unit tests for validate_pubkey()
    // =====================================================

    #[test]
    fn validate_pubkey_accepts_valid_key() {
        let pk = valid_test_pubkey();
        assert!(validate_pubkey(&pk).is_ok());
    }

    #[test]
    fn validate_pubkey_rejects_invalid_base64() {
        let result = validate_pubkey("not-valid-base64!!!");
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("base64"), "error should mention base64: {}", err);
    }

    #[test]
    fn validate_pubkey_rejects_wrong_byte_length() {
        // 16 bytes instead of 32
        let short = STANDARD.encode(vec![0u8; 16]);
        let result = validate_pubkey(&short);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("32 bytes"), "error should mention 32 bytes: {}", err);
    }

    #[test]
    fn validate_pubkey_rejects_too_long() {
        let long = STANDARD.encode(vec![0u8; 64]);
        let result = validate_pubkey(&long);
        assert!(result.is_err());
    }

    #[test]
    fn validate_pubkey_accepts_whitespace_trimmed() {
        let pk = valid_test_pubkey();
        let padded = format!("  {}  ", pk);
        assert!(validate_pubkey(&padded).is_ok());
    }

    // =====================================================
    // T005: Unit tests for load_trust_policy() and save_trusted_key()
    // =====================================================

    #[test]
    fn load_trust_policy_returns_unconfigured_when_no_file() {
        let (_dir, repo) = test_repo();
        let policy = load_trust_policy(&repo).unwrap();
        assert!(matches!(policy, TrustPolicy::Unconfigured));
    }

    #[test]
    fn load_trust_policy_returns_configured_empty_for_empty_file() {
        let (_dir, repo) = test_repo();
        let path = trusted_keys_path(&repo);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, "").unwrap();
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => assert!(keys.is_empty()),
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn load_trust_policy_parses_keys_and_labels() {
        let (_dir, repo) = test_repo();
        let pk1 = valid_test_pubkey();
        let pk2 = valid_test_pubkey();
        let path = trusted_keys_path(&repo);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, format!("# Comment\n{} Alice\n{}\n\n", pk1, pk2)).unwrap();
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 2);
                assert_eq!(keys[0].pubkey, pk1);
                assert_eq!(keys[0].label.as_deref(), Some("Alice"));
                assert_eq!(keys[1].pubkey, pk2);
                assert!(keys[1].label.is_none());
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn load_trust_policy_skips_malformed_lines() {
        let (_dir, repo) = test_repo();
        let pk = valid_test_pubkey();
        let path = trusted_keys_path(&repo);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, format!("garbage_not_base64\n{} Good key\n", pk)).unwrap();
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 1);
                assert_eq!(keys[0].pubkey, pk);
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn load_trust_policy_deduplicates_keys() {
        let (_dir, repo) = test_repo();
        let pk = valid_test_pubkey();
        let path = trusted_keys_path(&repo);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, format!("{} First\n{} Second\n", pk, pk)).unwrap();
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 1);
                // Last wins for label
                assert_eq!(keys[0].label.as_deref(), Some("Second"));
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn save_trusted_key_creates_file_and_appends() {
        let (_dir, repo) = test_repo();
        let pk1 = valid_test_pubkey();
        let pk2 = valid_test_pubkey();
        save_trusted_key(&repo, &pk1, Some("Alice")).unwrap();
        save_trusted_key(&repo, &pk2, None).unwrap();
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 2);
                assert_eq!(keys[0].pubkey, pk1);
                assert_eq!(keys[0].label.as_deref(), Some("Alice"));
                assert_eq!(keys[1].pubkey, pk2);
                assert!(keys[1].label.is_none());
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn save_trusted_key_prevents_duplicates() {
        let (_dir, repo) = test_repo();
        let pk = valid_test_pubkey();
        let added = save_trusted_key(&repo, &pk, Some("Alice")).unwrap();
        assert!(added, "first add should return true");
        let added = save_trusted_key(&repo, &pk, Some("Alice again")).unwrap();
        assert!(!added, "duplicate add should return false");
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 1, "should not have duplicates");
            }
            _ => panic!("expected Configured"),
        }
    }

    // =====================================================
    // T012: Unit tests for check_trust()
    // =====================================================

    fn make_result(status: VerifyStatus, pubkey: Option<&str>) -> SignatureVerificationResult {
        SignatureVerificationResult {
            commit_id: Oid::zero(),
            status,
            pubkey: pubkey.map(|s| s.to_string()),
            error: None,
        }
    }

    #[test]
    fn check_trust_unconfigured_passes_all_through() {
        let pk = valid_test_pubkey();
        let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
        let checked = check_trust(&results, &TrustPolicy::Unconfigured);
        assert_eq!(checked.len(), 1);
        assert_eq!(checked[0].status, VerifyStatus::Valid);
    }

    #[test]
    fn check_trust_configured_with_trusted_key_returns_valid() {
        let pk = valid_test_pubkey();
        let keys = vec![TrustedKey { pubkey: pk.clone(), label: None }];
        let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
        let checked = check_trust(&results, &TrustPolicy::Configured(keys));
        assert_eq!(checked[0].status, VerifyStatus::Valid);
    }

    #[test]
    fn check_trust_configured_with_untrusted_key_returns_untrusted() {
        let pk_trusted = valid_test_pubkey();
        let pk_untrusted = valid_test_pubkey();
        let keys = vec![TrustedKey { pubkey: pk_trusted, label: None }];
        let results = vec![make_result(VerifyStatus::Valid, Some(&pk_untrusted))];
        let checked = check_trust(&results, &TrustPolicy::Configured(keys));
        assert_eq!(checked[0].status, VerifyStatus::Untrusted);
        assert!(checked[0].error.as_ref().unwrap().contains(&pk_untrusted));
    }

    #[test]
    fn check_trust_passes_through_missing_and_invalid() {
        let keys = vec![];
        let results = vec![
            make_result(VerifyStatus::Missing, None),
            make_result(VerifyStatus::Invalid, Some("whatever")),
        ];
        let checked = check_trust(&results, &TrustPolicy::Configured(keys));
        assert_eq!(checked[0].status, VerifyStatus::Missing);
        assert_eq!(checked[1].status, VerifyStatus::Invalid);
    }

    #[test]
    fn check_trust_empty_configured_rejects_all_valid() {
        let pk = valid_test_pubkey();
        let results = vec![make_result(VerifyStatus::Valid, Some(&pk))];
        let checked = check_trust(&results, &TrustPolicy::Configured(vec![]));
        assert_eq!(checked[0].status, VerifyStatus::Untrusted);
    }

    // =====================================================
    // T009: Unit tests for remove_trusted_key()
    // =====================================================

    #[test]
    fn remove_trusted_key_removes_and_returns_entry() {
        let (_dir, repo) = test_repo();
        let pk1 = valid_test_pubkey();
        let pk2 = valid_test_pubkey();
        save_trusted_key(&repo, &pk1, Some("Alice")).unwrap();
        save_trusted_key(&repo, &pk2, Some("Bob")).unwrap();
        let removed = remove_trusted_key(&repo, &pk1).unwrap();
        assert_eq!(removed.pubkey, pk1);
        assert_eq!(removed.label.as_deref(), Some("Alice"));
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 1);
                assert_eq!(keys[0].pubkey, pk2);
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn remove_trusted_key_errors_when_not_found() {
        let (_dir, repo) = test_repo();
        let pk = valid_test_pubkey();
        save_trusted_key(&repo, &pk, None).unwrap();
        let other_pk = valid_test_pubkey();
        let result = remove_trusted_key(&repo, &other_pk);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not in the trusted keys list"));
    }

    #[test]
    fn remove_last_key_leaves_empty_configured_file() {
        let (_dir, repo) = test_repo();
        let pk = valid_test_pubkey();
        save_trusted_key(&repo, &pk, None).unwrap();
        remove_trusted_key(&repo, &pk).unwrap();
        // File should still exist (Configured) but empty
        let policy = load_trust_policy(&repo).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => assert!(keys.is_empty()),
            _ => panic!("expected Configured (empty)"),
        }
    }
}
diff --git a/tests/trust_test.rs b/tests/trust_test.rs
new file mode 100644
index 0000000..f8bfb07
--- /dev/null
+++ b/tests/trust_test.rs
@@ -0,0 +1,203 @@
mod common;

use common::TestRepo;

// ===========================================================================
// T006: Integration tests for `collab key add`
// ===========================================================================

#[test]
fn test_key_add_valid_key() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Add own key via --self
    let out = repo.run_ok(&["key", "add", "--self"]);
    assert!(
        out.contains("Trusted key added:"),
        "should confirm key added, got: {}",
        out
    );
}

#[test]
fn test_key_add_with_label() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let out = repo.run_ok(&["key", "add", "--self", "--label", "Alice (main laptop)"]);
    assert!(out.contains("Trusted key added:"));
    assert!(out.contains("Alice (main laptop)"));

    // Verify it shows up in list
    let out = repo.run_ok(&["key", "list"]);
    assert!(out.contains("Alice (main laptop)"));
}

#[test]
fn test_key_add_duplicate_prints_message() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.run_ok(&["key", "add", "--self"]);
    let out = repo.run_ok(&["key", "add", "--self"]);
    assert!(
        out.contains("already trusted"),
        "should say already trusted, got: {}",
        out
    );
}

#[test]
fn test_key_add_invalid_key_returns_error() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let err = repo.run_err(&["key", "add", "not-a-valid-key!!!"]);
    assert!(
        err.contains("invalid") || err.contains("base64"),
        "should mention invalid key, got: {}",
        err
    );
}

#[test]
fn test_key_add_self_and_pubkey_errors() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let err = repo.run_err(&["key", "add", "--self", "somepubkey"]);
    assert!(
        err.contains("cannot specify both"),
        "should error on --self with pubkey, got: {}",
        err
    );
}

#[test]
fn test_key_add_explicit_pubkey() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Get our own public key to use as a valid key
    let out = repo.run_ok(&["key", "add", "--self", "--label", "Me"]);
    assert!(out.contains("Trusted key added:"));

    // List should show the key
    let out = repo.run_ok(&["key", "list"]);
    assert!(out.contains("Me"));
}

// ===========================================================================
// T016: Integration tests for `collab key list`
// ===========================================================================

#[test]
fn test_key_list_no_keys() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let out = repo.run_ok(&["key", "list"]);
    assert!(
        out.contains("No trusted keys configured"),
        "should say no keys, got: {}",
        out
    );
}

#[test]
fn test_key_list_shows_keys_and_labels() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.run_ok(&["key", "add", "--self", "--label", "Alice"]);

    let out = repo.run_ok(&["key", "list"]);
    assert!(out.contains("Alice"), "should show label, got: {}", out);
    // Should contain a base64 key string (44 chars for ed25519)
    assert!(
        out.lines().any(|l| l.len() >= 44),
        "should contain a key string, got: {}",
        out
    );
}

// ===========================================================================
// T017: Integration tests for `collab key remove`
// ===========================================================================

#[test]
fn test_key_remove_existing() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Add a key, extract the pubkey from the list
    repo.run_ok(&["key", "add", "--self", "--label", "Alice"]);

    let list = repo.run_ok(&["key", "list"]);
    let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap();

    let out = repo.run_ok(&["key", "remove", pubkey]);
    assert!(
        out.contains("Removed trusted key:"),
        "should confirm removal, got: {}",
        out
    );
    assert!(out.contains("Alice"), "should show label of removed key");

    // List should now be empty
    let out = repo.run_ok(&["key", "list"]);
    assert!(out.contains("No trusted keys configured"));
}

#[test]
fn test_key_remove_nonexistent_errors() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Add a key first so the file exists
    repo.run_ok(&["key", "add", "--self"]);

    let err = repo.run_err(&["key", "remove", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY="]);
    assert!(
        err.contains("not in the trusted keys list"),
        "should say not found, got: {}",
        err
    );
}

// ===========================================================================
// T021: Edge case tests
// ===========================================================================

#[test]
fn test_key_add_then_remove_then_readd() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.run_ok(&["key", "add", "--self", "--label", "Original"]);

    let list = repo.run_ok(&["key", "list"]);
    let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap().to_string();

    // Remove
    repo.run_ok(&["key", "remove", &pubkey]);

    // Re-add with a different label
    let out = repo.run_ok(&["key", "add", &pubkey, "--label", "Re-added"]);
    assert!(out.contains("Trusted key added:"));

    let list = repo.run_ok(&["key", "list"]);
    assert!(list.contains("Re-added"));
}

#[test]
fn test_key_list_with_corrupted_file() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Add a valid key first
    repo.run_ok(&["key", "add", "--self", "--label", "Good key"]);

    // Manually corrupt the file by adding a bad line
    let git_dir = repo.dir.path().join(".git/collab/trusted-keys");
    let content = std::fs::read_to_string(&git_dir).unwrap();
    std::fs::write(
        &git_dir,
        format!("{}garbage_not_a_valid_key BadEntry\n", content),
    )
    .unwrap();

    // List should still work, showing valid keys and skipping the bad one
    let out = repo.run_ok(&["key", "list"]);
    assert!(out.contains("Good key"), "valid key should still appear");
    // The bad line should be skipped (warning goes to stderr)
}