use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GitIdentity {
pub name: String,
pub email: String,
pub signing_key: Option<String>,
}
impl GitIdentity {
pub fn from_git_config() -> Result<Self, String> {
let name = git_config("user.name")?;
let email = git_config("user.email")?;
let signing_key = git_config("user.signingkey").ok();
Ok(Self {
name,
email,
signing_key,
})
}
pub fn fingerprint(&self) -> String {
format!("{}:{}", self.name, self.email)
}
}
fn git_config(key: &str) -> Result<String, String> {
let output = Command::new("git")
.args(["config", "--get", key])
.output()
.map_err(|e| format!("Failed to run git config: {}", e))?;
if !output.status.success() {
return Err(format!("git config {} not set", key));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn ssh_sign(content: &str, key_path: &Path) -> Result<String, String> {
let tmp_data = std::env::temp_dir().join(format!("agent-relay-sign-{}", uuid::Uuid::new_v4()));
std::fs::write(&tmp_data, content).map_err(|e| format!("Failed to write temp file: {}", e))?;
let output = Command::new("ssh-keygen")
.args([
"-Y",
"sign",
"-f",
&key_path.to_string_lossy(),
"-n",
"agent-relay",
])
.arg(&tmp_data)
.output()
.map_err(|e| format!("ssh-keygen sign failed: {}", e))?;
let sig_path = PathBuf::from(format!("{}.sig", tmp_data.display()));
let signature = if sig_path.exists() {
std::fs::read_to_string(&sig_path)
.map_err(|e| format!("Failed to read signature: {}", e))?
} else if output.status.success() {
String::from_utf8_lossy(&output.stdout).to_string()
} else {
let _ = std::fs::remove_file(&tmp_data);
return Err(format!(
"ssh-keygen sign failed: {}",
String::from_utf8_lossy(&output.stderr)
));
};
let _ = std::fs::remove_file(&tmp_data);
let _ = std::fs::remove_file(&sig_path);
Ok(signature.trim().to_string())
}
pub fn ssh_verify(
content: &str,
signature: &str,
identity: &str,
allowed_signers_path: &Path,
) -> Result<bool, String> {
let tmp_data =
std::env::temp_dir().join(format!("agent-relay-verify-data-{}", uuid::Uuid::new_v4()));
let tmp_sig =
std::env::temp_dir().join(format!("agent-relay-verify-sig-{}", uuid::Uuid::new_v4()));
std::fs::write(&tmp_data, content).map_err(|e| format!("Failed to write temp data: {}", e))?;
std::fs::write(&tmp_sig, signature).map_err(|e| format!("Failed to write temp sig: {}", e))?;
let output = Command::new("ssh-keygen")
.args([
"-Y",
"verify",
"-f",
&allowed_signers_path.to_string_lossy(),
"-I",
identity,
"-n",
"agent-relay",
"-s",
])
.arg(&tmp_sig)
.stdin(std::process::Stdio::from(
std::fs::File::open(&tmp_data)
.map_err(|e| format!("Failed to open temp data: {}", e))?,
))
.output()
.map_err(|e| format!("ssh-keygen verify failed: {}", e))?;
let _ = std::fs::remove_file(&tmp_data);
let _ = std::fs::remove_file(&tmp_sig);
Ok(output.status.success())
}
pub fn find_ssh_key() -> Option<PathBuf> {
if let Ok(key) = git_config("user.signingkey") {
let path = if key.starts_with("key::") {
return None;
} else if key.starts_with('~') {
expand_tilde(&key)
} else {
PathBuf::from(&key)
};
if path.exists() {
return Some(path);
}
}
let home = dirs();
for name in &["id_ed25519", "id_ecdsa", "id_rsa"] {
let path = home.join(name);
if path.exists() {
return Some(path);
}
}
None
}
pub fn write_allowed_signers(
output_path: &Path,
entries: &[(String, String)], ) -> Result<(), String> {
let mut lines = Vec::new();
for (email, pubkey) in entries {
let key = pubkey.trim();
lines.push(format!("{} agent-relay {}", email, key));
}
std::fs::write(output_path, lines.join("\n"))
.map_err(|e| format!("Failed to write allowed_signers: {}", e))?;
Ok(())
}
fn dirs() -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".ssh")
} else {
PathBuf::from(".ssh")
}
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(path)
}
pub fn github_collaborators(owner: &str, repo: &str) -> Result<Vec<Collaborator>, String> {
let output = Command::new("gh")
.args([
"api",
&format!("repos/{}/{}/collaborators", owner, repo),
"--jq",
".[] | {login: .login, permissions: .permissions}",
])
.output()
.map_err(|e| format!("gh api failed: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to fetch collaborators: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut collabs = Vec::new();
for line in stdout.lines() {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(login) = val["login"].as_str() {
collabs.push(Collaborator {
username: login.to_string(),
can_push: val["permissions"]["push"].as_bool().unwrap_or(false),
});
}
}
}
Ok(collabs)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Collaborator {
pub username: String,
pub can_push: bool,
}
pub fn parse_github_remote() -> Option<(String, String)> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_github_url(&url)
}
fn parse_github_url(url: &str) -> Option<(String, String)> {
if let Some(rest) = url.strip_prefix("git@github.com:") {
let rest = rest.trim_end_matches(".git");
let parts: Vec<&str> = rest.splitn(2, '/').collect();
if parts.len() == 2 {
return Some((parts[0].to_string(), parts[1].to_string()));
}
}
if url.contains("github.com/") {
let after = url.split("github.com/").nth(1)?;
let after = after.trim_end_matches(".git");
let parts: Vec<&str> = after.splitn(2, '/').collect();
if parts.len() == 2 {
return Some((parts[0].to_string(), parts[1].to_string()));
}
}
None
}
use crate::{Message, Relay};
pub struct SecureRelay {
pub relay: Relay,
pub identity: GitIdentity,
pub ssh_key: Option<PathBuf>,
pub allowed_signers: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SignedMessage {
pub message: Message,
pub git_identity: GitIdentity,
pub signature: Option<String>,
pub verified: Option<bool>,
}
impl SecureRelay {
pub fn from_git_repo(relay_dir: PathBuf) -> Result<Self, String> {
let identity = GitIdentity::from_git_config()?;
let ssh_key = find_ssh_key();
let allowed_signers_path = relay_dir.join("allowed_signers");
let allowed_signers = if allowed_signers_path.exists() {
Some(allowed_signers_path)
} else {
None
};
let relay = Relay::new(relay_dir);
let _ = std::fs::create_dir_all(&relay.base_dir);
Ok(Self {
relay,
identity,
ssh_key,
allowed_signers,
})
}
pub fn send_signed(
&self,
session_id: &str,
to_session: Option<&str>,
content: &str,
) -> Result<SignedMessage, String> {
let msg = self
.relay
.send(session_id, &self.identity.name, to_session, content);
let signature = if let Some(ref key) = self.ssh_key {
match ssh_sign(content, key) {
Ok(sig) => Some(sig),
Err(e) => {
eprintln!("Warning: could not sign message: {}", e);
None
}
}
} else {
None
};
let signed = SignedMessage {
message: msg.clone(),
git_identity: self.identity.clone(),
signature,
verified: None,
};
let sig_path = self
.relay
.base_dir
.join("signatures")
.join(format!("{}.json", msg.id));
let _ = std::fs::create_dir_all(sig_path.parent().unwrap());
if let Ok(json) = serde_json::to_string_pretty(&signed) {
let _ = std::fs::write(&sig_path, json);
}
Ok(signed)
}
pub fn inbox_verified(&self, session_id: &str, limit: usize) -> Vec<SignedMessage> {
let msgs = self.relay.inbox(session_id, limit);
let sig_dir = self.relay.base_dir.join("signatures");
msgs.into_iter()
.map(|(msg, _is_new)| {
let sig_path = sig_dir.join(format!("{}.json", msg.id));
if let Ok(content) = std::fs::read_to_string(&sig_path) {
if let Ok(mut signed) = serde_json::from_str::<SignedMessage>(&content) {
if let (Some(ref sig), Some(ref allowed)) =
(&signed.signature, &self.allowed_signers)
{
signed.verified = Some(
ssh_verify(&msg.content, sig, &signed.git_identity.email, allowed)
.unwrap_or(false),
);
}
signed.message = msg;
return signed;
}
}
SignedMessage {
message: msg,
git_identity: GitIdentity {
name: "unknown".to_string(),
email: "unknown".to_string(),
signing_key: None,
},
signature: None,
verified: None,
}
})
.collect()
}
pub fn verify_collaborator(&self, username: &str) -> Result<bool, String> {
let (owner, repo) = parse_github_remote()
.ok_or_else(|| "Not a GitHub repo or no origin remote".to_string())?;
let collabs = github_collaborators(&owner, &repo)?;
Ok(collabs.iter().any(|c| c.username == username && c.can_push))
}
pub fn init_allowed_signers(&self) -> Result<usize, String> {
let (owner, repo) = parse_github_remote()
.ok_or_else(|| "Not a GitHub repo or no origin remote".to_string())?;
let collabs = github_collaborators(&owner, &repo)?;
let mut entries = Vec::new();
for collab in &collabs {
if !collab.can_push {
continue;
}
let output = Command::new("gh")
.args([
"api",
&format!("users/{}/keys", collab.username),
"--jq",
".[].key",
])
.output();
if let Ok(out) = output {
if out.status.success() {
let keys = String::from_utf8_lossy(&out.stdout);
for key in keys.lines() {
if !key.is_empty() {
entries.push((format!("{}@github", collab.username), key.to_string()));
}
}
}
}
}
let count = entries.len();
let path = self.relay.base_dir.join("allowed_signers");
write_allowed_signers(&path, &entries)?;
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_url_ssh() {
let result = parse_github_url("git@github.com:Naridon-Inc/agent-relay.git");
assert_eq!(result, Some(("Naridon-Inc".into(), "agent-relay".into())));
}
#[test]
fn test_parse_github_url_https() {
let result = parse_github_url("https://github.com/Naridon-Inc/agent-relay.git");
assert_eq!(result, Some(("Naridon-Inc".into(), "agent-relay".into())));
}
#[test]
fn test_parse_github_url_no_git_suffix() {
let result = parse_github_url("https://github.com/owner/repo");
assert_eq!(result, Some(("owner".into(), "repo".into())));
}
#[test]
fn test_parse_github_url_invalid() {
let result = parse_github_url("https://gitlab.com/owner/repo");
assert_eq!(result, None);
}
}