Skip to main content

mnemo_deal/
envelope.rs

1//! Deal envelope shape (v0.4.0 P1-5).
2
3use std::time::SystemTime;
4
5use hmac::{Hmac, KeyInit, Mac};
6use serde::{Deserialize, Serialize};
7use sha2::Sha256;
8use thiserror::Error;
9use uuid::Uuid;
10
11type HmacSha256 = Hmac<Sha256>;
12
13pub type AgentId = String;
14
15/// One signed contract row. The `hmac` covers the canonical
16/// concatenation `id || buyer || seller || terms || signed_at ||
17/// prev_hash` so any tamper of a field invalidates the row.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct DealEnvelope {
20    pub id: Uuid,
21    pub buyer: AgentId,
22    pub seller: AgentId,
23    pub terms: serde_json::Value,
24    pub signed_at: SystemTime,
25    pub prev_hash: [u8; 32],
26    pub hmac: [u8; 32],
27}
28
29#[derive(Debug, Error, PartialEq)]
30pub enum EnvelopeError {
31    #[error("HMAC key must be at least 32 bytes (got {0})")]
32    KeyTooShort(usize),
33    #[error("envelope HMAC verification failed at id {0}")]
34    HmacMismatch(Uuid),
35}
36
37impl DealEnvelope {
38    /// Build a fresh envelope chained off `prev_hash`. The signer
39    /// computes the HMAC; receivers verify with [`verify_hmac`].
40    pub fn sign(
41        buyer: impl Into<String>,
42        seller: impl Into<String>,
43        terms: serde_json::Value,
44        prev_hash: [u8; 32],
45        signed_at: SystemTime,
46        key: &[u8],
47    ) -> Result<Self, EnvelopeError> {
48        if key.len() < 32 {
49            return Err(EnvelopeError::KeyTooShort(key.len()));
50        }
51        let id = Uuid::now_v7();
52        let buyer: String = buyer.into();
53        let seller: String = seller.into();
54        let canonical = canonical_bytes(&id, &buyer, &seller, &terms, signed_at, &prev_hash);
55        let mut mac =
56            <HmacSha256 as KeyInit>::new_from_slice(key).expect("KeyInit accepts >=32 bytes");
57        mac.update(&canonical);
58        let hmac: [u8; 32] = mac.finalize().into_bytes().into();
59        Ok(Self {
60            id,
61            buyer,
62            seller,
63            terms,
64            signed_at,
65            prev_hash,
66            hmac,
67        })
68    }
69
70    /// Verify the envelope's `hmac` against the supplied key.
71    pub fn verify_hmac(&self, key: &[u8]) -> Result<(), EnvelopeError> {
72        if key.len() < 32 {
73            return Err(EnvelopeError::KeyTooShort(key.len()));
74        }
75        let canonical = canonical_bytes(
76            &self.id,
77            &self.buyer,
78            &self.seller,
79            &self.terms,
80            self.signed_at,
81            &self.prev_hash,
82        );
83        let mut mac =
84            <HmacSha256 as KeyInit>::new_from_slice(key).expect("KeyInit accepts >=32 bytes");
85        mac.update(&canonical);
86        mac.verify_slice(&self.hmac)
87            .map_err(|_| EnvelopeError::HmacMismatch(self.id))
88    }
89
90    /// Hash this envelope's body to produce the `prev_hash` for the
91    /// next envelope in the chain. Pure fn — does not touch the
92    /// HMAC key.
93    pub fn next_prev_hash(&self) -> [u8; 32] {
94        let canonical = canonical_bytes(
95            &self.id,
96            &self.buyer,
97            &self.seller,
98            &self.terms,
99            self.signed_at,
100            &self.prev_hash,
101        );
102        use sha2::Digest;
103        let mut h = Sha256::new();
104        h.update(canonical);
105        h.update(self.hmac);
106        h.finalize().into()
107    }
108}
109
110fn canonical_bytes(
111    id: &Uuid,
112    buyer: &str,
113    seller: &str,
114    terms: &serde_json::Value,
115    signed_at: SystemTime,
116    prev_hash: &[u8; 32],
117) -> Vec<u8> {
118    let mut out = Vec::with_capacity(256);
119    out.extend_from_slice(id.as_bytes());
120    out.push(b'|');
121    out.extend_from_slice(buyer.as_bytes());
122    out.push(b'|');
123    out.extend_from_slice(seller.as_bytes());
124    out.push(b'|');
125    // Use canonical JSON so different field orders don't produce
126    // different HMACs — serde_json::to_string already emits keys in
127    // insertion order, but this rebuild via to_value forces a stable
128    // representation by re-serializing through Value's BTreeMap.
129    let canon = serde_json::to_string(terms).unwrap_or_default();
130    out.extend_from_slice(canon.as_bytes());
131    out.push(b'|');
132    let dt: chrono::DateTime<chrono::Utc> = signed_at.into();
133    out.extend_from_slice(dt.to_rfc3339().as_bytes());
134    out.push(b'|');
135    out.extend_from_slice(prev_hash);
136    out
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn key() -> [u8; 32] {
144        [42u8; 32]
145    }
146
147    #[test]
148    fn sign_verify_round_trip() {
149        let e = DealEnvelope::sign(
150            "buyer-a",
151            "seller-b",
152            serde_json::json!({"price": "10 USDC"}),
153            [0u8; 32],
154            SystemTime::UNIX_EPOCH,
155            &key(),
156        )
157        .unwrap();
158        assert!(e.verify_hmac(&key()).is_ok());
159    }
160
161    #[test]
162    fn flipped_byte_breaks_verify() {
163        let mut e = DealEnvelope::sign(
164            "buyer-a",
165            "seller-b",
166            serde_json::json!({"price": "10 USDC"}),
167            [0u8; 32],
168            SystemTime::UNIX_EPOCH,
169            &key(),
170        )
171        .unwrap();
172        e.terms = serde_json::json!({"price": "1000 USDC"});
173        let err = e.verify_hmac(&key()).unwrap_err();
174        assert!(matches!(err, EnvelopeError::HmacMismatch(_)));
175    }
176
177    #[test]
178    fn short_key_is_rejected() {
179        let err = DealEnvelope::sign(
180            "a",
181            "b",
182            serde_json::json!({}),
183            [0u8; 32],
184            SystemTime::UNIX_EPOCH,
185            &[1u8; 16],
186        )
187        .unwrap_err();
188        assert_eq!(err, EnvelopeError::KeyTooShort(16));
189    }
190
191    #[test]
192    fn next_prev_hash_is_deterministic() {
193        let e = DealEnvelope::sign(
194            "a",
195            "b",
196            serde_json::json!({}),
197            [0u8; 32],
198            SystemTime::UNIX_EPOCH,
199            &key(),
200        )
201        .unwrap();
202        assert_eq!(e.next_prev_hash(), e.next_prev_hash());
203    }
204}