Skip to main content

agent_relay/
git.rs

1//! Git-integrated identity, SSH signing, and collaborator verification.
2//!
3//! Links agent-relay authentication to Git's own infrastructure:
4//! - Identity from `git config user.name` + `user.email`
5//! - Message signing with SSH keys (same key used for `git push`)
6//! - Collaborator verification via GitHub API (`gh` CLI)
7
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12// ── Git Identity ──
13
14#[derive(Serialize, Deserialize, Clone, Debug)]
15pub struct GitIdentity {
16    pub name: String,
17    pub email: String,
18    pub signing_key: Option<String>,
19}
20
21impl GitIdentity {
22    /// Auto-detect identity from the current git config.
23    pub fn from_git_config() -> Result<Self, String> {
24        let name = git_config("user.name")?;
25        let email = git_config("user.email")?;
26        let signing_key = git_config("user.signingkey").ok();
27        Ok(Self {
28            name,
29            email,
30            signing_key,
31        })
32    }
33
34    /// Fingerprint for this identity (used as a stable session seed).
35    pub fn fingerprint(&self) -> String {
36        format!("{}:{}", self.name, self.email)
37    }
38}
39
40fn git_config(key: &str) -> Result<String, String> {
41    let output = Command::new("git")
42        .args(["config", "--get", key])
43        .output()
44        .map_err(|e| format!("Failed to run git config: {}", e))?;
45
46    if !output.status.success() {
47        return Err(format!("git config {} not set", key));
48    }
49
50    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
51}
52
53// ── SSH Signing ──
54
55/// Sign a message body using the user's SSH key.
56/// Uses `ssh-keygen -Y sign` which is the same mechanism as `git commit -S`.
57pub fn ssh_sign(content: &str, key_path: &Path) -> Result<String, String> {
58    let tmp_data = std::env::temp_dir().join(format!("agent-relay-sign-{}", uuid::Uuid::new_v4()));
59    std::fs::write(&tmp_data, content).map_err(|e| format!("Failed to write temp file: {}", e))?;
60
61    let output = Command::new("ssh-keygen")
62        .args([
63            "-Y",
64            "sign",
65            "-f",
66            &key_path.to_string_lossy(),
67            "-n",
68            "agent-relay",
69        ])
70        .arg(&tmp_data)
71        .output()
72        .map_err(|e| format!("ssh-keygen sign failed: {}", e))?;
73
74    let sig_path = PathBuf::from(format!("{}.sig", tmp_data.display()));
75    let signature = if sig_path.exists() {
76        std::fs::read_to_string(&sig_path)
77            .map_err(|e| format!("Failed to read signature: {}", e))?
78    } else if output.status.success() {
79        String::from_utf8_lossy(&output.stdout).to_string()
80    } else {
81        let _ = std::fs::remove_file(&tmp_data);
82        return Err(format!(
83            "ssh-keygen sign failed: {}",
84            String::from_utf8_lossy(&output.stderr)
85        ));
86    };
87
88    let _ = std::fs::remove_file(&tmp_data);
89    let _ = std::fs::remove_file(&sig_path);
90
91    Ok(signature.trim().to_string())
92}
93
94/// Verify an SSH signature against the message content.
95/// Requires an `allowed_signers` file listing trusted public keys.
96pub fn ssh_verify(
97    content: &str,
98    signature: &str,
99    identity: &str,
100    allowed_signers_path: &Path,
101) -> Result<bool, String> {
102    let tmp_data =
103        std::env::temp_dir().join(format!("agent-relay-verify-data-{}", uuid::Uuid::new_v4()));
104    let tmp_sig =
105        std::env::temp_dir().join(format!("agent-relay-verify-sig-{}", uuid::Uuid::new_v4()));
106
107    std::fs::write(&tmp_data, content).map_err(|e| format!("Failed to write temp data: {}", e))?;
108    std::fs::write(&tmp_sig, signature).map_err(|e| format!("Failed to write temp sig: {}", e))?;
109
110    let output = Command::new("ssh-keygen")
111        .args([
112            "-Y",
113            "verify",
114            "-f",
115            &allowed_signers_path.to_string_lossy(),
116            "-I",
117            identity,
118            "-n",
119            "agent-relay",
120            "-s",
121        ])
122        .arg(&tmp_sig)
123        .stdin(std::process::Stdio::from(
124            std::fs::File::open(&tmp_data)
125                .map_err(|e| format!("Failed to open temp data: {}", e))?,
126        ))
127        .output()
128        .map_err(|e| format!("ssh-keygen verify failed: {}", e))?;
129
130    let _ = std::fs::remove_file(&tmp_data);
131    let _ = std::fs::remove_file(&tmp_sig);
132
133    Ok(output.status.success())
134}
135
136// ── SSH Key Discovery ──
137
138/// Find the user's SSH private key.
139/// Checks: git config user.signingkey, then common paths.
140pub fn find_ssh_key() -> Option<PathBuf> {
141    // 1. Check git config
142    if let Ok(key) = git_config("user.signingkey") {
143        let path = if key.starts_with("key::") {
144            // Literal key, not a path
145            return None;
146        } else if key.starts_with('~') {
147            expand_tilde(&key)
148        } else {
149            PathBuf::from(&key)
150        };
151        if path.exists() {
152            return Some(path);
153        }
154    }
155
156    // 2. Check common paths
157    let home = dirs();
158    for name in &["id_ed25519", "id_ecdsa", "id_rsa"] {
159        let path = home.join(name);
160        if path.exists() {
161            return Some(path);
162        }
163    }
164
165    None
166}
167
168/// Generate an `allowed_signers` file from the collaborators' public keys.
169/// Each line: `email namespace public-key`
170pub fn write_allowed_signers(
171    output_path: &Path,
172    entries: &[(String, String)], // (email, public_key_content)
173) -> Result<(), String> {
174    let mut lines = Vec::new();
175    for (email, pubkey) in entries {
176        let key = pubkey.trim();
177        lines.push(format!("{} agent-relay {}", email, key));
178    }
179    std::fs::write(output_path, lines.join("\n"))
180        .map_err(|e| format!("Failed to write allowed_signers: {}", e))?;
181    Ok(())
182}
183
184fn dirs() -> PathBuf {
185    if let Ok(home) = std::env::var("HOME") {
186        PathBuf::from(home).join(".ssh")
187    } else {
188        PathBuf::from(".ssh")
189    }
190}
191
192fn expand_tilde(path: &str) -> PathBuf {
193    if let Some(rest) = path.strip_prefix("~/") {
194        if let Ok(home) = std::env::var("HOME") {
195            return PathBuf::from(home).join(rest);
196        }
197    }
198    PathBuf::from(path)
199}
200
201// ── GitHub Collaborator Discovery ──
202
203/// Fetch collaborators from a GitHub repo using the `gh` CLI.
204/// Returns list of (username, email) pairs.
205pub fn github_collaborators(owner: &str, repo: &str) -> Result<Vec<Collaborator>, String> {
206    let output = Command::new("gh")
207        .args([
208            "api",
209            &format!("repos/{}/{}/collaborators", owner, repo),
210            "--jq",
211            ".[] | {login: .login, permissions: .permissions}",
212        ])
213        .output()
214        .map_err(|e| format!("gh api failed: {}", e))?;
215
216    if !output.status.success() {
217        return Err(format!(
218            "Failed to fetch collaborators: {}",
219            String::from_utf8_lossy(&output.stderr)
220        ));
221    }
222
223    let stdout = String::from_utf8_lossy(&output.stdout);
224    let mut collabs = Vec::new();
225
226    for line in stdout.lines() {
227        if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
228            if let Some(login) = val["login"].as_str() {
229                collabs.push(Collaborator {
230                    username: login.to_string(),
231                    can_push: val["permissions"]["push"].as_bool().unwrap_or(false),
232                });
233            }
234        }
235    }
236
237    Ok(collabs)
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct Collaborator {
242    pub username: String,
243    pub can_push: bool,
244}
245
246/// Extract owner/repo from a git remote URL.
247/// Handles: `git@github.com:owner/repo.git`, `https://github.com/owner/repo.git`
248pub fn parse_github_remote() -> Option<(String, String)> {
249    let output = Command::new("git")
250        .args(["remote", "get-url", "origin"])
251        .output()
252        .ok()?;
253
254    if !output.status.success() {
255        return None;
256    }
257
258    let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
259    parse_github_url(&url)
260}
261
262fn parse_github_url(url: &str) -> Option<(String, String)> {
263    // SSH: git@github.com:owner/repo.git
264    if let Some(rest) = url.strip_prefix("git@github.com:") {
265        let rest = rest.trim_end_matches(".git");
266        let parts: Vec<&str> = rest.splitn(2, '/').collect();
267        if parts.len() == 2 {
268            return Some((parts[0].to_string(), parts[1].to_string()));
269        }
270    }
271
272    // HTTPS: https://github.com/owner/repo.git
273    if url.contains("github.com/") {
274        let after = url.split("github.com/").nth(1)?;
275        let after = after.trim_end_matches(".git");
276        let parts: Vec<&str> = after.splitn(2, '/').collect();
277        if parts.len() == 2 {
278            return Some((parts[0].to_string(), parts[1].to_string()));
279        }
280    }
281
282    None
283}
284
285// ── Secure Relay ──
286
287use crate::{Message, Relay};
288
289/// A relay with Git identity + SSH signing.
290///
291/// Messages are signed on send and can be verified on read.
292/// Identity comes from `git config`.
293pub struct SecureRelay {
294    pub relay: Relay,
295    pub identity: GitIdentity,
296    pub ssh_key: Option<PathBuf>,
297    pub allowed_signers: Option<PathBuf>,
298}
299
300/// A message with an attached SSH signature.
301#[derive(Serialize, Deserialize, Clone, Debug)]
302pub struct SignedMessage {
303    pub message: Message,
304    pub git_identity: GitIdentity,
305    pub signature: Option<String>,
306    pub verified: Option<bool>,
307}
308
309impl SecureRelay {
310    /// Create from the current git repo. Auto-detects identity and SSH key.
311    pub fn from_git_repo(relay_dir: PathBuf) -> Result<Self, String> {
312        let identity = GitIdentity::from_git_config()?;
313        let ssh_key = find_ssh_key();
314        let allowed_signers_path = relay_dir.join("allowed_signers");
315        let allowed_signers = if allowed_signers_path.exists() {
316            Some(allowed_signers_path)
317        } else {
318            None
319        };
320
321        let relay = Relay::new(relay_dir);
322        // Ensure base directories exist for allowed_signers writes
323        let _ = std::fs::create_dir_all(&relay.base_dir);
324
325        Ok(Self {
326            relay,
327            identity,
328            ssh_key,
329            allowed_signers,
330        })
331    }
332
333    /// Send a signed message. The content is signed with the user's SSH key.
334    pub fn send_signed(
335        &self,
336        session_id: &str,
337        to_session: Option<&str>,
338        content: &str,
339    ) -> Result<SignedMessage, String> {
340        let msg = self
341            .relay
342            .send(session_id, &self.identity.name, to_session, content);
343
344        let signature = if let Some(ref key) = self.ssh_key {
345            match ssh_sign(content, key) {
346                Ok(sig) => Some(sig),
347                Err(e) => {
348                    eprintln!("Warning: could not sign message: {}", e);
349                    None
350                }
351            }
352        } else {
353            None
354        };
355
356        let signed = SignedMessage {
357            message: msg.clone(),
358            git_identity: self.identity.clone(),
359            signature,
360            verified: None,
361        };
362
363        // Write the signed envelope alongside the message
364        let sig_path = self
365            .relay
366            .base_dir
367            .join("signatures")
368            .join(format!("{}.json", msg.id));
369        let _ = std::fs::create_dir_all(sig_path.parent().unwrap());
370        if let Ok(json) = serde_json::to_string_pretty(&signed) {
371            let _ = std::fs::write(&sig_path, json);
372        }
373
374        Ok(signed)
375    }
376
377    /// Read inbox with signature verification.
378    pub fn inbox_verified(&self, session_id: &str, limit: usize) -> Vec<SignedMessage> {
379        let msgs = self.relay.inbox(session_id, limit);
380        let sig_dir = self.relay.base_dir.join("signatures");
381
382        msgs.into_iter()
383            .map(|(msg, _is_new)| {
384                let sig_path = sig_dir.join(format!("{}.json", msg.id));
385                if let Ok(content) = std::fs::read_to_string(&sig_path) {
386                    if let Ok(mut signed) = serde_json::from_str::<SignedMessage>(&content) {
387                        // Verify if we have allowed_signers and a signature
388                        if let (Some(ref sig), Some(ref allowed)) =
389                            (&signed.signature, &self.allowed_signers)
390                        {
391                            signed.verified = Some(
392                                ssh_verify(&msg.content, sig, &signed.git_identity.email, allowed)
393                                    .unwrap_or(false),
394                            );
395                        }
396                        signed.message = msg;
397                        return signed;
398                    }
399                }
400                // No signature found — unsigned message
401                SignedMessage {
402                    message: msg,
403                    git_identity: GitIdentity {
404                        name: "unknown".to_string(),
405                        email: "unknown".to_string(),
406                        signing_key: None,
407                    },
408                    signature: None,
409                    verified: None,
410                }
411            })
412            .collect()
413    }
414
415    /// Check if a user is a collaborator on the current GitHub repo.
416    pub fn verify_collaborator(&self, username: &str) -> Result<bool, String> {
417        let (owner, repo) = parse_github_remote()
418            .ok_or_else(|| "Not a GitHub repo or no origin remote".to_string())?;
419        let collabs = github_collaborators(&owner, &repo)?;
420        Ok(collabs.iter().any(|c| c.username == username && c.can_push))
421    }
422
423    /// Initialize the allowed_signers file from GitHub collaborators' SSH keys.
424    pub fn init_allowed_signers(&self) -> Result<usize, String> {
425        let (owner, repo) = parse_github_remote()
426            .ok_or_else(|| "Not a GitHub repo or no origin remote".to_string())?;
427
428        // Fetch each collaborator's SSH keys from GitHub
429        let collabs = github_collaborators(&owner, &repo)?;
430        let mut entries = Vec::new();
431
432        for collab in &collabs {
433            if !collab.can_push {
434                continue;
435            }
436            // Fetch SSH keys via gh api
437            let output = Command::new("gh")
438                .args([
439                    "api",
440                    &format!("users/{}/keys", collab.username),
441                    "--jq",
442                    ".[].key",
443                ])
444                .output();
445
446            if let Ok(out) = output {
447                if out.status.success() {
448                    let keys = String::from_utf8_lossy(&out.stdout);
449                    for key in keys.lines() {
450                        if !key.is_empty() {
451                            entries.push((format!("{}@github", collab.username), key.to_string()));
452                        }
453                    }
454                }
455            }
456        }
457
458        let count = entries.len();
459        let path = self.relay.base_dir.join("allowed_signers");
460        write_allowed_signers(&path, &entries)?;
461        Ok(count)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_parse_github_url_ssh() {
471        let result = parse_github_url("git@github.com:Naridon-Inc/agent-relay.git");
472        assert_eq!(result, Some(("Naridon-Inc".into(), "agent-relay".into())));
473    }
474
475    #[test]
476    fn test_parse_github_url_https() {
477        let result = parse_github_url("https://github.com/Naridon-Inc/agent-relay.git");
478        assert_eq!(result, Some(("Naridon-Inc".into(), "agent-relay".into())));
479    }
480
481    #[test]
482    fn test_parse_github_url_no_git_suffix() {
483        let result = parse_github_url("https://github.com/owner/repo");
484        assert_eq!(result, Some(("owner".into(), "repo".into())));
485    }
486
487    #[test]
488    fn test_parse_github_url_invalid() {
489        let result = parse_github_url("https://gitlab.com/owner/repo");
490        assert_eq!(result, None);
491    }
492}