use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::crypto::hash::prefixed_blake3;
use crate::crypto::KeyPair;
use crate::{IdprovaError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionDetails {
#[serde(rename = "type")]
pub action_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(rename = "inputHash")]
pub input_hash: String,
#[serde(rename = "outputHash", skip_serializing_if = "Option::is_none")]
pub output_hash: Option<String>,
pub status: String,
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptContext {
#[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(rename = "parentReceiptId", skip_serializing_if = "Option::is_none")]
pub parent_receipt_id: Option<String>,
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Receipt {
pub id: String,
pub timestamp: DateTime<Utc>,
pub agent: String,
pub dat: String,
pub action: ActionDetails,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<ReceiptContext>,
pub chain: ChainLink,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainLink {
#[serde(rename = "previousHash")]
pub previous_hash: String,
#[serde(rename = "sequenceNumber")]
pub sequence_number: u64,
}
#[derive(Serialize)]
struct ReceiptSigningPayload<'a> {
pub id: &'a str,
pub timestamp: &'a DateTime<Utc>,
pub agent: &'a str,
pub dat: &'a str,
pub action: &'a ActionDetails,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<&'a ReceiptContext>,
pub chain: &'a ChainLink,
}
impl Receipt {
pub fn signing_payload_bytes(&self) -> Vec<u8> {
let payload = ReceiptSigningPayload {
id: &self.id,
timestamp: &self.timestamp,
agent: &self.agent,
dat: &self.dat,
action: &self.action,
context: self.context.as_ref(),
chain: &self.chain,
};
serde_json::to_vec(&payload).unwrap_or_default()
}
pub fn compute_hash(&self) -> String {
prefixed_blake3(&self.signing_payload_bytes())
}
pub fn verify_signature(&self, public_key_bytes: &[u8; 32]) -> Result<()> {
let sig_bytes = hex::decode(&self.signature)
.map_err(|e| IdprovaError::InvalidReceipt(format!("signature hex decode: {e}")))?;
let payload = self.signing_payload_bytes();
KeyPair::verify(public_key_bytes, &payload, &sig_bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::KeyPair;
use chrono::Utc;
fn make_receipt(kp: &KeyPair, seq: u64, prev_hash: &str) -> Receipt {
let chain = ChainLink {
previous_hash: prev_hash.to_string(),
sequence_number: seq,
};
let action = ActionDetails {
action_type: "mcp:tool-call".to_string(),
server: None,
tool: Some("read_file".to_string()),
input_hash: "blake3:abc123".to_string(),
output_hash: None,
status: "success".to_string(),
duration_ms: Some(42),
};
let mut r = Receipt {
id: format!("rcpt_{seq}"),
timestamp: Utc::now(),
agent: "did:aid:example.com:kai".to_string(),
dat: "dat_test".to_string(),
action,
context: None,
chain,
signature: String::new(), };
let payload = r.signing_payload_bytes();
let sig = kp.sign(&payload);
r.signature = hex::encode(sig);
r
}
#[test]
fn test_s3_hash_excludes_signature() {
let kp = KeyPair::generate();
let r = make_receipt(&kp, 0, "genesis");
let hash1 = r.compute_hash();
let mut r2 = r.clone();
r2.signature = "deadbeef".to_string();
let hash2 = r2.compute_hash();
assert_eq!(
hash1, hash2,
"compute_hash() must not depend on the signature field"
);
}
#[test]
fn test_s2_receipt_signature_verification() {
let kp = KeyPair::generate();
let r = make_receipt(&kp, 0, "genesis");
let pub_bytes = kp.public_key_bytes();
assert!(r.verify_signature(&pub_bytes).is_ok());
let mut tampered = r.clone();
tampered.action.status = "forged".to_string();
assert!(
tampered.verify_signature(&pub_bytes).is_err(),
"tampered receipt must fail signature verification"
);
let kp2 = KeyPair::generate();
let wrong_pub = kp2.public_key_bytes();
assert!(
r.verify_signature(&wrong_pub).is_err(),
"wrong public key must fail verification"
);
}
}