polyc-crypto 0.1.3

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
Documentation
//! Canonical signing for handoff (sub-agent transfer) provenance.
//!
//! A signed handoff event commits to its *canonical bytes*: the buffa encoding
//! of the message with the `signature_hex` field cleared. The signer sets
//! `signed_by` to its encoded public key before signing so the encoded key is
//! covered too; the verifier clears `signature_hex` only (the rest, including
//! `signed_by`, must match exactly). Mirrors `crate::toolcall`'s pattern.
//!
//! The hex-encoded signature lives on the wire because handoff events are
//! grep-able in journal dumps and 128 hex chars is still tiny; the
//! per-tool-call signatures stay raw bytes because they show up on every
//! tool round-trip and pay a 2x cost.
//!
//! Use [`sign_handoff_into`] / [`sign_handoff_return_into`] to mint and
//! attach a signature in place; [`verify_handoff`] / [`verify_handoff_return`]
//! to check provenance. The verifiers never panic — bad hex, bad signature,
//! wrong key all surface as `false`.

use buffa::Message as _;
use polyc_proto::proto::polychrome::handoff::v1::{Handoff, HandoffReturn};

use crate::{Signer, verify};

/// Canonical bytes for a [`Handoff`]: the encoding with `signature_hex`
/// cleared, so the signature commits to everything *except* itself (including
/// the originator's `signed_by`).
fn handoff_canonical_bytes(handoff: &Handoff) -> Vec<u8> {
    let mut canonical = handoff.clone();
    canonical.signature_hex.clear();
    canonical.encode_to_vec()
}

/// Canonical bytes for a [`HandoffReturn`]: the encoding with `signature_hex`
/// cleared.
fn handoff_return_canonical_bytes(ret: &HandoffReturn) -> Vec<u8> {
    let mut canonical = ret.clone();
    canonical.signature_hex.clear();
    canonical.encode_to_vec()
}

/// Sign the canonical bytes of `handoff`, returning the hex-encoded signature
/// suitable for [`Handoff::signature_hex`].
///
/// Also sets `signed_by` to the signer's encoded public key (so the encoded
/// key is covered by the signature — preventing a verifier from being
/// tricked into checking a payload against a key the originator never
/// claimed).
pub fn sign_handoff_into(signer: &Signer, handoff: &mut Handoff) {
    handoff.signed_by = signer.public_key_bytes();
    handoff.signature_hex.clear();
    let sig = signer.sign(&handoff_canonical_bytes(handoff));
    handoff.signature_hex = hex_encode(&sig);
}

/// Sign the canonical bytes of `ret`, storing the hex signature in place and
/// setting `signed_by` to the signer's encoded public key.
pub fn sign_handoff_return_into(signer: &Signer, ret: &mut HandoffReturn) {
    ret.signed_by = signer.public_key_bytes();
    ret.signature_hex.clear();
    let sig = signer.sign(&handoff_return_canonical_bytes(ret));
    ret.signature_hex = hex_encode(&sig);
}

/// Verify the provenance signature on `handoff` against an encoded
/// `public_key`. Use `&handoff.signed_by` for self-verifying signatures.
///
/// Returns `false` on any decode failure or signature mismatch — never panics.
#[must_use]
pub fn verify_handoff(public_key: &[u8], handoff: &Handoff) -> bool {
    let Some(sig) = hex_decode(&handoff.signature_hex) else {
        return false;
    };
    verify(public_key, &handoff_canonical_bytes(handoff), &sig)
}

/// Verify the provenance signature on `ret` against an encoded `public_key`.
///
/// Returns `false` on any decode failure or signature mismatch — never panics.
#[must_use]
pub fn verify_handoff_return(public_key: &[u8], ret: &HandoffReturn) -> bool {
    let Some(sig) = hex_decode(&ret.signature_hex) else {
        return false;
    };
    verify(public_key, &handoff_return_canonical_bytes(ret), &sig)
}

/// Encode raw bytes as lowercase hex. Inlined so the crate stays free of a
/// `hex` dependency — handoff events are infrequent and a few-dozen-byte
/// signature dwarfs the per-byte work.
fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        out.push(HEX[(b >> 4) as usize] as char);
        out.push(HEX[(b & 0x0f) as usize] as char);
    }
    out
}

