use chrono::{DateTime, Utc};
pub const DOMAIN_TAG_ATTESTATION_PREIMAGE: u8 = 0x10;
pub const DOMAIN_TAG_ROTATION_ENVELOPE: u8 = 0x11;
pub const SCHEMA_VERSION_ATTESTATION: u16 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceIdentity {
User,
ChildAgent {
agent_id: String,
parent_session_id: String,
delegation_id: String,
model: String,
},
Tool {
name: String,
},
Runtime,
ExternalOutcome,
ManualCorrection,
}
impl SourceIdentity {
#[must_use]
pub const fn variant_tag(&self) -> &'static str {
match self {
Self::User => "user",
Self::ChildAgent { .. } => "child_agent",
Self::Tool { .. } => "tool",
Self::Runtime => "runtime",
Self::ExternalOutcome => "external_outcome",
Self::ManualCorrection => "manual_correction",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineageBinding {
ChainPosition(u64),
PreviousHash(String),
}
impl LineageBinding {
#[must_use]
pub const fn tag(&self) -> u8 {
match self {
Self::ChainPosition(_) => 0x01,
Self::PreviousHash(_) => 0x02,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttestationPreimage {
pub schema_version: u16,
pub source: SourceIdentity,
pub event_id: String,
pub payload_hash: String,
pub session_id: String,
pub ledger_id: String,
pub lineage: LineageBinding,
pub signed_at: DateTime<Utc>,
pub key_id: String,
}
#[must_use]
pub fn canonical_signing_input(p: &AttestationPreimage) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
out.push(DOMAIN_TAG_ATTESTATION_PREIMAGE);
out.extend_from_slice(&p.schema_version.to_be_bytes());
write_lp(&mut out, p.source.variant_tag().as_bytes());
match &p.source {
SourceIdentity::User
| SourceIdentity::Runtime
| SourceIdentity::ExternalOutcome
| SourceIdentity::ManualCorrection => {}
SourceIdentity::ChildAgent {
agent_id,
parent_session_id,
delegation_id,
model,
} => {
write_lp(&mut out, agent_id.as_bytes());
write_lp(&mut out, parent_session_id.as_bytes());
write_lp(&mut out, delegation_id.as_bytes());
write_lp(&mut out, model.as_bytes());
}
SourceIdentity::Tool { name } => {
write_lp(&mut out, name.as_bytes());
}
}
write_lp(&mut out, p.event_id.as_bytes());
write_lp(&mut out, p.payload_hash.as_bytes());
write_lp(&mut out, p.session_id.as_bytes());
write_lp(&mut out, p.ledger_id.as_bytes());
out.push(p.lineage.tag());
match &p.lineage {
LineageBinding::ChainPosition(n) => {
out.extend_from_slice(&8u64.to_be_bytes());
out.extend_from_slice(&n.to_be_bytes());
}
LineageBinding::PreviousHash(hex) => {
write_lp(&mut out, hex.as_bytes());
}
}
let signed_at_str = p
.signed_at
.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
write_lp(&mut out, signed_at_str.as_bytes());
write_lp(&mut out, p.key_id.as_bytes());
out
}
#[must_use]
pub fn canonical_rotation_input(
schema_version: u16,
old_pubkey: &[u8; 32],
new_pubkey: &[u8; 32],
signed_at: DateTime<Utc>,
) -> Vec<u8> {
let mut out = Vec::with_capacity(128);
out.push(DOMAIN_TAG_ROTATION_ENVELOPE);
out.extend_from_slice(&schema_version.to_be_bytes());
write_lp(&mut out, old_pubkey);
write_lp(&mut out, new_pubkey);
let signed_at_str = signed_at.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
write_lp(&mut out, signed_at_str.as_bytes());
out
}
fn write_lp(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
out.extend_from_slice(bytes);
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixture_preimage() -> AttestationPreimage {
AttestationPreimage {
schema_version: SCHEMA_VERSION_ATTESTATION,
source: SourceIdentity::User,
event_id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
payload_hash: "deadbeef".into(),
session_id: "session-001".into(),
ledger_id: "ledger-main".into(),
lineage: LineageBinding::ChainPosition(10),
signed_at: Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap(),
key_id: "fp:abc123".into(),
}
}
#[test]
fn canonical_bytes_match_golden_hex_fixture() {
let bytes = canonical_signing_input(&fixture_preimage());
let hex = hex_encode(&bytes);
let expected = concat!(
"10", "0001", "0000000000000004", "75736572", "000000000000001e", "6576745f303141525a334e44454b545356345252464651363947354641",
"56", "0000000000000008", "6465616462656566", "000000000000000b", "73657373696f6e2d303031", "000000000000000b", "6c65646765722d6d61696e", "01", "0000000000000008", "000000000000000a", "000000000000001b", "323032362d30352d30325431323a30303a30302e3030303030305a",
"0000000000000009", "66703a616263313233", );
assert_eq!(
hex, expected,
"canonical encoder drift — bytes must be byte-identical across platforms"
);
}
#[test]
fn canonical_byte_identical_across_serdes() {
let p = fixture_preimage();
let from_encoder = canonical_signing_input(&p);
let mut manual: Vec<u8> = Vec::new();
manual.push(DOMAIN_TAG_ATTESTATION_PREIMAGE);
manual.extend_from_slice(&p.schema_version.to_be_bytes());
push_lp(&mut manual, p.source.variant_tag().as_bytes());
push_lp(&mut manual, p.event_id.as_bytes());
push_lp(&mut manual, p.payload_hash.as_bytes());
push_lp(&mut manual, p.session_id.as_bytes());
push_lp(&mut manual, p.ledger_id.as_bytes());
manual.push(p.lineage.tag());
if let LineageBinding::ChainPosition(n) = p.lineage {
manual.extend_from_slice(&8u64.to_be_bytes());
manual.extend_from_slice(&n.to_be_bytes());
}
let signed_at_str = p
.signed_at
.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
push_lp(&mut manual, signed_at_str.as_bytes());
push_lp(&mut manual, p.key_id.as_bytes());
assert_eq!(
from_encoder, manual,
"encoder output must match the hand-built canonical bytes"
);
}
#[test]
fn field_reorder_does_not_change_signed_semantics() {
let p1 = fixture_preimage();
#[allow(clippy::needless_update)]
let p2 = AttestationPreimage {
key_id: "fp:abc123".into(),
signed_at: chrono::Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap(),
lineage: LineageBinding::ChainPosition(10),
ledger_id: "ledger-main".into(),
session_id: "session-001".into(),
payload_hash: "deadbeef".into(),
event_id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
source: SourceIdentity::User,
schema_version: SCHEMA_VERSION_ATTESTATION,
};
assert_eq!(canonical_signing_input(&p1), canonical_signing_input(&p2));
}
#[test]
fn distinct_preimages_produce_distinct_bytes() {
let mut p2 = fixture_preimage();
p2.event_id = "evt_01ARZ3NDEKTSV4RRFFQ69G5FAW".into();
assert_ne!(
canonical_signing_input(&fixture_preimage()),
canonical_signing_input(&p2)
);
}
#[test]
fn lineage_variant_tag_prevents_cross_variant_collision() {
let mut a = fixture_preimage();
a.lineage = LineageBinding::ChainPosition(0);
let mut b = fixture_preimage();
b.lineage = LineageBinding::PreviousHash(String::new());
assert_ne!(canonical_signing_input(&a), canonical_signing_input(&b));
}
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
out.extend_from_slice(bytes);
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
}