agent-rooms 0.1.0

Rust port of the parley protocol core (@p-vbordei/agent-rooms): canonical encoding, Ed25519 signing, message validation
Documentation
//! Ed25519 keypair generation, signing, and verification.
//!
//! Parley wire encoding (SPEC §3): bare lowercase hex — 64 chars for a
//! pubkey, 128 chars for a signature. No multibase prefix. The legacy
//! `ed25519:` string form from the reference impl is also provided for
//! parity with `parley/crypto/keys.py`.

use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::rngs::OsRng;

use crate::error::Error;

pub const PUBKEY_LEN: usize = 32;
pub const PRIVKEY_LEN: usize = 32;
pub const SIG_LEN: usize = 64;

/// Generate a fresh Ed25519 keypair. Returns `(secret_key, public_key)` as
/// 32-byte arrays.
pub fn generate_keypair() -> ([u8; PRIVKEY_LEN], [u8; PUBKEY_LEN]) {
    let signing = SigningKey::generate(&mut OsRng);
    let verifying = signing.verifying_key();
    (signing.to_bytes(), verifying.to_bytes())
}

/// Sign `message` with `secret_key`. Returns a 64-byte detached signature.
pub fn sign(secret_key: &[u8; PRIVKEY_LEN], message: &[u8]) -> [u8; SIG_LEN] {
    let signing = SigningKey::from_bytes(secret_key);
    signing.sign(message).to_bytes()
}

/// Verify `signature` over `message` against `public_key`. Returns false on
/// any failure (wrong length, malformed key, bad signature).
pub fn verify(public_key: &[u8; PUBKEY_LEN], message: &[u8], signature: &[u8]) -> bool {
    let Ok(verifying) = VerifyingKey::from_bytes(public_key) else {
        return false;
    };
    let Ok(sig_bytes) = <[u8; SIG_LEN]>::try_from(signature) else {
        return false;
    };
    let sig = Signature::from_bytes(&sig_bytes);
    verifying.verify(message, &sig).is_ok()
}

/// Parse a 64-char lowercase-hex pubkey to a 32-byte array.
pub fn pubkey_from_hex(hex_str: &str) -> Result<[u8; PUBKEY_LEN], Error> {
    let v = hex::decode(hex_str).map_err(|e| Error::InvalidHex(e.to_string()))?;
    if v.len() != PUBKEY_LEN {
        return Err(Error::InvalidPubkey);
    }
    let mut out = [0u8; PUBKEY_LEN];
    out.copy_from_slice(&v);
    Ok(out)
}

/// Parse a 128-char lowercase-hex signature to a 64-byte array.
pub fn sig_from_hex(hex_str: &str) -> Result<[u8; SIG_LEN], Error> {
    let v = hex::decode(hex_str).map_err(|e| Error::InvalidHex(e.to_string()))?;
    if v.len() != SIG_LEN {
        return Err(Error::BadSignature);
    }
    let mut out = [0u8; SIG_LEN];
    out.copy_from_slice(&v);
    Ok(out)
}

/// Lowercase-hex encode any byte slice.
pub fn to_hex(bytes: &[u8]) -> String {
    hex::encode(bytes)
}

/// Legacy "ed25519:" prefix form used by `parley/crypto/keys.py:pubkey_to_str`.
pub fn pubkey_to_prefixed_str(pubkey: &[u8; PUBKEY_LEN]) -> String {
    format!("ed25519:{}", hex::encode(pubkey))
}

/// Parse the legacy `ed25519:<hex>` form.
pub fn pubkey_from_prefixed_str(s: &str) -> Result<[u8; PUBKEY_LEN], Error> {
    let stripped = s.strip_prefix("ed25519:").ok_or_else(|| {
        Error::InvalidHex(format!("unsupported key format: {}", &s[..s.len().min(10)]))
    })?;
    pubkey_from_hex(stripped)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roundtrip_sign_verify() {
        let (sk, pk) = generate_keypair();
        let msg = b"hello parley";
        let sig = sign(&sk, msg);
        assert!(verify(&pk, msg, &sig));
        assert!(!verify(&pk, b"other", &sig));
    }

    #[test]
    fn deterministic_sk_pk() {
        // SK = 0x01 * 32 — same fixed key used by the conformance vectors.
        let sk = [1u8; 32];
        let signing = SigningKey::from_bytes(&sk);
        let pk = signing.verifying_key().to_bytes();
        assert_eq!(
            hex::encode(pk),
            "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c"
        );
    }
}