/// Decode lowercase or uppercase hex. Returns `None` on any invalid digit or
/// odd input length. Never panics.
fn hex_decode(s: &str) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(2) {
        return None;
    }
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len() / 2);
    for pair in bytes.chunks_exact(2) {
        let hi = nibble(pair[0])?;
        let lo = nibble(pair[1])?;
        out.push((hi << 4) | lo);
    }
    Some(out)
}

const fn nibble(c: u8) -> Option<u8> {
    match c {
        b'0'..=b'9' => Some(c - b'0'),
        b'a'..=b'f' => Some(c - b'a' + 10),
        b'A'..=b'F' => Some(c - b'A' + 10),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use polyc_proto::proto::polychrome::agent::v1::{Content, Message, TextContent, content};

    use super::*;

    fn text_msg(role: &str, text: &str) -> Message {
        Message {
            role: role.to_owned(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::Text(Box::new(TextContent {
                    text: text.to_owned(),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            internal_only: false,
            ..Default::default()
        }
    }

    fn sample_handoff() -> Handoff {
        Handoff {
            child_conversation_id: "child-7".to_owned(),
            child_agent_id: "researcher".to_owned(),
            carried_count: 2,
            carried_context: vec![text_msg("user", "find prior art"), text_msg("model", "ok")],
            reason: "delegate research".to_owned(),
            ..Default::default()
        }
    }

    fn sample_return() -> HandoffReturn {
        HandoffReturn {
            child_conversation_id: "child-7".to_owned(),
            final_message: buffa::MessageField::some(text_msg("model", "result: 42")),
            ..Default::default()
        }
    }

    #[test]
    fn handoff_round_trips() {
        let signer = Signer::from_seed(11);
        let mut h = sample_handoff();
        sign_handoff_into(&signer, &mut h);
        assert!(!h.signature_hex.is_empty());
        assert_eq!(h.signed_by, signer.public_key_bytes());
        assert!(verify_handoff(&h.signed_by, &h));
    }

    #[test]
    fn handoff_tampered_child_id_fails() {
        let signer = Signer::from_seed(11);
        let mut h = sample_handoff();
        sign_handoff_into(&signer, &mut h);
        h.child_conversation_id = "child-evil".to_owned();
        assert!(!verify_handoff(&h.signed_by, &h));
    }

    #[test]
    fn handoff_tampered_carried_context_fails() {
        let signer = Signer::from_seed(11);
        let mut h = sample_handoff();
        sign_handoff_into(&signer, &mut h);
        h.carried_context.push(text_msg("user", "leaked"));
        assert!(!verify_handoff(&h.signed_by, &h));
    }

    #[test]
    fn handoff_wrong_key_fails() {
        let signer = Signer::from_seed(11);
        let other = Signer::from_seed(12);
        let mut h = sample_handoff();
        sign_handoff_into(&signer, &mut h);
        assert!(!verify_handoff(&other.public_key_bytes(), &h));
    }

    #[test]
    fn handoff_unsigned_fails() {
        let signer = Signer::from_seed(11);
        let h = sample_handoff();
        assert!(!verify_handoff(&signer.public_key_bytes(), &h));
    }

    #[test]
    fn handoff_return_round_trips() {
        let signer = Signer::from_seed(13);
        let mut r = sample_return();
        sign_handoff_return_into(&signer, &mut r);
        assert!(verify_handoff_return(&signer.public_key_bytes(), &r));
    }

    #[test]
    fn handoff_return_tampered_final_message_fails() {
        let signer = Signer::from_seed(13);
        let mut r = sample_return();
        sign_handoff_return_into(&signer, &mut r);
        r.final_message = buffa::MessageField::some(text_msg("model", "tampered"));
        assert!(!verify_handoff_return(&signer.public_key_bytes(), &r));
    }

    #[test]
    fn handoff_garbage_signature_hex_returns_false() {
        let mut h = sample_handoff();
        h.signature_hex = "not-hex!".to_owned();
        assert!(!verify_handoff(b"any-key", &h));
        h.signature_hex = "abcd".to_owned();
        assert!(!verify_handoff(b"any-key", &h));
    }

    #[test]
    fn hex_round_trip() {
        for bytes in [
            &b""[..],
            &[0x00, 0xff, 0x10, 0xab][..],
            &(0u8..=255).collect::<Vec<_>>()[..],
        ] {
            let s = hex_encode(bytes);
            assert_eq!(hex_decode(&s).as_deref(), Some(bytes));
        }
        assert!(hex_decode("abc").is_none(), "odd length rejected");
        assert!(hex_decode("zz").is_none(), "non-hex rejected");
    }
}