rsclaw-runtime 2026.6.26

rsclaw composition root: AppState/RPC handlers (a2a, cmd, cron, gateway, hooks, server, ws) + process entry point
//! Ed25519 keypair identity for the relay overlay (spec Phase 5).
//!
//! Hub-side: load a node's `public_key` from config, verify a signed nonce
//! over the canonical handshake payload.
//!
//! Spoke-side: load `private_key` (inline or from file), sign the
//! challenge payload, and present the signature in an `Auth` frame.
//!
//! Canonical signed payload (must match across hub/spoke implementations):
//! `rsclaw.a2a.relay.v1\n{node_id}\n{relay_id}\n{nonce_node}\n{nonce_relay}`
//!
//! - `nonce_node` is generated by the spoke and sent in the `Hello` frame.
//! - `nonce_relay` is generated by the hub and sent in the `Challenge`
//!   frame; binds the signature to a specific connection (replay defence).
//! - Both nonces are 32 raw bytes encoded as base64.

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")
}

/// 32 fresh random bytes, base64 encoded. Suitable for handshake nonces.
pub fn fresh_nonce_b64() -> String {
    let mut buf = [0u8; NONCE_LEN];
    rand::rng().fill_bytes(&mut buf);
    encode_b64(&buf)
}

/// Canonical message bytes that BOTH sides must hash/sign. Any drift here
/// breaks every existing trust relationship — change only with a protocol
/// version bump.
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()
}

/// Load a SigningKey from base64-encoded raw 32 bytes (spoke side).
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))
}

/// Load a VerifyingKey from base64-encoded raw 32 bytes (hub side).
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")
}

/// Sign the canonical payload with a spoke private key. Returns the
/// base64-encoded 64-byte signature.
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())
}

/// Verify a base64 signature against the canonical payload. Fail-closed:
/// any decoding/structural error returns Err.
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")
}

/// Helper for tooling/tests: derive the public key (base64) from a private
/// key (base64).
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()))
}

/// Generate a brand-new keypair (private_b64, public_b64). Convenience
/// for setup tooling; not used at runtime.
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() {
        // Exactly the replay defence: signature is bound to nonce_relay.
        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);
    }

    /// Tooling helper for relay setup. Print a fresh keypair so an
    /// operator can `cargo test --lib relay_identity::tests::print_keypair
    /// -- --ignored --nocapture`. Not a real test; never fails.
    #[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() {
        // Pin the exact byte layout — any change here invalidates every
        // production-deployed trust relationship. Bump HANDSHAKE_PROTOCOL
        // version if you really need to change format.
        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"
        );
    }
}