agent_mesh_protocol/
github_binding.rs1use crate::user_key::UserPublic;
19use crate::{MeshError, Result};
20use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
21use serde::{Deserialize, Serialize};
22use ssh_key::{Algorithm, PrivateKey as SshPrivateKey, PublicKey as SshPublicKey};
23
24const BINDING_TAG: &[u8] = b"agent-mesh-github-binding-v1";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct GitHubBinding {
33 pub user_pubkey: UserPublic,
35 pub ssh_pubkey: [u8; 32],
37 pub github_username: Option<String>,
41 #[serde(with = "ssh_sig_serde")]
44 pub signature: Signature,
45}
46
47impl GitHubBinding {
48 pub fn sign(
54 user: &UserPublic,
55 ssh_key: &SshPrivateKey,
56 github_username: Option<String>,
57 ) -> Result<Self> {
58 let ssh_signing = ssh_to_ed25519_signing(ssh_key)?;
59 let msg = binding_message(user);
60 let sig = ssh_signing.sign(&msg);
61 let ssh_verifying = ssh_signing.verifying_key();
62 Ok(Self {
63 user_pubkey: user.clone(),
64 ssh_pubkey: *ssh_verifying.as_bytes(),
65 github_username,
66 signature: sig,
67 })
68 }
69
70 pub fn verify(&self, candidate_ssh_pubkey: &[u8; 32]) -> Result<()> {
79 if self.ssh_pubkey != *candidate_ssh_pubkey {
80 return Err(MeshError::BadSignature);
81 }
82 let verifying = VerifyingKey::from_bytes(candidate_ssh_pubkey)
83 .map_err(|e| MeshError::InvalidKey(e.to_string()))?;
84 let msg = binding_message(&self.user_pubkey);
85 verifying
86 .verify(&msg, &self.signature)
87 .map_err(|_| MeshError::BadSignature)
88 }
89}
90
91pub fn ssh_pubkey_ed25519_bytes(pub_key: &SshPublicKey) -> Result<[u8; 32]> {
95 if pub_key.algorithm() != Algorithm::Ed25519 {
96 return Err(MeshError::InvalidKey(format!(
97 "expected ed25519 SSH key, got {:?}",
98 pub_key.algorithm()
99 )));
100 }
101 let ed = pub_key
102 .key_data()
103 .ed25519()
104 .ok_or_else(|| MeshError::InvalidKey("not ed25519".into()))?;
105 Ok(ed.0)
106}
107
108fn binding_message(user: &UserPublic) -> Vec<u8> {
109 let mut msg = Vec::with_capacity(BINDING_TAG.len() + 32);
110 msg.extend_from_slice(BINDING_TAG);
111 msg.extend_from_slice(&user.as_bytes());
112 msg
113}
114
115fn ssh_to_ed25519_signing(ssh: &SshPrivateKey) -> Result<SigningKey> {
116 if ssh.algorithm() != Algorithm::Ed25519 {
117 return Err(MeshError::InvalidKey(format!(
118 "expected ed25519 SSH key, got {:?}",
119 ssh.algorithm()
120 )));
121 }
122 let ed = ssh
123 .key_data()
124 .ed25519()
125 .ok_or_else(|| MeshError::InvalidKey("not ed25519".into()))?;
126 Ok(SigningKey::from_bytes(&ed.private.to_bytes()))
127}
128
129mod ssh_sig_serde {
130 use ed25519_dalek::Signature;
131 use serde::{Deserialize, Deserializer, Serialize, Serializer};
132
133 pub fn serialize<S: Serializer>(sig: &Signature, ser: S) -> Result<S::Ok, S::Error> {
134 let bytes: [u8; 64] = sig.to_bytes();
135 bytes.serialize(ser)
136 }
137
138 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Signature, D::Error> {
139 let bytes: Vec<u8> = Vec::deserialize(de)?;
140 if bytes.len() != 64 {
141 return Err(serde::de::Error::custom("expected 64-byte signature"));
142 }
143 let mut arr = [0u8; 64];
144 arr.copy_from_slice(&bytes);
145 Ok(Signature::from_bytes(&arr))
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::UserKey;
153 use rand::rngs::OsRng;
154 use ssh_key::{LineEnding, PrivateKey};
155
156 fn fresh_ssh() -> PrivateKey {
157 PrivateKey::random(&mut OsRng, Algorithm::Ed25519).expect("generate ssh key")
158 }
159
160 #[test]
161 fn sign_and_verify_binding() {
162 let user = UserKey::generate();
163 let ssh = fresh_ssh();
164 let binding = GitHubBinding::sign(&user.public(), &ssh, Some("alice".into())).unwrap();
165 let ssh_pub = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
166 binding.verify(&ssh_pub).expect("happy path");
167 }
168
169 #[test]
170 fn wrong_ssh_key_fails_verify() {
171 let user = UserKey::generate();
172 let ssh = fresh_ssh();
173 let other = fresh_ssh();
174 let binding = GitHubBinding::sign(&user.public(), &ssh, None).unwrap();
175 let other_pub = ssh_pubkey_ed25519_bytes(other.public_key()).unwrap();
176 assert!(matches!(
177 binding.verify(&other_pub).unwrap_err(),
178 MeshError::BadSignature
179 ));
180 }
181
182 #[test]
183 fn tampered_user_key_fails_verify() {
184 let user = UserKey::generate();
185 let attacker = UserKey::generate();
186 let ssh = fresh_ssh();
187 let mut binding = GitHubBinding::sign(&user.public(), &ssh, None).unwrap();
188 binding.user_pubkey = attacker.public();
189 let ssh_pub = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
190 assert!(matches!(
191 binding.verify(&ssh_pub).unwrap_err(),
192 MeshError::BadSignature
193 ));
194 }
195
196 #[test]
197 fn wrong_algorithm_ssh_key_rejected_on_sign() {
198 let ssh = fresh_ssh();
206 let bytes = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
207 assert_eq!(bytes.len(), 32);
208 }
209
210 #[test]
211 fn serde_roundtrip_binding() {
212 let user = UserKey::generate();
213 let ssh = fresh_ssh();
214 let binding = GitHubBinding::sign(&user.public(), &ssh, Some("bob".into())).unwrap();
215 let json = serde_json::to_string(&binding).unwrap();
216 let parsed: GitHubBinding = serde_json::from_str(&json).unwrap();
217 assert_eq!(parsed, binding);
218 let ssh_pub = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
219 parsed
220 .verify(&ssh_pub)
221 .expect("roundtripped binding still verifies");
222 }
223
224 #[test]
225 fn ssh_pubkey_extracts_correctly() {
226 let ssh = fresh_ssh();
227 let bytes = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
228 let kp = ssh.key_data().ed25519().unwrap();
231 let signing = SigningKey::from_bytes(&kp.private.to_bytes());
232 assert_eq!(bytes, *signing.verifying_key().as_bytes());
233 }
234
235 #[test]
236 fn binding_survives_openssh_roundtrip() {
237 let ssh = fresh_ssh();
240 let user = UserKey::generate();
241 let pem = ssh.to_openssh(LineEnding::LF).unwrap();
242 let reparsed = PrivateKey::from_openssh(pem.as_bytes()).unwrap();
243 let binding = GitHubBinding::sign(&user.public(), &reparsed, Some("carol".into())).unwrap();
244 let ssh_pub = ssh_pubkey_ed25519_bytes(reparsed.public_key()).unwrap();
245 binding
246 .verify(&ssh_pub)
247 .expect("openssh-roundtripped binding verifies");
248 }
249}