use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::RngCore;
const HANDSHAKE_PROTOCOL: &str = "rsclaw.a2a.relay.v1";
const NONCE_LEN: usize = 32;
fn b64() -> base64::engine::general_purpose::GeneralPurpose {
base64::engine::general_purpose::STANDARD
}
pub fn encode_b64(bytes: &[u8]) -> String {
b64().encode(bytes)
}
pub fn decode_b64(s: &str) -> Result<Vec<u8>> {
b64().decode(s.trim()).context("base64 decode")
}
pub fn fresh_nonce_b64() -> String {
let mut buf = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut buf);
encode_b64(&buf)
}
pub fn canonical_payload(
node_id: &str,
relay_id: &str,
nonce_node: &str,
nonce_relay: &str,
) -> Vec<u8> {
format!("{HANDSHAKE_PROTOCOL}\n{node_id}\n{relay_id}\n{nonce_node}\n{nonce_relay}")
.into_bytes()
}
pub fn signing_key_from_b64(s: &str) -> Result<SigningKey> {
let bytes = decode_b64(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow!("ed25519 private key must be 32 bytes"))?;
Ok(SigningKey::from_bytes(&arr))
}
pub fn verifying_key_from_b64(s: &str) -> Result<VerifyingKey> {
let bytes = decode_b64(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow!("ed25519 public key must be 32 bytes"))?;
VerifyingKey::from_bytes(&arr).context("invalid ed25519 public key")
}
pub fn sign_handshake(
signing_key: &SigningKey,
node_id: &str,
relay_id: &str,
nonce_node: &str,
nonce_relay: &str,
) -> String {
let payload = canonical_payload(node_id, relay_id, nonce_node, nonce_relay);
let sig: Signature = signing_key.sign(&payload);
encode_b64(&sig.to_bytes())
}
pub fn verify_handshake(
public_key_b64: &str,
node_id: &str,
relay_id: &str,
nonce_node: &str,
nonce_relay: &str,
signature_b64: &str,
) -> Result<()> {
if signature_b64.trim().is_empty() {
bail!("empty handshake signature");
}
let vk = verifying_key_from_b64(public_key_b64)?;
let sig_bytes = decode_b64(signature_b64)?;
let sig_arr: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| anyhow!("ed25519 signature must be 64 bytes"))?;
let signature = Signature::from_bytes(&sig_arr);
let payload = canonical_payload(node_id, relay_id, nonce_node, nonce_relay);
vk.verify(&payload, &signature)
.context("handshake signature verification failed")
}
pub fn derive_public_key_b64(private_b64: &str) -> Result<String> {
let sk = signing_key_from_b64(private_b64)?;
Ok(encode_b64(sk.verifying_key().as_bytes()))
}
pub fn generate_keypair_b64() -> (String, String) {
let mut secret = [0u8; 32];
rand::rng().fill_bytes(&mut secret);
let sk = SigningKey::from_bytes(&secret);
let pk = sk.verifying_key();
(encode_b64(&secret), encode_b64(pk.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_sign_verify() {
let (priv_b64, pub_b64) = generate_keypair_b64();
let sk = signing_key_from_b64(&priv_b64).unwrap();
let nonce_node = fresh_nonce_b64();
let nonce_relay = fresh_nonce_b64();
let sig = sign_handshake(&sk, "home-mac", "main", &nonce_node, &nonce_relay);
verify_handshake(&pub_b64, "home-mac", "main", &nonce_node, &nonce_relay, &sig)
.expect("valid signature must verify");
}
#[test]
fn wrong_node_id_fails() {
let (priv_b64, pub_b64) = generate_keypair_b64();
let sk = signing_key_from_b64(&priv_b64).unwrap();
let nn = fresh_nonce_b64();
let nr = fresh_nonce_b64();
let sig = sign_handshake(&sk, "home-mac", "main", &nn, &nr);
assert!(verify_handshake(&pub_b64, "other", "main", &nn, &nr, &sig).is_err());
}
#[test]
fn wrong_relay_nonce_fails_replay() {
let (priv_b64, pub_b64) = generate_keypair_b64();
let sk = signing_key_from_b64(&priv_b64).unwrap();
let nn = fresh_nonce_b64();
let sig = sign_handshake(&sk, "home-mac", "main", &nn, &fresh_nonce_b64());
assert!(
verify_handshake(&pub_b64, "home-mac", "main", &nn, &fresh_nonce_b64(), &sig).is_err(),
"different relay nonce must reject"
);
}
#[test]
fn empty_signature_rejected() {
let (_, pub_b64) = generate_keypair_b64();
assert!(
verify_handshake(&pub_b64, "n", "r", &fresh_nonce_b64(), &fresh_nonce_b64(), "")
.is_err()
);
}
#[test]
fn garbage_signature_rejected() {
let (_, pub_b64) = generate_keypair_b64();
assert!(
verify_handshake(
&pub_b64,
"n",
"r",
&fresh_nonce_b64(),
&fresh_nonce_b64(),
"not-base64!!"
)
.is_err()
);
}
#[test]
fn derive_public_matches_generate() {
let (priv_b64, pub_b64) = generate_keypair_b64();
assert_eq!(derive_public_key_b64(&priv_b64).unwrap(), pub_b64);
}
#[test]
#[ignore]
fn print_keypair() {
let (priv_b64, pub_b64) = generate_keypair_b64();
println!("RELAY_PRIV_B64={priv_b64}");
println!("RELAY_PUB_B64={pub_b64}");
}
#[test]
fn canonical_payload_is_stable() {
let p = canonical_payload("home-mac", "main", "AAAA", "BBBB");
assert_eq!(
std::str::from_utf8(&p).unwrap(),
"rsclaw.a2a.relay.v1\nhome-mac\nmain\nAAAA\nBBBB"
);
}
}