use crate::user_key::UserPublic;
use crate::{MeshError, Result};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use ssh_key::{Algorithm, PrivateKey as SshPrivateKey, PublicKey as SshPublicKey};
const BINDING_TAG: &[u8] = b"agent-mesh-github-binding-v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GitHubBinding {
pub user_pubkey: UserPublic,
pub ssh_pubkey: [u8; 32],
pub github_username: Option<String>,
#[serde(with = "ssh_sig_serde")]
pub signature: Signature,
}
impl GitHubBinding {
pub fn sign(
user: &UserPublic,
ssh_key: &SshPrivateKey,
github_username: Option<String>,
) -> Result<Self> {
let ssh_signing = ssh_to_ed25519_signing(ssh_key)?;
let msg = binding_message(user);
let sig = ssh_signing.sign(&msg);
let ssh_verifying = ssh_signing.verifying_key();
Ok(Self {
user_pubkey: user.clone(),
ssh_pubkey: *ssh_verifying.as_bytes(),
github_username,
signature: sig,
})
}
pub fn verify(&self, candidate_ssh_pubkey: &[u8; 32]) -> Result<()> {
if self.ssh_pubkey != *candidate_ssh_pubkey {
return Err(MeshError::BadSignature);
}
let verifying = VerifyingKey::from_bytes(candidate_ssh_pubkey)
.map_err(|e| MeshError::InvalidKey(e.to_string()))?;
let msg = binding_message(&self.user_pubkey);
verifying
.verify(&msg, &self.signature)
.map_err(|_| MeshError::BadSignature)
}
}
pub fn ssh_pubkey_ed25519_bytes(pub_key: &SshPublicKey) -> Result<[u8; 32]> {
if pub_key.algorithm() != Algorithm::Ed25519 {
return Err(MeshError::InvalidKey(format!(
"expected ed25519 SSH key, got {:?}",
pub_key.algorithm()
)));
}
let ed = pub_key
.key_data()
.ed25519()
.ok_or_else(|| MeshError::InvalidKey("not ed25519".into()))?;
Ok(ed.0)
}
fn binding_message(user: &UserPublic) -> Vec<u8> {
let mut msg = Vec::with_capacity(BINDING_TAG.len() + 32);
msg.extend_from_slice(BINDING_TAG);
msg.extend_from_slice(&user.as_bytes());
msg
}
fn ssh_to_ed25519_signing(ssh: &SshPrivateKey) -> Result<SigningKey> {
if ssh.algorithm() != Algorithm::Ed25519 {
return Err(MeshError::InvalidKey(format!(
"expected ed25519 SSH key, got {:?}",
ssh.algorithm()
)));
}
let ed = ssh
.key_data()
.ed25519()
.ok_or_else(|| MeshError::InvalidKey("not ed25519".into()))?;
Ok(SigningKey::from_bytes(&ed.private.to_bytes()))
}
mod ssh_sig_serde {
use ed25519_dalek::Signature;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(sig: &Signature, ser: S) -> Result<S::Ok, S::Error> {
let bytes: [u8; 64] = sig.to_bytes();
bytes.serialize(ser)
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Signature, D::Error> {
let bytes: Vec<u8> = Vec::deserialize(de)?;
if bytes.len() != 64 {
return Err(serde::de::Error::custom("expected 64-byte signature"));
}
let mut arr = [0u8; 64];
arr.copy_from_slice(&bytes);
Ok(Signature::from_bytes(&arr))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UserKey;
use rand::rngs::OsRng;
use ssh_key::{LineEnding, PrivateKey};
fn fresh_ssh() -> PrivateKey {
PrivateKey::random(&mut OsRng, Algorithm::Ed25519).expect("generate ssh key")
}
#[test]
fn sign_and_verify_binding() {
let user = UserKey::generate();
let ssh = fresh_ssh();
let binding = GitHubBinding::sign(&user.public(), &ssh, Some("alice".into())).unwrap();
let ssh_pub = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
binding.verify(&ssh_pub).expect("happy path");
}
#[test]
fn wrong_ssh_key_fails_verify() {
let user = UserKey::generate();
let ssh = fresh_ssh();
let other = fresh_ssh();
let binding = GitHubBinding::sign(&user.public(), &ssh, None).unwrap();
let other_pub = ssh_pubkey_ed25519_bytes(other.public_key()).unwrap();
assert!(matches!(
binding.verify(&other_pub).unwrap_err(),
MeshError::BadSignature
));
}
#[test]
fn tampered_user_key_fails_verify() {
let user = UserKey::generate();
let attacker = UserKey::generate();
let ssh = fresh_ssh();
let mut binding = GitHubBinding::sign(&user.public(), &ssh, None).unwrap();
binding.user_pubkey = attacker.public();
let ssh_pub = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
assert!(matches!(
binding.verify(&ssh_pub).unwrap_err(),
MeshError::BadSignature
));
}
#[test]
fn wrong_algorithm_ssh_key_rejected_on_sign() {
let ssh = fresh_ssh();
let bytes = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
assert_eq!(bytes.len(), 32);
}
#[test]
fn serde_roundtrip_binding() {
let user = UserKey::generate();
let ssh = fresh_ssh();
let binding = GitHubBinding::sign(&user.public(), &ssh, Some("bob".into())).unwrap();
let json = serde_json::to_string(&binding).unwrap();
let parsed: GitHubBinding = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, binding);
let ssh_pub = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
parsed
.verify(&ssh_pub)
.expect("roundtripped binding still verifies");
}
#[test]
fn ssh_pubkey_extracts_correctly() {
let ssh = fresh_ssh();
let bytes = ssh_pubkey_ed25519_bytes(ssh.public_key()).unwrap();
let kp = ssh.key_data().ed25519().unwrap();
let signing = SigningKey::from_bytes(&kp.private.to_bytes());
assert_eq!(bytes, *signing.verifying_key().as_bytes());
}
#[test]
fn binding_survives_openssh_roundtrip() {
let ssh = fresh_ssh();
let user = UserKey::generate();
let pem = ssh.to_openssh(LineEnding::LF).unwrap();
let reparsed = PrivateKey::from_openssh(pem.as_bytes()).unwrap();
let binding = GitHubBinding::sign(&user.public(), &reparsed, Some("carol".into())).unwrap();
let ssh_pub = ssh_pubkey_ed25519_bytes(reparsed.public_key()).unwrap();
binding
.verify(&ssh_pub)
.expect("openssh-roundtripped binding verifies");
}
}