Skip to main content

macp_core/
commitment_hash.rs

1//! RFC-MACP-0013 canonical commitment hash.
2//!
3//! `commitment_hash` maps a `CommitmentPayload` to a stable
4//! `sha256:<64 lowercase hex>` string by:
5//!
6//! 1. **Projecting** the payload to a frozen JSON object (RFC-MACP-0013 §3).
7//! 2. **Canonicalizing** that object with a hand-written RFC 8785 (JCS)
8//!    serializer (§4) -- deliberately not `serde_json`, whose object
9//!    serialization does not implement JCS's key ordering or escaping rules.
10//! 3. Hashing the domain-separated preimage with SHA-256 and hex-encoding
11//!    the digest.
12//!
13//! This module has exactly one job and does it unconditionally: it never
14//! validates the payload and never fails (see [`commitment_hash`]'s doc for
15//! the D3 guarantee). Semantic validation of `CommitmentPayload` (matching
16//! session-bound versions, well-formed `supersedes`, etc.) is a separate
17//! concern handled elsewhere (see `macp-modes::mode::util`).
18
19use macp_pb::pb::CommitmentPayload;
20use sha2::{Digest, Sha256};
21use std::fmt::Write as _;
22
23/// Domain-separation label for this hashing algorithm (RFC-MACP-0013 §4).
24///
25/// The preimage is `LABEL + ":" + canonical_json_bytes`, not `LABEL` alone --
26/// see [`commitment_hash`] for the literal concatenation.
27///
28/// This label, the fixed nine-field projection, and the fixed top-level key
29/// order below are all part of what this label identifies. If
30/// `CommitmentPayload` ever grows a tenth field, RFC-MACP-0013 §5/§7 requires
31/// minting a *new* label (`macp-commitment-hash/2`) for the new projection --
32/// never silently extending the projection under this label.
33const LABEL: &str = "macp-commitment-hash/1";
34
35/// Compute the RFC-MACP-0013 commitment hash for `payload`.
36///
37/// Returns `"sha256:"` followed by 64 lowercase hex characters.
38///
39/// D3 -- this function never fails. It accepts any `CommitmentPayload`,
40/// well-formed or not (including one with every string field empty, or a
41/// `supersedes` present with empty sub-fields), and always returns a
42/// `String`. It performs no validation; callers that need to reject
43/// malformed payloads before hashing them must do so separately.
44pub fn commitment_hash(payload: &CommitmentPayload) -> String {
45    let canonical = canonicalize(payload);
46
47    let mut preimage = Vec::with_capacity(LABEL.len() + 1 + canonical.len());
48    preimage.extend_from_slice(LABEL.as_bytes());
49    preimage.push(b':');
50    preimage.extend_from_slice(&canonical);
51
52    let digest = Sha256::digest(&preimage);
53
54    let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
55    out.push_str("sha256:");
56    for byte in digest {
57        write!(out, "{byte:02x}").expect("write! to String never fails");
58    }
59    out
60}
61
62/// Project `payload` to the RFC-MACP-0013 §3 JSON object and serialize it
63/// with RFC 8785 (JCS) rules, returning canonical UTF-8 bytes.
64///
65/// The projection's nine top-level member names (`action`,
66/// `authority_scope`, `commitment_id`, `configuration_version`,
67/// `mode_version`, `outcome_positive`, `policy_version`, `reason`,
68/// `supersedes`) are a frozen, all-ASCII set, so JCS's "sort members by
69/// UTF-16 code unit" rule (RFC 8785 §3.2.3) collapses to one static byte
70/// order that we can hard-code instead of sorting at runtime. Verified by
71/// direct byte comparison of the key strings:
72///
73///   "action" < "authority_scope"          (index 1: 'c' 0x63 < 'u' 0x75)
74///   "authority_scope" < "commitment_id"   (index 0: 'a' 0x61 < 'c' 0x63)
75///   "commitment_id" < "configuration_version" (index 2: 'm' 0x6d < 'n' 0x6e)
76///   "configuration_version" < "mode_version"  (index 0: 'c' 0x63 < 'm' 0x6d)
77///   "mode_version" < "outcome_positive"       (index 0: 'm' 0x6d < 'o' 0x6f)
78///   "outcome_positive" < "policy_version"     (index 0: 'o' 0x6f < 'p' 0x70)
79///   "policy_version" < "reason"                (index 0: 'p' 0x70 < 'r' 0x72)
80///   "reason" < "supersedes"                    (index 0: 'r' 0x72 < 's' 0x73)
81///
82/// and, inside the nested `supersedes` object:
83///
84///   "commitment_hash" < "session_id"           (index 0: 'c' 0x63 < 's' 0x73)
85///
86/// `supersedes` is omitted from the object entirely when `None` (unset, not
87/// merely empty) -- see the `cmt_hash_003` / `cmt_hash_004` test pair below,
88/// which is the only thing pinning that this is implemented as omission
89/// rather than "empty string fields count as absent".
90///
91/// Do NOT extend this fixed order if `CommitmentPayload` grows a tenth
92/// field -- see the [`LABEL`] doc comment.
93fn canonicalize(payload: &CommitmentPayload) -> Vec<u8> {
94    let mut out = String::new();
95    out.push('{');
96
97    out.push_str("\"action\":");
98    push_json_string(&mut out, &payload.action);
99
100    out.push_str(",\"authority_scope\":");
101    push_json_string(&mut out, &payload.authority_scope);
102
103    out.push_str(",\"commitment_id\":");
104    push_json_string(&mut out, &payload.commitment_id);
105
106    out.push_str(",\"configuration_version\":");
107    push_json_string(&mut out, &payload.configuration_version);
108
109    out.push_str(",\"mode_version\":");
110    push_json_string(&mut out, &payload.mode_version);
111
112    out.push_str(",\"outcome_positive\":");
113    out.push_str(if payload.outcome_positive {
114        "true"
115    } else {
116        "false"
117    });
118
119    out.push_str(",\"policy_version\":");
120    push_json_string(&mut out, &payload.policy_version);
121
122    out.push_str(",\"reason\":");
123    push_json_string(&mut out, &payload.reason);
124
125    if let Some(ref supersedes) = payload.supersedes {
126        out.push_str(",\"supersedes\":{\"commitment_hash\":");
127        push_json_string(&mut out, &supersedes.commitment_hash);
128        out.push_str(",\"session_id\":");
129        push_json_string(&mut out, &supersedes.session_id);
130        out.push('}');
131    }
132
133    out.push('}');
134    out.into_bytes()
135}
136
137/// Append `value` as a JCS-canonical JSON string literal (including
138/// surrounding quotes) to `out`, per RFC 8785 §3.2.2.2.
139///
140/// - `"`, `\`, and the controls with short forms (`\b \t \n \f \r`) use
141///   their short escape.
142/// - Any other C0 control character (0x00-0x1F) is emitted as `\u00XX`.
143/// - Everything else -- including non-ASCII BMP characters and astral-plane
144///   codepoints -- is emitted as literal UTF-8. Rust's `char` is always a
145///   valid Unicode scalar value, so astral-plane codepoints round-trip as a
146///   single `char` here with no manual UTF-16 surrogate-pair encoding.
147///
148/// Key names are not run through this function: the nine projection keys
149/// are a fixed compile-time list already known to need no escaping.
150fn push_json_string(out: &mut String, value: &str) {
151    out.push('"');
152    for c in value.chars() {
153        match c {
154            '"' => out.push_str("\\\""),
155            '\\' => out.push_str("\\\\"),
156            '\u{08}' => out.push_str("\\b"),
157            '\t' => out.push_str("\\t"),
158            '\n' => out.push_str("\\n"),
159            '\u{0C}' => out.push_str("\\f"),
160            '\r' => out.push_str("\\r"),
161            c if (c as u32) < 0x20 => {
162                write!(out, "\\u{:04x}", c as u32).expect("write! to String never fails");
163            }
164            c => out.push(c),
165        }
166    }
167    out.push('"');
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use macp_pb::pb::CommitmentRef;
174
175    /// Decode a lowercase-hex string into bytes (test-only; no `hex` crate
176    /// dependency -- see the "exactly one new dependency" constraint).
177    fn decode_hex(s: &str) -> Vec<u8> {
178        assert_eq!(s.len() % 2, 0, "hex string must have even length");
179        (0..s.len())
180            .step_by(2)
181            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex byte"))
182            .collect()
183    }
184
185    fn base_payload() -> CommitmentPayload {
186        CommitmentPayload {
187            commitment_id: String::new(),
188            action: String::new(),
189            authority_scope: String::new(),
190            reason: String::new(),
191            mode_version: String::new(),
192            policy_version: String::new(),
193            configuration_version: String::new(),
194            outcome_positive: false,
195            supersedes: None,
196        }
197    }
198
199    // --- spec reference vectors (RFC-MACP-0013 conformance fixtures) ---
200    // Payload field values are copied verbatim from
201    // schemas/conformance/cmt-hash/cmt_hash_00{1..5}_*.json in the sibling
202    // multiagentcoordinationprotocol repo.
203
204    #[test]
205    fn cmt_hash_001_minimal() {
206        let payload = CommitmentPayload {
207            commitment_id: "c1".into(),
208            action: "decision.approved".into(),
209            authority_scope: "seam".into(),
210            reason: "sealed by seam".into(),
211            mode_version: "macp.mode.decision.v1".into(),
212            policy_version: "1.0.0".into(),
213            configuration_version: "1.0.0".into(),
214            outcome_positive: true,
215            supersedes: None,
216        };
217
218        let jcs_hex = "7b22616374696f6e223a226465636973696f6e2e617070726f766564222c22617574686f726974795f73636f7065223a227365616d222c22636f6d6d69746d656e745f6964223a226331222c22636f6e66696775726174696f6e5f76657273696f6e223a22312e302e30222c226d6f64655f76657273696f6e223a226d6163702e6d6f64652e6465636973696f6e2e7631222c226f7574636f6d655f706f736974697665223a747275652c22706f6c6963795f76657273696f6e223a22312e302e30222c22726561736f6e223a227365616c6564206279207365616d227d";
219        assert_eq!(
220            canonicalize(&payload),
221            decode_hex(jcs_hex),
222            "cmt_hash_001_minimal: JCS bytes mismatch"
223        );
224
225        assert_eq!(
226            commitment_hash(&payload),
227            "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41",
228            "cmt_hash_001_minimal: hash mismatch"
229        );
230    }
231
232    #[test]
233    fn cmt_hash_002_supersedes() {
234        let payload = CommitmentPayload {
235            commitment_id: "c2".into(),
236            action: "decision.approved".into(),
237            authority_scope: "seam".into(),
238            reason: "sealed by seam".into(),
239            mode_version: "macp.mode.decision.v1".into(),
240            policy_version: "1.0.0".into(),
241            configuration_version: "1.0.0".into(),
242            outcome_positive: true,
243            supersedes: Some(CommitmentRef {
244                session_id: "prior-sess".into(),
245                commitment_hash:
246                    "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41".into(),
247            }),
248        };
249
250        assert_eq!(
251            commitment_hash(&payload),
252            "sha256:7cc490432ad6b25e9c19fc7c3a84f1e33abe497fca1fd5266ff0275db3650f9d",
253            "cmt_hash_002_supersedes: hash mismatch"
254        );
255    }
256
257    #[test]
258    fn cmt_hash_003_all_empty() {
259        let payload = base_payload();
260
261        assert_eq!(
262            commitment_hash(&payload),
263            "sha256:3240d1a7adb7bd9420ad5490182227ce699c9e4e465f7934885fe2ded939f32e",
264            "cmt_hash_003_all_empty: hash mismatch"
265        );
266    }
267
268    #[test]
269    fn cmt_hash_004_empty_supersedes() {
270        let mut payload = base_payload();
271        payload.supersedes = Some(CommitmentRef {
272            session_id: String::new(),
273            commitment_hash: String::new(),
274        });
275
276        assert_eq!(
277            commitment_hash(&payload),
278            "sha256:9776c22ef165f26817f89bb456cf6bc56a659eb1561a576f6ea9a435bd3291d7",
279            "cmt_hash_004_empty_supersedes: hash mismatch"
280        );
281    }
282
283    #[test]
284    fn cmt_hash_003_and_004_hashes_differ() {
285        // The sole check that `supersedes: None` (unset) and
286        // `supersedes: Some(all-empty)` (empty) project differently, i.e.
287        // that omission-when-None is actually implemented rather than
288        // "empty sub-fields are treated as absent".
289        let without = commitment_hash(&base_payload());
290        let mut with_empty_supersedes = base_payload();
291        with_empty_supersedes.supersedes = Some(CommitmentRef {
292            session_id: String::new(),
293            commitment_hash: String::new(),
294        });
295        let with = commitment_hash(&with_empty_supersedes);
296
297        assert_ne!(
298            without, with,
299            "supersedes:None and supersedes:Some(empty) must hash differently"
300        );
301    }
302
303    #[test]
304    fn cmt_hash_005_escapes() {
305        let payload = CommitmentPayload {
306            commitment_id: "c5".into(),
307            action: "decision.\"appro\\ved\"".into(),
308            authority_scope: "café".into(),
309            reason: "ré\tsumé\n— naïve \u{1F702}".into(),
310            mode_version: "macp.mode.decision.v1".into(),
311            policy_version: "1.0.0".into(),
312            configuration_version: "1.0.0".into(),
313            outcome_positive: false,
314            supersedes: None,
315        };
316
317        let jcs_hex = "7b22616374696f6e223a226465636973696f6e2e5c22617070726f5c5c7665645c22222c22617574686f726974795f73636f7065223a22636166c3a9222c22636f6d6d69746d656e745f6964223a226335222c22636f6e66696775726174696f6e5f76657273696f6e223a22312e302e30222c226d6f64655f76657273696f6e223a226d6163702e6d6f64652e6465636973696f6e2e7631222c226f7574636f6d655f706f736974697665223a66616c73652c22706f6c6963795f76657273696f6e223a22312e302e30222c22726561736f6e223a2272c3a95c7473756dc3a95c6ee28094206e61c3af766520f09f9c82227d";
318        assert_eq!(
319            canonicalize(&payload),
320            decode_hex(jcs_hex),
321            "cmt_hash_005_escapes: JCS bytes mismatch"
322        );
323
324        assert_eq!(
325            commitment_hash(&payload),
326            "sha256:03f8ac2b8172958504092ce9fe5154dbcfe300fd30a350453d4e4bd715822ab2",
327            "cmt_hash_005_escapes: hash mismatch"
328        );
329    }
330
331    // --- isolated escaping unit tests ---
332
333    fn escape(value: &str) -> String {
334        let mut out = String::new();
335        push_json_string(&mut out, value);
336        out
337    }
338
339    #[test]
340    fn escapes_embedded_quote() {
341        assert_eq!(escape("a\"b"), "\"a\\\"b\"");
342    }
343
344    #[test]
345    fn escapes_embedded_backslash() {
346        assert_eq!(escape("a\\b"), "\"a\\\\b\"");
347    }
348
349    #[test]
350    fn escapes_tab() {
351        assert_eq!(escape("a\tb"), "\"a\\tb\"");
352    }
353
354    #[test]
355    fn escapes_newline() {
356        assert_eq!(escape("a\nb"), "\"a\\nb\"");
357    }
358
359    #[test]
360    fn escapes_other_c0_control_as_u00xx() {
361        // 0x01 (SOH) has no short form.
362        assert_eq!(escape("a\u{01}b"), "\"a\\u0001b\"");
363    }
364
365    #[test]
366    fn non_ascii_bmp_char_is_emitted_literally() {
367        assert_eq!(escape("café"), "\"café\"");
368    }
369
370    #[test]
371    fn astral_plane_codepoint_is_emitted_literally_not_as_surrogate_pair() {
372        // U+1F702 ALCHEMICAL SYMBOL FOR VINEGAR -- vector 005's stress case.
373        // RFC 8785 leaves non-BMP characters unescaped; this must NOT be
374        // hand-rolled into a 🜂 surrogate pair.
375        let value = "\u{1F702}";
376        let escaped = escape(value);
377        assert_eq!(escaped, "\"\u{1F702}\"");
378        assert!(!escaped.contains("\\u"));
379    }
380}