use buffa::Message as _;
use polyc_proto::proto::polychrome::handoff::v1::{Handoff, HandoffDenied};
use crate::signing_role::{HandoffRole, HandoffSigner, RoleTrustSet, SignatureVerdict};
fn handoff_canonical_bytes(handoff: &Handoff) -> Vec<u8> {
let mut canonical = handoff.clone();
canonical.signature_hex.clear();
canonical.encode_to_vec()
}
fn handoff_denied_canonical_bytes(denied: &HandoffDenied) -> Vec<u8> {
let mut canonical = denied.clone();
canonical.signature_hex.clear();
canonical.encode_to_vec()
}
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);
}
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);
}
#[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,
)
}
#[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::*;
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);
}
#[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
);
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");
}
}