use serde::de::{Error as DeError, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::HashSet;
use std::fmt;
use crate::crypto::{PublicKey, Signature, SigningKey};
use crate::domain::RECEIPT_CONTEXT;
use crate::error::{Error, Result};
pub const RECEIPT_VERSION: u8 = 1;
pub fn srl_commitment_digest(srl_bytes: &[u8]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(srl_bytes);
hasher.finalize().into()
}
pub fn trusted_roots_digest(roots: &[[u8; 32]]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut sorted: Vec<[u8; 32]> = roots.to_vec();
sorted.sort_unstable();
sorted.dedup();
let mut hasher = Sha256::new();
for key in &sorted {
hasher.update(key);
}
hasher.finalize().into()
}
pub fn canonical_policy_bytes(policy: &serde_json::Value) -> Option<Vec<u8>> {
use std::collections::BTreeMap;
let object = policy.as_object()?;
let sorted: BTreeMap<&String, &serde_json::Value> = object.iter().collect();
let mut bytes = Vec::new();
ciborium::into_writer(&sorted, &mut bytes).ok()?;
Some(bytes)
}
pub fn policy_commitment_digest(policy_bytes: &[u8]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(policy_bytes);
hasher.finalize().into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Allow,
Deny,
}
impl Outcome {
pub fn as_str(&self) -> &'static str {
match self {
Outcome::Allow => "allow",
Outcome::Deny => "deny",
}
}
}
impl Serialize for Outcome {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Outcome {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
match s.as_str() {
"allow" => Ok(Outcome::Allow),
"deny" => Ok(Outcome::Deny),
other => Err(D::Error::custom(format!(
"outcome must be \"allow\" or \"deny\", got {:?}",
other
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReceiptPayload {
pub version: u8,
pub authorizer_id: Option<String>,
pub warrant_chain: Vec<u8>,
pub action: String,
pub outcome: Outcome,
pub timestamp: i64,
pub root_principal: Option<String>,
pub request_hash: Option<[u8; 32]>,
pub pop_signature: Option<[u8; 64]>,
pub request_id: String,
pub decision_code: Option<String>,
pub policy_definition_hash: Option<[u8; 32]>,
pub srl_version: Option<u64>,
pub srl_hash: Option<[u8; 32]>,
pub prev_receipt_hash: Option<[u8; 32]>,
pub trusted_roots_hash: Option<[u8; 32]>,
}
impl ReceiptPayload {
pub fn allow(
warrant_chain: Vec<u8>,
action: impl Into<String>,
timestamp: i64,
request_id: impl Into<String>,
pop_signature: [u8; 64],
) -> Self {
Self {
version: RECEIPT_VERSION,
authorizer_id: None,
warrant_chain,
action: action.into(),
outcome: Outcome::Allow,
timestamp,
root_principal: None,
request_hash: None,
pop_signature: Some(pop_signature),
request_id: request_id.into(),
decision_code: None,
policy_definition_hash: None,
srl_version: None,
srl_hash: None,
prev_receipt_hash: None,
trusted_roots_hash: None,
}
}
pub fn deny(
warrant_chain: Vec<u8>,
action: impl Into<String>,
timestamp: i64,
request_id: impl Into<String>,
decision_code: impl Into<String>,
pop_signature: [u8; 64],
) -> Self {
Self {
version: RECEIPT_VERSION,
authorizer_id: None,
warrant_chain,
action: action.into(),
outcome: Outcome::Deny,
timestamp,
root_principal: None,
request_hash: None,
pop_signature: Some(pop_signature),
request_id: request_id.into(),
decision_code: Some(decision_code.into()),
policy_definition_hash: None,
srl_version: None,
srl_hash: None,
prev_receipt_hash: None,
trusted_roots_hash: None,
}
}
pub fn deny_before_pop(
warrant_chain: Vec<u8>,
action: impl Into<String>,
timestamp: i64,
request_id: impl Into<String>,
decision_code: impl Into<String>,
) -> Self {
Self {
version: RECEIPT_VERSION,
authorizer_id: None,
warrant_chain,
action: action.into(),
outcome: Outcome::Deny,
timestamp,
root_principal: None,
request_hash: None,
pop_signature: None,
request_id: request_id.into(),
decision_code: Some(decision_code.into()),
policy_definition_hash: None,
srl_version: None,
srl_hash: None,
prev_receipt_hash: None,
trusted_roots_hash: None,
}
}
pub fn check_conditional_requirements(&self) -> Result<()> {
if self.outcome == Outcome::Deny && self.decision_code.is_none() {
return Err(Error::InvalidReceipt(
"decision_code is required when outcome is \"deny\"".to_string(),
));
}
if self.outcome == Outcome::Allow && self.pop_signature.is_none() {
return Err(Error::InvalidReceipt(
"pop_signature is required when outcome is \"allow\"".to_string(),
));
}
Ok(())
}
pub fn to_cbor(&self) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
ciborium::into_writer(self, &mut bytes).map_err(|e| {
Error::InvalidReceipt(format!("failed to serialize receipt payload: {}", e))
})?;
Ok(bytes)
}
}
impl Serialize for ReceiptPayload {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
let optional_present = [
self.authorizer_id.is_some(),
self.root_principal.is_some(),
self.request_hash.is_some(),
self.pop_signature.is_some(),
self.decision_code.is_some(),
self.policy_definition_hash.is_some(),
self.srl_version.is_some(),
self.srl_hash.is_some(),
self.prev_receipt_hash.is_some(),
self.trusted_roots_hash.is_some(),
]
.iter()
.filter(|present| **present)
.count();
let mut map = serializer.serialize_map(Some(6 + optional_present))?;
map.serialize_entry(&0u8, &self.version)?;
if let Some(authorizer_id) = &self.authorizer_id {
map.serialize_entry(&1u8, authorizer_id)?;
}
map.serialize_entry(&2u8, serde_bytes::Bytes::new(&self.warrant_chain))?;
map.serialize_entry(&3u8, &self.action)?;
map.serialize_entry(&4u8, &self.outcome)?;
map.serialize_entry(&5u8, &self.timestamp)?;
if let Some(root_principal) = &self.root_principal {
map.serialize_entry(&6u8, root_principal)?;
}
if let Some(request_hash) = &self.request_hash {
map.serialize_entry(&7u8, serde_bytes::Bytes::new(request_hash))?;
}
if let Some(pop_signature) = &self.pop_signature {
map.serialize_entry(&8u8, serde_bytes::Bytes::new(pop_signature))?;
}
map.serialize_entry(&9u8, &self.request_id)?;
if let Some(decision_code) = &self.decision_code {
map.serialize_entry(&10u8, decision_code)?;
}
if let Some(policy_definition_hash) = &self.policy_definition_hash {
map.serialize_entry(&11u8, serde_bytes::Bytes::new(policy_definition_hash))?;
}
if let Some(srl_version) = &self.srl_version {
map.serialize_entry(&12u8, srl_version)?;
}
if let Some(srl_hash) = &self.srl_hash {
map.serialize_entry(&13u8, serde_bytes::Bytes::new(srl_hash))?;
}
if let Some(prev_receipt_hash) = &self.prev_receipt_hash {
map.serialize_entry(&14u8, serde_bytes::Bytes::new(prev_receipt_hash))?;
}
if let Some(trusted_roots_hash) = &self.trusted_roots_hash {
map.serialize_entry(&15u8, serde_bytes::Bytes::new(trusted_roots_hash))?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for ReceiptPayload {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct ReceiptPayloadVisitor;
fn fixed<const N: usize, E: DeError>(
bytes: Vec<u8>,
field: &str,
) -> std::result::Result<[u8; N], E> {
let len = bytes.len();
bytes
.try_into()
.map_err(|_| E::custom(format!("{} must be {} bytes, got {}", field, N, len)))
}
impl<'de> Visitor<'de> for ReceiptPayloadVisitor {
type Value = ReceiptPayload;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("CBOR map of receipt payload with integer keys")
}
fn visit_map<A: MapAccess<'de>>(
self,
mut map: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut seen = HashSet::new();
let mut version = None;
let mut authorizer_id = None;
let mut warrant_chain: Option<Vec<u8>> = None;
let mut action = None;
let mut outcome = None;
let mut timestamp = None;
let mut root_principal = None;
let mut request_hash = None;
let mut pop_signature = None;
let mut request_id = None;
let mut decision_code = None;
let mut policy_definition_hash = None;
let mut srl_version = None;
let mut srl_hash = None;
let mut prev_receipt_hash = None;
let mut trusted_roots_hash = None;
while let Some(key) = map.next_key::<u8>()? {
if !seen.insert(key) {
return Err(A::Error::custom(format!("duplicate key {}", key)));
}
match key {
0 => version = Some(map.next_value()?),
1 => authorizer_id = Some(map.next_value()?),
2 => {
let bytes: serde_bytes::ByteBuf = map.next_value()?;
warrant_chain = Some(bytes.into_vec());
}
3 => action = Some(map.next_value()?),
4 => outcome = Some(map.next_value()?),
5 => timestamp = Some(map.next_value()?),
6 => root_principal = Some(map.next_value()?),
7 => {
let bytes: serde_bytes::ByteBuf = map.next_value()?;
request_hash = Some(fixed(bytes.into_vec(), "request_hash")?);
}
8 => {
let bytes: serde_bytes::ByteBuf = map.next_value()?;
pop_signature = Some(fixed(bytes.into_vec(), "pop_signature")?);
}
9 => request_id = Some(map.next_value()?),
10 => decision_code = Some(map.next_value()?),
11 => {
let bytes: serde_bytes::ByteBuf = map.next_value()?;
policy_definition_hash =
Some(fixed(bytes.into_vec(), "policy_definition_hash")?);
}
12 => srl_version = Some(map.next_value()?),
13 => {
let bytes: serde_bytes::ByteBuf = map.next_value()?;
srl_hash = Some(fixed(bytes.into_vec(), "srl_hash")?);
}
14 => {
let bytes: serde_bytes::ByteBuf = map.next_value()?;
prev_receipt_hash = Some(fixed(bytes.into_vec(), "prev_receipt_hash")?);
}
15 => {
let bytes: serde_bytes::ByteBuf = map.next_value()?;
trusted_roots_hash =
Some(fixed(bytes.into_vec(), "trusted_roots_hash")?);
}
_ => {
return Err(A::Error::custom(format!(
"unknown receipt payload key {}",
key
)));
}
}
}
Ok(ReceiptPayload {
version: version.ok_or_else(|| A::Error::custom("missing version"))?,
authorizer_id,
warrant_chain: warrant_chain
.ok_or_else(|| A::Error::custom("missing warrant_chain"))?,
action: action.ok_or_else(|| A::Error::custom("missing action"))?,
outcome: outcome.ok_or_else(|| A::Error::custom("missing outcome"))?,
timestamp: timestamp.ok_or_else(|| A::Error::custom("missing timestamp"))?,
root_principal,
request_hash,
pop_signature,
request_id: request_id.ok_or_else(|| A::Error::custom("missing request_id"))?,
decision_code,
policy_definition_hash,
srl_version,
srl_hash,
prev_receipt_hash,
trusted_roots_hash,
})
}
}
deserializer.deserialize_map(ReceiptPayloadVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Receipt {
pub receipt_version: u8,
#[serde(with = "serde_bytes")]
pub payload: Vec<u8>,
pub signer_key: PublicKey,
pub signature: Signature,
}
impl Receipt {
pub fn create(payload: &ReceiptPayload, signing_key: &SigningKey) -> Result<Self> {
let payload_bytes = payload.to_cbor()?;
let preimage = Self::signing_preimage(RECEIPT_VERSION, &payload_bytes);
Ok(Self {
receipt_version: RECEIPT_VERSION,
payload: payload_bytes,
signer_key: signing_key.public_key(),
signature: signing_key.sign(&preimage),
})
}
pub fn digest(&self) -> Result<[u8; 32]> {
use sha2::{Digest, Sha256};
let mut bytes = Vec::new();
ciborium::into_writer(self, &mut bytes)
.map_err(|e| Error::InvalidReceipt(format!("failed to encode receipt: {}", e)))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(hasher.finalize().into())
}
pub fn verify_signature(&self) -> Result<ReceiptPayload> {
if self.receipt_version != RECEIPT_VERSION {
return Err(Error::UnsupportedVersion(self.receipt_version));
}
let preimage = Self::signing_preimage(self.receipt_version, &self.payload);
self.signer_key
.verify(&preimage, &self.signature)
.map_err(|e| Error::InvalidReceipt(format!("signature does not verify: {}", e)))?;
let payload: ReceiptPayload = ciborium::from_reader(&self.payload[..])
.map_err(|e| Error::InvalidReceipt(format!("failed to deserialize payload: {}", e)))?;
if payload.version != self.receipt_version {
return Err(Error::InvalidReceipt(format!(
"payload version {} does not match artifact version {}",
payload.version, self.receipt_version
)));
}
payload.check_conditional_requirements()?;
Ok(payload)
}
pub fn signing_preimage(receipt_version: u8, payload_bytes: &[u8]) -> Vec<u8> {
let mut preimage = Vec::with_capacity(RECEIPT_CONTEXT.len() + 1 + payload_bytes.len());
preimage.extend_from_slice(RECEIPT_CONTEXT);
preimage.push(receipt_version);
preimage.extend_from_slice(payload_bytes);
preimage
}
}
pub fn verify_chain(
receipts: &[Receipt],
signer: Option<&PublicKey>,
) -> Result<Vec<ReceiptPayload>> {
if receipts.is_empty() {
return Err(Error::InvalidReceipt("receipt chain is empty".to_string()));
}
let mut payloads = Vec::with_capacity(receipts.len());
let mut previous: Option<[u8; 32]> = None;
let expected = signer.or_else(|| receipts.first().map(|receipt| &receipt.signer_key));
for (index, receipt) in receipts.iter().enumerate() {
if Some(&receipt.signer_key) != expected {
return Err(Error::InvalidReceipt(format!(
"receipt {index} is signed by a different key"
)));
}
let payload = receipt.verify_signature()?;
if let Some(previous) = previous {
if payload.prev_receipt_hash != Some(previous) {
return Err(Error::InvalidReceipt(format!(
"receipt {index} does not link to the receipt before it"
)));
}
}
previous = Some(receipt.digest()?);
payloads.push(payload);
}
Ok(payloads)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::SigningKey;
fn chain(key: &SigningKey, length: usize) -> Vec<Receipt> {
let mut receipts: Vec<Receipt> = Vec::new();
for index in 0..length {
let mut next = payload();
next.request_id = format!("req-{index}");
next.prev_receipt_hash = receipts.last().map(|receipt| receipt.digest().unwrap());
receipts.push(Receipt::create(&next, key).unwrap());
}
receipts
}
#[test]
fn verify_chain_rejects_empty_input() {
let key = SigningKey::generate().public_key();
for signer in [None, Some(&key)] {
assert!(matches!(
verify_chain(&[], signer),
Err(Error::InvalidReceipt(reason)) if reason == "receipt chain is empty"
));
}
}
#[test]
fn verify_chain_detects_edits_gaps_and_foreign_signers() {
let key = SigningKey::generate();
let receipts = chain(&key, 4);
let payloads = verify_chain(&receipts, Some(&key.public_key())).unwrap();
assert_eq!(payloads.len(), 4);
assert_eq!(payloads[3].request_id, "req-3");
assert!(
verify_chain(&receipts[1..], None).is_ok(),
"a run may start mid-chain"
);
let mut gap = receipts.clone();
gap.remove(2);
assert!(verify_chain(&gap, None).is_err());
let mut swapped = receipts.clone();
swapped.swap(1, 2);
assert!(verify_chain(&swapped, None).is_err());
let other = SigningKey::generate();
assert!(verify_chain(&receipts, Some(&other.public_key())).is_err());
let mut mixed = receipts.clone();
mixed[3] = chain(&other, 1).remove(0);
assert!(verify_chain(&mixed, None).is_err());
let mut tampered = receipts;
tampered[1].payload[5] ^= 1;
assert!(verify_chain(&tampered, None).is_err());
}
fn payload() -> ReceiptPayload {
ReceiptPayload::allow(
vec![0xA1, 0x01, 0x02],
"tool:read_file",
1_700_000_000,
"req-1",
[9u8; 64],
)
}
fn full_payload() -> ReceiptPayload {
ReceiptPayload {
version: RECEIPT_VERSION,
authorizer_id: Some("authorizer-1".to_string()),
warrant_chain: vec![0xA1, 0x01, 0x02],
action: "tool:delete_file".to_string(),
outcome: Outcome::Deny,
timestamp: 1_700_000_000,
root_principal: Some("root@example.com".to_string()),
request_hash: Some([7u8; 32]),
pop_signature: Some([9u8; 64]),
request_id: "req-2".to_string(),
decision_code: Some("tool-not-authorized".to_string()),
policy_definition_hash: Some([3u8; 32]),
srl_version: Some(47),
srl_hash: Some([5u8; 32]),
prev_receipt_hash: Some([6u8; 32]),
trusted_roots_hash: Some([8u8; 32]),
}
}
#[test]
fn round_trips_an_unversioned_revocation_commitment() {
let mut original = full_payload();
original.srl_version = None;
original.srl_hash = Some([5u8; 32]);
let bytes = original.to_cbor().unwrap();
let decoded: ReceiptPayload = ciborium::from_reader(&bytes[..]).unwrap();
assert_eq!(original, decoded);
assert_eq!(decoded.srl_version, None);
assert_eq!(decoded.srl_hash, Some([5u8; 32]));
}
#[test]
fn omits_revocation_keys_when_no_list_was_loaded() {
let original = payload();
assert_eq!(original.srl_version, None);
assert_eq!(original.srl_hash, None);
let bytes = original.to_cbor().unwrap();
let map: ciborium::Value = ciborium::from_reader(&bytes[..]).unwrap();
let keys: Vec<i128> = match map {
ciborium::Value::Map(entries) => entries
.iter()
.filter_map(|(k, _)| k.as_integer().map(i128::from))
.collect(),
other => panic!("payload must encode as a CBOR map, got {:?}", other),
};
assert!(!keys.contains(&12), "key 12 must be omitted, not null");
assert!(!keys.contains(&13), "key 13 must be omitted, not null");
}
#[test]
fn rejects_an_srl_hash_that_is_not_32_bytes() {
let mut bytes = Vec::new();
ciborium::into_writer(
&ciborium::Value::Map(vec![(
ciborium::Value::Integer(13u8.into()),
ciborium::Value::Bytes(vec![0u8; 31]),
)]),
&mut bytes,
)
.unwrap();
let decoded: std::result::Result<ReceiptPayload, _> = ciborium::from_reader(&bytes[..]);
let message = decoded.unwrap_err().to_string();
assert!(
message.contains("srl_hash") && message.contains("32"),
"error must name the field and the expected length, got: {message}"
);
}
#[test]
fn round_trips_the_chain_link_and_root_commitment() {
let original = full_payload();
let bytes = original.to_cbor().unwrap();
let decoded: ReceiptPayload = ciborium::from_reader(&bytes[..]).unwrap();
assert_eq!(decoded.prev_receipt_hash, Some([6u8; 32]));
assert_eq!(decoded.trusted_roots_hash, Some([8u8; 32]));
}
#[test]
fn omits_the_chain_link_when_there_is_no_predecessor() {
let original = payload();
assert_eq!(original.prev_receipt_hash, None);
let bytes = original.to_cbor().unwrap();
let map: ciborium::Value = ciborium::from_reader(&bytes[..]).unwrap();
let keys: Vec<i128> = match map {
ciborium::Value::Map(entries) => entries
.iter()
.filter_map(|(k, _)| k.as_integer().map(i128::from))
.collect(),
other => panic!("payload must encode as a CBOR map, got {:?}", other),
};
assert!(
!keys.contains(&14),
"key 14 must be omitted, not zero-filled"
);
assert!(
!keys.contains(&15),
"key 15 must be omitted, not zero-filled"
);
}
#[test]
fn the_root_commitment_is_order_and_duplicate_independent() {
let a = [1u8; 32];
let b = [2u8; 32];
assert_eq!(trusted_roots_digest(&[a, b]), trusted_roots_digest(&[b, a]));
assert_eq!(
trusted_roots_digest(&[a, b]),
trusted_roots_digest(&[b, a, a])
);
assert_ne!(trusted_roots_digest(&[a]), trusted_roots_digest(&[a, b]));
}
#[test]
fn an_empty_root_set_has_its_own_commitment() {
assert_ne!(
trusted_roots_digest(&[]),
trusted_roots_digest(&[[1u8; 32]])
);
}
#[test]
fn rejects_a_chain_link_that_is_not_32_bytes() {
let mut bytes = Vec::new();
ciborium::into_writer(
&ciborium::Value::Map(vec![(
ciborium::Value::Integer(14u8.into()),
ciborium::Value::Bytes(vec![0u8; 31]),
)]),
&mut bytes,
)
.unwrap();
let decoded: std::result::Result<ReceiptPayload, _> = ciborium::from_reader(&bytes[..]);
let message = decoded.unwrap_err().to_string();
assert!(
message.contains("prev_receipt_hash") && message.contains("32"),
"error must name the field and expected length, got: {message}"
);
}
#[test]
fn round_trips_a_minimal_payload() {
let original = payload();
let bytes = original.to_cbor().unwrap();
let decoded: ReceiptPayload = ciborium::from_reader(&bytes[..]).unwrap();
assert_eq!(original, decoded);
}
#[test]
fn round_trips_a_payload_with_every_field_present() {
let original = full_payload();
let bytes = original.to_cbor().unwrap();
let decoded: ReceiptPayload = ciborium::from_reader(&bytes[..]).unwrap();
assert_eq!(original, decoded);
}
#[test]
fn encoding_is_deterministic() {
let payload = full_payload();
assert_eq!(payload.to_cbor().unwrap(), payload.to_cbor().unwrap());
}
#[test]
fn absent_optional_fields_are_omitted_not_null() {
let bytes = payload().to_cbor().unwrap();
let value: ciborium::Value = ciborium::from_reader(&bytes[..]).unwrap();
let entries = value.as_map().expect("payload must encode as a CBOR map");
let keys: Vec<i128> = entries
.iter()
.map(|(k, _)| k.as_integer().expect("keys must be integers").into())
.collect();
assert_eq!(keys, vec![0, 2, 3, 4, 5, 8, 9]);
assert!(entries.iter().all(|(_, v)| !v.is_null()));
}
#[test]
fn integer_keys_are_encoded_in_numeric_order() {
let bytes = full_payload().to_cbor().unwrap();
let value: ciborium::Value = ciborium::from_reader(&bytes[..]).unwrap();
let keys: Vec<i128> = value
.as_map()
.unwrap()
.iter()
.map(|(k, _)| k.as_integer().unwrap().into())
.collect();
assert_eq!(
keys,
vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
);
}
#[test]
fn byte_fields_encode_as_cbor_byte_strings_not_arrays() {
let bytes = full_payload().to_cbor().unwrap();
let value: ciborium::Value = ciborium::from_reader(&bytes[..]).unwrap();
let entries = value.as_map().unwrap();
for key in [2i128, 7, 8, 11, 13, 14, 15] {
let (_, field) = entries
.iter()
.find(|(k, _)| i128::from(k.as_integer().unwrap()) == key)
.unwrap();
assert!(
field.is_bytes(),
"key {} must encode as a CBOR byte string, got {:?}",
key,
field
);
}
}
#[test]
fn outcome_encodes_as_text_not_boolean() {
let bytes = payload().to_cbor().unwrap();
let value: ciborium::Value = ciborium::from_reader(&bytes[..]).unwrap();
let (_, outcome) = value
.as_map()
.unwrap()
.iter()
.find(|(k, _)| i128::from(k.as_integer().unwrap()) == 4)
.unwrap();
assert_eq!(outcome.as_text(), Some("allow"));
}
#[test]
fn rejects_a_boolean_outcome() {
let encoded = ciborium::Value::Map(vec![
(
ciborium::Value::Integer(0.into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer(2.into()),
ciborium::Value::Bytes(vec![0xA1]),
),
(
ciborium::Value::Integer(3.into()),
ciborium::Value::Text("tool:read".into()),
),
(
ciborium::Value::Integer(4.into()),
ciborium::Value::Bool(true),
),
(
ciborium::Value::Integer(5.into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer(9.into()),
ciborium::Value::Text("req".into()),
),
]);
let mut bytes = Vec::new();
ciborium::into_writer(&encoded, &mut bytes).unwrap();
let decoded: std::result::Result<ReceiptPayload, _> = ciborium::from_reader(&bytes[..]);
assert!(decoded.is_err(), "a boolean outcome must be rejected");
}
#[test]
fn rejects_unknown_payload_keys() {
let mut bytes = Vec::new();
let encoded = ciborium::Value::Map(vec![
(
ciborium::Value::Integer(0.into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer(2.into()),
ciborium::Value::Bytes(vec![0xA1]),
),
(
ciborium::Value::Integer(3.into()),
ciborium::Value::Text("tool:read".into()),
),
(
ciborium::Value::Integer(4.into()),
ciborium::Value::Text("allow".into()),
),
(
ciborium::Value::Integer(5.into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer(9.into()),
ciborium::Value::Text("req".into()),
),
(
ciborium::Value::Integer(99.into()),
ciborium::Value::Text("surprise".into()),
),
]);
ciborium::into_writer(&encoded, &mut bytes).unwrap();
let decoded: std::result::Result<ReceiptPayload, _> = ciborium::from_reader(&bytes[..]);
assert!(decoded.is_err(), "unknown keys must fail closed");
}
#[test]
fn rejects_a_wrong_length_request_hash() {
let mut bytes = Vec::new();
let encoded = ciborium::Value::Map(vec![
(
ciborium::Value::Integer(0.into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer(2.into()),
ciborium::Value::Bytes(vec![0xA1]),
),
(
ciborium::Value::Integer(3.into()),
ciborium::Value::Text("tool:read".into()),
),
(
ciborium::Value::Integer(4.into()),
ciborium::Value::Text("allow".into()),
),
(
ciborium::Value::Integer(5.into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer(7.into()),
ciborium::Value::Bytes(vec![0u8; 31]),
),
(
ciborium::Value::Integer(9.into()),
ciborium::Value::Text("req".into()),
),
]);
ciborium::into_writer(&encoded, &mut bytes).unwrap();
let decoded: std::result::Result<ReceiptPayload, _> = ciborium::from_reader(&bytes[..]);
assert!(decoded.is_err(), "a 31-byte request_hash must be rejected");
}
#[test]
fn signs_and_verifies() {
let key = SigningKey::generate();
let receipt = Receipt::create(&full_payload(), &key).unwrap();
assert_eq!(receipt.receipt_version, RECEIPT_VERSION);
assert_eq!(receipt.signer_key, key.public_key());
assert_eq!(receipt.verify_signature().unwrap(), full_payload());
}
#[test]
fn verification_fails_when_payload_is_tampered_with() {
let key = SigningKey::generate();
let mut receipt = Receipt::create(&full_payload(), &key).unwrap();
let last = receipt.payload.len() - 1;
receipt.payload[last] ^= 0xFF;
assert!(receipt.verify_signature().is_err());
}
#[test]
fn verification_fails_under_a_different_signer_key() {
let key = SigningKey::generate();
let other = SigningKey::generate();
let mut receipt = Receipt::create(&full_payload(), &key).unwrap();
receipt.signer_key = other.public_key();
assert!(receipt.verify_signature().is_err());
}
#[test]
fn domain_separation_rejects_a_signature_over_the_bare_payload() {
let key = SigningKey::generate();
let payload_bytes = full_payload().to_cbor().unwrap();
let receipt = Receipt {
receipt_version: RECEIPT_VERSION,
payload: payload_bytes.clone(),
signer_key: key.public_key(),
signature: key.sign(&payload_bytes),
};
assert!(
receipt.verify_signature().is_err(),
"a signature omitting the domain context must not verify"
);
}
#[test]
fn artifact_version_cannot_be_downgraded_without_detection() {
let key = SigningKey::generate();
let mut receipt = Receipt::create(&full_payload(), &key).unwrap();
receipt.receipt_version = 2;
assert!(receipt.verify_signature().is_err());
}
#[test]
fn payload_version_must_match_artifact_version() {
let key = SigningKey::generate();
let mut payload = full_payload();
payload.version = 2;
let payload_bytes = payload.to_cbor().unwrap();
let preimage = Receipt::signing_preimage(RECEIPT_VERSION, &payload_bytes);
let receipt = Receipt {
receipt_version: RECEIPT_VERSION,
payload: payload_bytes,
signer_key: key.public_key(),
signature: key.sign(&preimage),
};
let error = receipt.verify_signature().unwrap_err();
assert!(
error
.to_string()
.contains("does not match artifact version"),
"expected a version mismatch, got: {}",
error
);
}
#[test]
fn the_artifact_round_trips_through_cbor() {
let key = SigningKey::generate();
let receipt = Receipt::create(&full_payload(), &key).unwrap();
let mut bytes = Vec::new();
ciborium::into_writer(&receipt, &mut bytes).unwrap();
let decoded: Receipt = ciborium::from_reader(&bytes[..]).unwrap();
assert_eq!(decoded.verify_signature().unwrap(), full_payload());
}
#[test]
fn a_denial_without_a_decision_code_is_rejected_by_the_conditional_check() {
let mut payload = full_payload();
payload.decision_code = None;
assert!(payload.check_conditional_requirements().is_err());
assert!(full_payload().check_conditional_requirements().is_ok());
}
#[test]
fn an_allow_without_a_pop_signature_is_rejected_by_the_conditional_check() {
let mut allowed = payload();
allowed.pop_signature = None;
assert!(allowed.check_conditional_requirements().is_err());
assert!(payload().check_conditional_requirements().is_ok());
}
#[test]
fn verification_rejects_a_signed_allow_without_pop_signature() {
let signer = SigningKey::generate();
let mut invalid = payload();
invalid.pop_signature = None;
let receipt = Receipt::create(&invalid, &signer).unwrap();
let err = receipt.verify_signature().unwrap_err();
assert!(
err.to_string().contains("pop_signature is required"),
"public verification must reject invalid receipt claims, got: {err}"
);
}
#[test]
fn verification_rejects_a_signed_deny_without_decision_code() {
let signer = SigningKey::generate();
let mut invalid = full_payload();
invalid.outcome = Outcome::Deny;
invalid.decision_code = None;
let receipt = Receipt::create(&invalid, &signer).unwrap();
let err = receipt.verify_signature().unwrap_err();
assert!(
err.to_string().contains("decision_code is required"),
"public verification must reject invalid receipt claims, got: {err}"
);
}
#[test]
fn a_denial_before_pop_verification_may_omit_the_pop_signature() {
let denied = ReceiptPayload::deny_before_pop(
vec![0xA1, 0x01, 0x02],
"tool:read_file",
1_700_000_000,
"req-3",
"pop-signature-invalid",
);
assert!(denied.pop_signature.is_none());
assert!(denied.check_conditional_requirements().is_ok());
}
}