use base64::Engine;
use ed25519_dalek::{Signer, Verifier};
use serde::{Deserialize, Serialize};
pub mod mechanism;
pub const RECEIPT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Session {
pub session_id: String,
pub issuer_kid: String,
pub issued_at_micros: u64,
pub parent_chain: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", content = "body", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Projection {
Identity(serde_json::Value),
Capability(serde_json::Value),
Flow(serde_json::Value),
Economic(serde_json::Value),
}
impl Projection {
pub fn kind(&self) -> &'static str {
match self {
Projection::Identity(_) => "identity",
Projection::Capability(_) => "capability",
Projection::Flow(_) => "flow",
Projection::Economic(_) => "economic",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Receipt {
pub version: u32,
pub session: Session,
pub projections: Vec<Projection>,
pub root_hash_hex: String,
pub signature_b64: String,
}
impl Receipt {
pub fn sign(
session: Session,
projections: Vec<Projection>,
signing_key: &ed25519_dalek::SigningKey,
) -> Self {
let canonical = canonical_signing_bytes(&session, &projections);
let root_hash_hex = hex::encode(blake3::hash(&canonical).as_bytes());
let sig = signing_key.sign(&canonical);
let signature_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
Self {
version: RECEIPT_VERSION,
session,
projections,
root_hash_hex,
signature_b64,
}
}
pub fn verify(&self, verifying_key_bytes: &[u8; 32]) -> Result<(), ReceiptError> {
let vk = ed25519_dalek::VerifyingKey::from_bytes(verifying_key_bytes)
.map_err(|e| ReceiptError::InvalidKey(e.to_string()))?;
let canonical = canonical_signing_bytes(&self.session, &self.projections);
let computed_hash_hex = hex::encode(blake3::hash(&canonical).as_bytes());
if computed_hash_hex != self.root_hash_hex {
return Err(ReceiptError::RootHashMismatch {
expected: self.root_hash_hex.clone(),
actual: computed_hash_hex,
});
}
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(&self.signature_b64)
.map_err(|e| ReceiptError::InvalidSignatureEncoding(e.to_string()))?;
let sig_array: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| ReceiptError::InvalidSignatureEncoding("len != 64".into()))?;
let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
vk.verify(&canonical, &sig)
.map_err(|e| ReceiptError::SignatureMismatch(e.to_string()))?;
Ok(())
}
}
pub fn canonical_signing_bytes(session: &Session, projections: &[Projection]) -> Vec<u8> {
let envelope = serde_json::json!({
"version": RECEIPT_VERSION,
"session": session,
"projections": projections,
});
serde_json::to_vec(&envelope).expect("envelope serializes deterministically")
}
#[derive(Debug, thiserror::Error)]
pub enum ReceiptError {
#[error("verifying key invalid: {0}")]
InvalidKey(String),
#[error("signature encoding invalid: {0}")]
InvalidSignatureEncoding(String),
#[error("root hash mismatch: expected {expected}, computed {actual}")]
RootHashMismatch { expected: String, actual: String },
#[error("signature did not verify: {0}")]
SignatureMismatch(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_session() -> Session {
Session {
session_id: "spiffe://test/agent".into(),
issuer_kid: "kid-1".into(),
issued_at_micros: 1_717_000_000_000_000,
parent_chain: vec![],
}
}
fn dummy_projections() -> Vec<Projection> {
vec![
Projection::Identity(serde_json::json!({"sub": "spiffe://test/agent"})),
Projection::Flow(serde_json::json!({"node_count": 3, "any_adversarial": false})),
]
}
#[test]
fn receipt_round_trips_through_verify() {
let sk = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
let vk: [u8; 32] = sk.verifying_key().to_bytes();
let receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk);
receipt.verify(&vk).expect("fresh receipt must verify");
}
#[test]
fn tampered_session_fails_verify() {
let sk = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
let vk: [u8; 32] = sk.verifying_key().to_bytes();
let mut receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk);
receipt.session.session_id = "spiffe://attacker/imposter".into();
assert!(matches!(
receipt.verify(&vk),
Err(ReceiptError::RootHashMismatch { .. })
));
}
#[test]
fn projection_added_after_signing_fails_verify() {
let sk = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
let vk: [u8; 32] = sk.verifying_key().to_bytes();
let mut receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk);
receipt.projections.push(Projection::Economic(
serde_json::json!({"forged": "payment"}),
));
assert!(matches!(
receipt.verify(&vk),
Err(ReceiptError::RootHashMismatch { .. })
));
}
#[test]
fn wrong_verifying_key_fails_verify() {
let sk_a = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]);
let sk_b = ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]);
let receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk_a);
let vk_b: [u8; 32] = sk_b.verifying_key().to_bytes();
assert!(matches!(
receipt.verify(&vk_b),
Err(ReceiptError::SignatureMismatch(_))
));
}
#[test]
fn projection_wire_format_is_adjacent_tagged() {
let p = Projection::Capability(serde_json::json!({"label": "trusted"}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["kind"], "capability");
assert!(v["body"].is_object());
}
#[test]
fn projection_kind_strings_are_stable() {
assert_eq!(Projection::Identity(serde_json::Value::Null).kind(), "identity");
assert_eq!(Projection::Capability(serde_json::Value::Null).kind(), "capability");
assert_eq!(Projection::Flow(serde_json::Value::Null).kind(), "flow");
assert_eq!(Projection::Economic(serde_json::Value::Null).kind(), "economic");
}
}