pub const NAMESPACE_TAG: u8 = 0xF0;
pub const MAGIC: &[u8] = b"x0x.external-agent-sign.v1";
pub const SCHEME_ID: &str = "x0x.agent-sign.v2.ml-dsa-65";
pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
pub const MAX_CONTEXT_LEN: usize = 64;
pub const INTERNAL_CONTEXT_DENYLIST: &[&str] = &[
"announcement",
"user-announcement",
"certificate",
"agent-certificate",
"group-commit",
"group-message",
"treekem",
"dm",
"direct-message",
"gossip",
"gossip-frame",
"a2a-card",
"agent-card",
"upgrade",
"upgrade-manifest",
"relay",
"peer-relay",
];
#[derive(Debug)]
pub struct ContextError(pub &'static str);
impl std::fmt::Display for ContextError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ContextError {}
pub fn validate_context(context: &str) -> Result<(), ContextError> {
if context.is_empty() {
return Err(ContextError("context must be non-empty"));
}
if context.len() > MAX_CONTEXT_LEN {
return Err(ContextError("context exceeds maximum length"));
}
if !context.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'_' || b == b'-'
}) {
return Err(ContextError(
"context must match [a-z0-9._-] (lowercase ASCII, digits, dot, underscore, hyphen)",
));
}
if INTERNAL_CONTEXT_DENYLIST.contains(&context) {
return Err(ContextError(
"context is reserved for an internal x0x signing domain",
));
}
Ok(())
}
#[must_use]
pub fn assemble_buffer(context: &str, payload: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(1 + MAGIC.len() + 4 + context.len() + payload.len());
buf.push(NAMESPACE_TAG);
buf.extend_from_slice(MAGIC);
let len = u32::try_from(context.len()).unwrap_or(u32::MAX);
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(context.as_bytes());
buf.extend_from_slice(payload);
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_contexts_accepted() {
for c in [
"x0x-symphony-handoff-v1",
"audit.log.v2",
"a",
"foo_bar-baz.qux",
"0123456789",
&"x".repeat(MAX_CONTEXT_LEN),
] {
assert!(
validate_context(c).is_ok(),
"context {c:?} should be accepted"
);
}
}
#[test]
fn invalid_contexts_rejected() {
assert!(validate_context("").is_err());
assert!(validate_context(&"x".repeat(MAX_CONTEXT_LEN + 1)).is_err());
for c in [
"Has Space",
"UPPER",
"with/slash",
"embedded\u{0}nul",
"punctuation!",
"café",
"x0x handoff",
] {
assert!(
validate_context(c).is_err(),
"context {c:?} should be rejected"
);
}
}
#[test]
fn internal_context_denylist_rejected() {
for c in INTERNAL_CONTEXT_DENYLIST {
assert!(
validate_context(c).is_err(),
"internal context {c:?} must be denied"
);
}
assert!(validate_context("announcement-v2-external").is_ok());
}
#[test]
fn assemble_buffer_is_length_prefixed_and_disjoint() {
let a = assemble_buffer("ctx", b"payload");
assert_eq!(a[0], NAMESPACE_TAG);
assert_eq!(&a[1..1 + MAGIC.len()], MAGIC);
let len = u32::from_be_bytes([
a[1 + MAGIC.len()],
a[2 + MAGIC.len()],
a[3 + MAGIC.len()],
a[4 + MAGIC.len()],
]);
assert_eq!(len as usize, "ctx".len());
}
#[test]
fn no_context_payload_collision_across_pairs() {
let left = assemble_buffer("ab", b"cd");
let right = assemble_buffer("abc", b"d");
assert_ne!(
left, right,
"length-prefixed framing must distinguish (ab,cd) from (abc,d)"
);
}
#[test]
fn external_buffer_cannot_be_a_valid_internal_prefix() {
let buf = assemble_buffer("any.context", b"");
assert_eq!(buf[0], NAMESPACE_TAG);
assert!(buf.starts_with(&[NAMESPACE_TAG]));
assert!(buf.starts_with(
&[NAMESPACE_TAG]
.iter()
.copied()
.chain(MAGIC.iter().copied())
.collect::<Vec<_>>()
));
}
}