polyc-crypto 2026.8.2

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
Documentation
//! Canonical signing for conversation handoff 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. Everything else,
//! including `signed_by`, must match exactly.
//!
//! The canonical bytes never reach the key directly. The handoff role frames
//! them with its issuer and with the artifact kind first. See
//! [`crate::signing_role::RoleSigner::sign_handoff`]. Two handoff artifact
//! kinds therefore never verify as one another, and neither verifies as a
//! sub-agent artifact.
//!
//! The hex-encoded signature lives on the wire because handoff events are
//! grep-able in journal dumps, and 128 hex chars is still tiny.
//!
//! Use [`sign_handoff_into`] / [`sign_handoff_denied_into`] to mint and attach
//! a signature in place. Use [`classify_handoff`] / [`classify_handoff_denied`]
//! to read provenance against a deployment trust set. The classifiers never
//! panic. Bad hex and a bad signature read as
//! [`SignatureVerdict::Invalid`]. A good signature from a key the deployment
//! does not hold reads as [`SignatureVerdict::Untrusted`].

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

use crate::signing_role::{HandoffRole, HandoffSigner, RoleTrustSet, SignatureVerdict};

/// 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 [`HandoffDenied`]: the encoding with
/// `signature_hex` cleared.
fn handoff_denied_canonical_bytes(denied: &HandoffDenied) -> Vec<u8> {
    let mut canonical = denied.clone();
    canonical.signature_hex.clear();
    canonical.encode_to_vec()
}

/// Signs the canonical bytes of `handoff` in place, in the handoff artifact
/// domain.
///
/// The function also sets `signed_by` to the signer's encoded public key. The
/// signature therefore covers the claimed key, so a verifier cannot be tricked
/// into checking a payload against a key the originator never claimed.
pub fn sign_handoff_into(signer: &HandoffSigner, handoff: &mut Handoff) {
    handoff.signed_by = signer.public_key_bytes();
    handoff.signature_hex.clear();
    let signature = signer.sign_handoff(&handoff_canonical_bytes(handoff));
    handoff.signature_hex = crate::hex::lower(&signature);
}

/// Signs the canonical bytes of `denied` in place, in the refusal artifact
/// domain, and sets `signed_by` to the signer's encoded public key.
pub fn sign_handoff_denied_into(signer: &HandoffSigner, denied: &mut HandoffDenied) {
    denied.signed_by = signer.public_key_bytes();
    denied.signature_hex.clear();
    let signature = signer.sign_handoff_denied(&handoff_denied_canonical_bytes(denied));
    denied.signature_hex = crate::hex::lower(&signature);
}

/// Classifies the provenance signature on `handoff` against `trust`.
///
/// The verdict is [`SignatureVerdict::Verified`] only when the signature
/// checks out against the embedded `signed_by` key AND `trust` holds that key
/// for the handoff role. A good signature from a key outside `trust` reads as
/// [`SignatureVerdict::Untrusted`]. Bad hex, a bad signature, and a malformed
/// key all read as [`SignatureVerdict::Invalid`]. The function never panics.
#[must_use]
pub fn classify_handoff(trust: &RoleTrustSet<HandoffRole>, handoff: &Handoff) -> SignatureVerdict {
    let Some(signature) = crate::hex::decode(&handoff.signature_hex) else {
        return SignatureVerdict::Invalid;
    };
    trust.classify_handoff(
        &handoff.signed_by,
        &handoff_canonical_bytes(handoff),
        &signature,
    )
}

