1use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12#[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 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 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
53pub 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
94pub 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
136pub fn find_ssh_key() -> Option<PathBuf> {
141 if let Ok(key) = git_config("user.signingkey") {
143 let path = if key.starts_with("key::") {
144 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 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
168pub fn write_allowed_signers(
171 output_path: &Path,
172 entries: &[(String, String)], ) -> 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
201pub 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
246pub 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 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 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
285use crate::{Message, Relay};
288
289pub 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#[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 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 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 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 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 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 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 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 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 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 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 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}