Skip to main content

idprova_core/receipt/
entry.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::crypto::hash::prefixed_blake3;
5use crate::crypto::KeyPair;
6use crate::{IdprovaError, Result};
7
8/// Details of the action performed.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ActionDetails {
11    /// Action type (e.g., "mcp:tool-call", "a2a:message").
12    #[serde(rename = "type")]
13    pub action_type: String,
14    /// Target server hostname.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub server: Option<String>,
17    /// Tool or method name.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub tool: Option<String>,
20    /// BLAKE3 hash of the input data.
21    #[serde(rename = "inputHash")]
22    pub input_hash: String,
23    /// BLAKE3 hash of the output data.
24    #[serde(rename = "outputHash", skip_serializing_if = "Option::is_none")]
25    pub output_hash: Option<String>,
26    /// Action status.
27    pub status: String,
28    /// Duration in milliseconds.
29    #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
30    pub duration_ms: Option<u64>,
31}
32
33/// Contextual information for the receipt.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ReceiptContext {
36    /// Session identifier.
37    #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
38    pub session_id: Option<String>,
39    /// Parent receipt ID (for action chains).
40    #[serde(rename = "parentReceiptId", skip_serializing_if = "Option::is_none")]
41    pub parent_receipt_id: Option<String>,
42    /// Unique request identifier.
43    #[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
44    pub request_id: Option<String>,
45}
46
47/// A single action receipt in the hash chain.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Receipt {
50    /// Unique receipt identifier.
51    pub id: String,
52    /// Timestamp of the action.
53    pub timestamp: DateTime<Utc>,
54    /// Agent DID that performed the action.
55    pub agent: String,
56    /// DAT JTI that authorized the action.
57    pub dat: String,
58    /// Action details.
59    pub action: ActionDetails,
60    /// Optional context.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub context: Option<ReceiptContext>,
63    /// Hash chain linkage.
64    pub chain: ChainLink,
65    /// Agent's signature over this receipt.
66    pub signature: String,
67}
68
69/// Hash chain linkage for tamper-evidence.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ChainLink {
72    /// BLAKE3 hash of the previous receipt (or "genesis" for first).
73    #[serde(rename = "previousHash")]
74    pub previous_hash: String,
75    /// Sequence number in the chain.
76    #[serde(rename = "sequenceNumber")]
77    pub sequence_number: u64,
78}
79
80/// Signing payload — receipt fields excluding the signature.
81///
82/// # Security: fix S3 (circular dependency in compute_hash)
83///
84/// The `signature` field must NOT be included when computing the hash or signing,
85/// since the signature is computed over the payload, not over itself.
86/// SDK implementers MUST use this struct (or equivalent) as the signing input.
87#[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    /// Returns the canonical signing payload bytes (excludes signature field).
101    ///
102    /// This is the data that is (or should be) signed to produce `self.signature`,
103    /// and the data used as input to `compute_hash()`.
104    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    /// Compute the BLAKE3 hash of this receipt (for chain linking).
118    ///
119    /// Uses `signing_payload_bytes()` (i.e., excludes the signature field)
120    /// so the hash is stable regardless of whether the receipt is signed yet.
121    pub fn compute_hash(&self) -> String {
122        prefixed_blake3(&self.signing_payload_bytes())
123    }
124
125    /// Verify this receipt's signature against the agent's public key.
126    ///
127    /// # Security: fix S2 (receipt signatures never verified)
128    ///
129    /// The signature field is hex-encoded Ed25519 signature over `signing_payload_bytes()`.
130    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(), // placeholder
167        };
168        // Sign the payload
169        let payload = r.signing_payload_bytes();
170        let sig = kp.sign(&payload);
171        r.signature = hex::encode(sig);
172        r
173    }
174
175    /// S3: compute_hash() must NOT include the signature field.
176    ///
177    /// The hash must be identical whether computed before or after signing
178    /// (i.e., the signature field must be excluded from the hash input).
179    #[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        // Mutate the signature — hash must remain the same
187        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    /// S2: verify_signature() must reject tampered receipts.
198    #[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        // Valid receipt verifies OK
205        assert!(r.verify_signature(&pub_bytes).is_ok());
206
207        // Tamper with the action — verification must fail
208        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        // Wrong key must fail
216        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}