/// Classifies the provenance signature on `denied` against `trust`.
///
/// The verdicts read exactly as [`classify_handoff`] describes. The function
/// never panics.
#[must_use]
pub fn classify_handoff_denied(
    trust: &RoleTrustSet<HandoffRole>,
    denied: &HandoffDenied,
) -> SignatureVerdict {
    let Some(signature) = crate::hex::decode(&denied.signature_hex) else {
        return SignatureVerdict::Invalid;
    };
    trust.classify_handoff_denied(
        &denied.signed_by,
        &handoff_denied_canonical_bytes(denied),
        &signature,
    )
}

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

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

    use super::*;

    /// The deployment trust set that holds exactly `signer`'s key.
    fn trust(signer: &HandoffSigner) -> RoleTrustSet<HandoffRole> {
        RoleTrustSet::current(signer)
    }

    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_denied() -> HandoffDenied {
        HandoffDenied {
            parent_conversation_id: "parent-7".to_owned(),
            parent_agent_id: "assistant".to_owned(),
            child_agent_id: "researcher".to_owned(),
            reason: "delegate research".to_owned(),
            denial_reason: "this agent can't hand off to that agent".to_owned(),
            allowed: vec!["coding".to_owned()],
            ..Default::default()
        }
    }

    #[test]
    fn handoff_round_trips() {
        let signer = HandoffSigner::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_eq!(
            classify_handoff(&trust(&signer), &h),
            SignatureVerdict::Verified
        );
    }

    #[test]
    fn handoff_tampered_child_id_fails() {
        let signer = HandoffSigner::from_seed(11);
        let mut h = sample_handoff();
        sign_handoff_into(&signer, &mut h);
        h.child_conversation_id = "child-evil".to_owned();
        assert_eq!(
            classify_handoff(&trust(&signer), &h),
            SignatureVerdict::Invalid
        );
    }

    #[test]
    fn handoff_tampered_carried_context_fails() {
        let signer = HandoffSigner::from_seed(11);
        let mut h = sample_handoff();
        sign_handoff_into(&signer, &mut h);
        h.carried_context.push(text_msg("user", "leaked"));
        assert_eq!(
            classify_handoff(&trust(&signer), &h),
            SignatureVerdict::Invalid
        );
    }

    #[test]
    fn handoff_from_an_untrusted_signer_reads_untrusted() {
        let signer = HandoffSigner::from_seed(11);
        let other = HandoffSigner::from_seed(12);
        let mut h = sample_handoff();
        sign_handoff_into(&signer, &mut h);
        assert_eq!(
            classify_handoff(&trust(&other), &h),
            SignatureVerdict::Untrusted
        );
    }

    #[test]
    fn handoff_unsigned_fails() {
        let signer = HandoffSigner::from_seed(11);
        let h = sample_handoff();
        assert_eq!(
            classify_handoff(&trust(&signer), &h),
            SignatureVerdict::Invalid
        );
    }

    #[test]
    fn handoff_denied_round_trips() {
        let signer = HandoffSigner::from_seed(14);
        let mut d = sample_denied();
        sign_handoff_denied_into(&signer, &mut d);
        assert!(!d.signature_hex.is_empty());
        assert_eq!(d.signed_by, signer.public_key_bytes());
        assert_eq!(
            classify_handoff_denied(&trust(&signer), &d),
            SignatureVerdict::Verified
        );
    }

    #[test]
    fn handoff_denied_tampered_allowed_fails() {
        let signer = HandoffSigner::from_seed(14);
        let mut d = sample_denied();
        sign_handoff_denied_into(&signer, &mut d);
        d.allowed.push("evil".to_owned());
        assert_eq!(
            classify_handoff_denied(&trust(&signer), &d),
            SignatureVerdict::Invalid
        );
    }

    #[test]
    fn handoff_denied_tampered_child_agent_id_fails() {
        let signer = HandoffSigner::from_seed(14);
        let mut d = sample_denied();
        sign_handoff_denied_into(&signer, &mut d);
        d.child_agent_id = "evil-agent".to_owned();
        assert_eq!(
            classify_handoff_denied(&trust(&signer), &d),
            SignatureVerdict::Invalid
        );
    }

    #[test]
    fn handoff_denied_from_an_untrusted_signer_reads_untrusted() {
        let signer = HandoffSigner::from_seed(14);
        let other = HandoffSigner::from_seed(15);
        let mut d = sample_denied();
        sign_handoff_denied_into(&signer, &mut d);
        assert_eq!(
            classify_handoff_denied(&trust(&other), &d),
            SignatureVerdict::Untrusted
        );
    }

    #[test]
    fn handoff_denied_unsigned_fails() {
        let signer = HandoffSigner::from_seed(14);
        let d = sample_denied();
        assert_eq!(
            classify_handoff_denied(&trust(&signer), &d),
            SignatureVerdict::Invalid
        );
    }

    #[test]
    fn handoff_garbage_signature_hex_reads_invalid() {
        let signer = HandoffSigner::from_seed(11);
        let pinned = trust(&signer);
        let mut h = sample_handoff();
        h.signature_hex = "not-hex!".to_owned();
        assert_eq!(classify_handoff(&pinned, &h), SignatureVerdict::Invalid);
        h.signature_hex = "abcd".to_owned();
        assert_eq!(classify_handoff(&pinned, &h), SignatureVerdict::Invalid);
    }

    /// A refusal signature never verifies as an accepted handoff.
    ///
    /// Both messages carry three strings and one bytes field under the same
    /// tags, so their canonical bytes can collide. The artifact domain in the
    /// signed frame is what separates them.
    #[test]
    fn a_refusal_signature_does_not_verify_as_a_handoff() {
        let signer = HandoffSigner::from_seed(16);
        let mut denied = sample_denied();
        sign_handoff_denied_into(&signer, &mut denied);

        let mut forged = Handoff {
            child_conversation_id: denied.parent_conversation_id.clone(),
            child_agent_id: denied.parent_agent_id.clone(),
            reason: denied.child_agent_id.clone(),
            signed_by: denied.signed_by.clone(),
            signature_hex: denied.signature_hex.clone(),
            ..Default::default()
        };
        assert_eq!(
            classify_handoff(&trust(&signer), &forged),
            SignatureVerdict::Invalid
        );

        // And the reverse direction, over the same key.
        let mut handoff = sample_handoff();
        sign_handoff_into(&signer, &mut handoff);
        forged = handoff.clone();
        let replayed = HandoffDenied {
            parent_conversation_id: handoff.child_conversation_id.clone(),
            parent_agent_id: handoff.child_agent_id.clone(),
            signed_by: handoff.signed_by.clone(),
            signature_hex: handoff.signature_hex.clone(),
            ..Default::default()
        };
        assert_eq!(
            classify_handoff_denied(&trust(&signer), &replayed),
            SignatureVerdict::Invalid
        );
        assert_eq!(
            classify_handoff(&trust(&signer), &forged),
            SignatureVerdict::Verified
        );
    }

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