use crate::error::{ReceiptError, Result};
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
pub const ENVELOPE_SCHEMA: &str = "chatmangpt.receipt.envelope.v1";
pub const SIGNATURE_ALGORITHM: &str = "Ed25519";
pub const HASH_PREFIX: &str = "blake3:";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Producer {
pub system: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PayloadRef {
pub schema: String,
pub hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnvelopeSignature {
pub algorithm: String,
pub public_key_ref: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnvelopeChainLink {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_envelope_hash: Option<String>,
pub own_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReceiptEnvelope {
pub schema: String,
pub envelope_id: String,
pub operation_id: String,
pub timestamp: DateTime<Utc>,
pub producer: Producer,
pub payload: PayloadRef,
pub signature: EnvelopeSignature,
pub chain: EnvelopeChainLink,
}
impl ReceiptEnvelope {
pub fn new(
envelope_id: impl Into<String>, operation_id: impl Into<String>, producer: Producer,
payload: PayloadRef, previous_envelope_hash: Option<String>,
public_key_ref: impl Into<String>,
) -> Self {
Self {
schema: ENVELOPE_SCHEMA.to_string(),
envelope_id: envelope_id.into(),
operation_id: operation_id.into(),
timestamp: Utc::now(),
producer,
payload,
signature: EnvelopeSignature {
algorithm: SIGNATURE_ALGORITHM.to_string(),
public_key_ref: public_key_ref.into(),
value: String::new(),
},
chain: EnvelopeChainLink {
previous_envelope_hash,
own_hash: String::new(),
},
}
}
fn signing_message(&self) -> Result<Vec<u8>> {
let mut clone = self.clone();
clone.signature.value.clear();
clone.chain.own_hash.clear();
let json = serde_json::to_string(&clone)?;
Ok(json.into_bytes())
}
pub fn hash(&self) -> Result<String> {
let mut clone = self.clone();
clone.chain.own_hash.clear();
let json = serde_json::to_string(&clone)?;
let h = blake3::hash(json.as_bytes());
Ok(format!("{}{}", HASH_PREFIX, h.to_hex()))
}
pub fn sign(mut self, signing_key: &SigningKey) -> Result<Self> {
if self.schema != ENVELOPE_SCHEMA {
return Err(ReceiptError::InvalidReceipt(format!(
"envelope schema must be {} (got {})",
ENVELOPE_SCHEMA, self.schema
)));
}
let message = self.signing_message()?;
let signature = signing_key.sign(&message);
self.signature.value = hex::encode(signature.to_bytes());
self.chain.own_hash = self.hash()?;
Ok(self)
}
pub fn verify(&self, verifying_key: &VerifyingKey) -> Result<()> {
if self.schema != ENVELOPE_SCHEMA {
return Err(ReceiptError::InvalidReceipt(format!(
"envelope schema must be {} (got {})",
ENVELOPE_SCHEMA, self.schema
)));
}
let message = self.signing_message()?;
let signature_bytes =
hex::decode(&self.signature.value).map_err(|_| ReceiptError::InvalidSignature)?;
let signature =
Signature::from_slice(&signature_bytes).map_err(|_| ReceiptError::InvalidSignature)?;
verifying_key
.verify(&message, &signature)
.map_err(|_| ReceiptError::InvalidSignature)?;
let recomputed = self.hash()?;
if recomputed != self.chain.own_hash {
return Err(ReceiptError::InvalidReceipt(
"envelope own_hash does not match recomputed hash".into(),
));
}
Ok(())
}
}
pub fn payload_hash(bytes: &[u8]) -> String {
let h = blake3::hash(bytes);
format!("{}{}", HASH_PREFIX, h.to_hex())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnvelopeChain {
pub envelopes: Vec<ReceiptEnvelope>,
}
impl EnvelopeChain {
pub fn new() -> Self {
Self::default()
}
pub fn from_genesis(genesis: ReceiptEnvelope) -> Result<Self> {
if genesis.chain.previous_envelope_hash.is_some() {
return Err(ReceiptError::InvalidReceipt(
"genesis envelope must have no previous_envelope_hash".into(),
));
}
Ok(Self {
envelopes: vec![genesis],
})
}
pub fn append(&mut self, envelope: ReceiptEnvelope) -> Result<()> {
if self.envelopes.is_empty() {
if envelope.chain.previous_envelope_hash.is_some() {
return Err(ReceiptError::InvalidChain(
"first envelope must be genesis (no previous hash)".into(),
));
}
} else {
let last = self.envelopes.last().unwrap();
let last_hash = &last.chain.own_hash;
match &envelope.chain.previous_envelope_hash {
Some(prev) if prev == last_hash => {}
Some(prev) => {
return Err(ReceiptError::HashMismatch {
expected: last_hash.clone(),
actual: prev.clone(),
});
}
None => {
return Err(ReceiptError::InvalidChain(
"non-genesis envelope must have a previous hash".into(),
));
}
}
}
self.envelopes.push(envelope);
Ok(())
}
pub fn len(&self) -> usize {
self.envelopes.len()
}
pub fn is_empty(&self) -> bool {
self.envelopes.is_empty()
}
pub fn last(&self) -> Option<&ReceiptEnvelope> {
self.envelopes.last()
}
pub fn genesis(&self) -> Option<&ReceiptEnvelope> {
self.envelopes.first()
}
pub fn verify(&self, verifying_key: &VerifyingKey) -> Result<()> {
if self.envelopes.is_empty() {
return Ok(());
}
let first = &self.envelopes[0];
if first.chain.previous_envelope_hash.is_some() {
return Err(ReceiptError::InvalidChain(
"genesis envelope must have no previous_envelope_hash".into(),
));
}
first.verify(verifying_key)?;
for i in 1..self.envelopes.len() {
let curr = &self.envelopes[i];
let prev = &self.envelopes[i - 1];
curr.verify(verifying_key)?;
match &curr.chain.previous_envelope_hash {
Some(p) if p == &prev.chain.own_hash => {}
Some(p) => {
return Err(ReceiptError::HashMismatch {
expected: prev.chain.own_hash.clone(),
actual: p.clone(),
});
}
None => {
return Err(ReceiptError::InvalidChain(format!(
"envelope at index {} missing previous hash",
i
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receipt::generate_keypair;
fn payload(schema: &str) -> PayloadRef {
PayloadRef {
schema: schema.into(),
hash: payload_hash(b"hello world"),
path: Some("/tmp/example.json".into()),
}
}
fn producer(kind: &str) -> Producer {
Producer {
system: "dteam".into(),
kind: kind.into(),
}
}
#[test]
fn round_trip_sign_verify() {
let (sk, vk) = generate_keypair();
let env = ReceiptEnvelope::new(
"recenv-test-001",
"obl-test-001",
producer("ralph-plan"),
payload("chatmangpt.ralph.plan.v1"),
None,
"~/.config/ggen/portfolio.ed25519.pub",
)
.sign(&sk)
.unwrap();
env.verify(&vk).unwrap();
assert!(env.chain.own_hash.starts_with(HASH_PREFIX));
assert!(env.signature.value.len() == 128); }
#[test]
fn payload_hash_is_blake3_prefixed() {
let h = payload_hash(b"abc");
assert!(h.starts_with(HASH_PREFIX));
let raw = blake3::hash(b"abc").to_hex().to_string();
assert_eq!(h, format!("{}{}", HASH_PREFIX, raw));
}
#[test]
fn chain_links_correctly() {
let (sk, vk) = generate_keypair();
let g = ReceiptEnvelope::new(
"recenv-1",
"obl-1",
producer("automl-plan"),
payload("automl"),
None,
"kref",
)
.sign(&sk)
.unwrap();
let g_hash = g.chain.own_hash.clone();
let next = ReceiptEnvelope::new(
"recenv-2",
"obl-2",
producer("ralph-plan"),
payload("ralph"),
Some(g_hash.clone()),
"kref",
)
.sign(&sk)
.unwrap();
let mut chain = EnvelopeChain::from_genesis(g).unwrap();
chain.append(next).unwrap();
chain.verify(&vk).unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(
chain.envelopes[1].chain.previous_envelope_hash.as_deref(),
Some(g_hash.as_str())
);
}
#[test]
fn chain_rejects_wrong_previous_hash() {
let (sk, _vk) = generate_keypair();
let g = ReceiptEnvelope::new(
"g",
"obl-g",
producer("automl-plan"),
payload("a"),
None,
"kref",
)
.sign(&sk)
.unwrap();
let bad = ReceiptEnvelope::new(
"b",
"obl-b",
producer("ralph-plan"),
payload("b"),
Some("blake3:0".repeat(64)),
"kref",
)
.sign(&sk)
.unwrap();
let mut chain = EnvelopeChain::from_genesis(g).unwrap();
assert!(chain.append(bad).is_err());
}
#[test]
fn tampered_payload_fails_verify() {
let (sk, vk) = generate_keypair();
let mut env = ReceiptEnvelope::new(
"e",
"obl",
producer("ralph-plan"),
payload("ralph"),
None,
"kref",
)
.sign(&sk)
.unwrap();
env.payload.hash = payload_hash(b"different bytes");
assert!(env.verify(&vk).is_err());
}
}