idprova_core/receipt/
entry.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::crypto::hash::prefixed_blake3;
5use crate::crypto::KeyPair;
6use crate::{IdprovaError, Result};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ActionDetails {
11 #[serde(rename = "type")]
13 pub action_type: String,
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub server: Option<String>,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub tool: Option<String>,
20 #[serde(rename = "inputHash")]
22 pub input_hash: String,
23 #[serde(rename = "outputHash", skip_serializing_if = "Option::is_none")]
25 pub output_hash: Option<String>,
26 pub status: String,
28 #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
30 pub duration_ms: Option<u64>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ReceiptContext {
36 #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
38 pub session_id: Option<String>,
39 #[serde(rename = "parentReceiptId", skip_serializing_if = "Option::is_none")]
41 pub parent_receipt_id: Option<String>,
42 #[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
44 pub request_id: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Receipt {
50 pub id: String,
52 pub timestamp: DateTime<Utc>,
54 pub agent: String,
56 pub dat: String,
58 pub action: ActionDetails,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub context: Option<ReceiptContext>,
63 pub chain: ChainLink,
65 pub signature: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ChainLink {
72 #[serde(rename = "previousHash")]
74 pub previous_hash: String,
75 #[serde(rename = "sequenceNumber")]
77 pub sequence_number: u64,
78}
79
80#[derive(Serialize)]
88struct ReceiptSigningPayload<'a> {
89 pub id: &'a str,
90 pub timestamp: &'a DateTime<Utc>,
91 pub agent: &'a str,
92 pub dat: &'a str,
93 pub action: &'a ActionDetails,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub context: Option<&'a ReceiptContext>,
96 pub chain: &'a ChainLink,
97}
98
99impl Receipt {
100 pub fn signing_payload_bytes(&self) -> Vec<u8> {
105 let payload = ReceiptSigningPayload {
106 id: &self.id,
107 timestamp: &self.timestamp,
108 agent: &self.agent,
109 dat: &self.dat,
110 action: &self.action,
111 context: self.context.as_ref(),
112 chain: &self.chain,
113 };
114 serde_json::to_vec(&payload).unwrap_or_default()
115 }
116
117 pub fn compute_hash(&self) -> String {
122 prefixed_blake3(&self.signing_payload_bytes())
123 }
124
125 pub fn verify_signature(&self, public_key_bytes: &[u8; 32]) -> Result<()> {
131 let sig_bytes = hex::decode(&self.signature)
132 .map_err(|e| IdprovaError::InvalidReceipt(format!("signature hex decode: {e}")))?;
133 let payload = self.signing_payload_bytes();
134 KeyPair::verify(public_key_bytes, &payload, &sig_bytes)
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::crypto::KeyPair;
142 use chrono::Utc;
143
144 fn make_receipt(kp: &KeyPair, seq: u64, prev_hash: &str) -> Receipt {
145 let chain = ChainLink {
146 previous_hash: prev_hash.to_string(),
147 sequence_number: seq,
148 };
149 let action = ActionDetails {
150 action_type: "mcp:tool-call".to_string(),
151 server: None,
152 tool: Some("read_file".to_string()),
153 input_hash: "blake3:abc123".to_string(),
154 output_hash: None,
155 status: "success".to_string(),
156 duration_ms: Some(42),
157 };
158 let mut r = Receipt {
159 id: format!("rcpt_{seq}"),
160 timestamp: Utc::now(),
161 agent: "did:idprova:example.com:kai".to_string(),
162 dat: "dat_test".to_string(),
163 action,
164 context: None,
165 chain,
166 signature: String::new(), };
168 let payload = r.signing_payload_bytes();
170 let sig = kp.sign(&payload);
171 r.signature = hex::encode(sig);
172 r
173 }
174
175 #[test]
180 fn test_s3_hash_excludes_signature() {
181 let kp = KeyPair::generate();
182 let r = make_receipt(&kp, 0, "genesis");
183
184 let hash1 = r.compute_hash();
185
186 let mut r2 = r.clone();
188 r2.signature = "deadbeef".to_string();
189 let hash2 = r2.compute_hash();
190
191 assert_eq!(
192 hash1, hash2,
193 "compute_hash() must not depend on the signature field"
194 );
195 }
196
197 #[test]
199 fn test_s2_receipt_signature_verification() {
200 let kp = KeyPair::generate();
201 let r = make_receipt(&kp, 0, "genesis");
202 let pub_bytes = kp.public_key_bytes();
203
204 assert!(r.verify_signature(&pub_bytes).is_ok());
206
207 let mut tampered = r.clone();
209 tampered.action.status = "forged".to_string();
210 assert!(
211 tampered.verify_signature(&pub_bytes).is_err(),
212 "tampered receipt must fail signature verification"
213 );
214
215 let kp2 = KeyPair::generate();
217 let wrong_pub = kp2.public_key_bytes();
218 assert!(
219 r.verify_signature(&wrong_pub).is_err(),
220 "wrong public key must fail verification"
221 );
222 }
223}