use macp_pb::pb::CommitmentPayload;
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
const LABEL: &str = "macp-commitment-hash/1";
pub fn commitment_hash(payload: &CommitmentPayload) -> String {
let canonical = canonicalize(payload);
let mut preimage = Vec::with_capacity(LABEL.len() + 1 + canonical.len());
preimage.extend_from_slice(LABEL.as_bytes());
preimage.push(b':');
preimage.extend_from_slice(&canonical);
let digest = Sha256::digest(&preimage);
let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
out.push_str("sha256:");
for byte in digest {
write!(out, "{byte:02x}").expect("write! to String never fails");
}
out
}
fn canonicalize(payload: &CommitmentPayload) -> Vec<u8> {
let mut out = String::new();
out.push('{');
out.push_str("\"action\":");
push_json_string(&mut out, &payload.action);
out.push_str(",\"authority_scope\":");
push_json_string(&mut out, &payload.authority_scope);
out.push_str(",\"commitment_id\":");
push_json_string(&mut out, &payload.commitment_id);
out.push_str(",\"configuration_version\":");
push_json_string(&mut out, &payload.configuration_version);
out.push_str(",\"mode_version\":");
push_json_string(&mut out, &payload.mode_version);
out.push_str(",\"outcome_positive\":");
out.push_str(if payload.outcome_positive {
"true"
} else {
"false"
});
out.push_str(",\"policy_version\":");
push_json_string(&mut out, &payload.policy_version);
out.push_str(",\"reason\":");
push_json_string(&mut out, &payload.reason);
if let Some(ref supersedes) = payload.supersedes {
out.push_str(",\"supersedes\":{\"commitment_hash\":");
push_json_string(&mut out, &supersedes.commitment_hash);
out.push_str(",\"session_id\":");
push_json_string(&mut out, &supersedes.session_id);
out.push('}');
}
out.push('}');
out.into_bytes()
}
fn push_json_string(out: &mut String, value: &str) {
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{08}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{0C}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
c if (c as u32) < 0x20 => {
write!(out, "\\u{:04x}", c as u32).expect("write! to String never fails");
}
c => out.push(c),
}
}
out.push('"');
}
#[cfg(test)]
mod tests {
use super::*;
use macp_pb::pb::CommitmentRef;
fn decode_hex(s: &str) -> Vec<u8> {
assert_eq!(s.len() % 2, 0, "hex string must have even length");
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex byte"))
.collect()
}
fn base_payload() -> CommitmentPayload {
CommitmentPayload {
commitment_id: String::new(),
action: String::new(),
authority_scope: String::new(),
reason: String::new(),
mode_version: String::new(),
policy_version: String::new(),
configuration_version: String::new(),
outcome_positive: false,
supersedes: None,
}
}
#[test]
fn cmt_hash_001_minimal() {
let payload = CommitmentPayload {
commitment_id: "c1".into(),
action: "decision.approved".into(),
authority_scope: "seam".into(),
reason: "sealed by seam".into(),
mode_version: "macp.mode.decision.v1".into(),
policy_version: "1.0.0".into(),
configuration_version: "1.0.0".into(),
outcome_positive: true,
supersedes: None,
};
let jcs_hex = "7b22616374696f6e223a226465636973696f6e2e617070726f766564222c22617574686f726974795f73636f7065223a227365616d222c22636f6d6d69746d656e745f6964223a226331222c22636f6e66696775726174696f6e5f76657273696f6e223a22312e302e30222c226d6f64655f76657273696f6e223a226d6163702e6d6f64652e6465636973696f6e2e7631222c226f7574636f6d655f706f736974697665223a747275652c22706f6c6963795f76657273696f6e223a22312e302e30222c22726561736f6e223a227365616c6564206279207365616d227d";
assert_eq!(
canonicalize(&payload),
decode_hex(jcs_hex),
"cmt_hash_001_minimal: JCS bytes mismatch"
);
assert_eq!(
commitment_hash(&payload),
"sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41",
"cmt_hash_001_minimal: hash mismatch"
);
}
#[test]
fn cmt_hash_002_supersedes() {
let payload = CommitmentPayload {
commitment_id: "c2".into(),
action: "decision.approved".into(),
authority_scope: "seam".into(),
reason: "sealed by seam".into(),
mode_version: "macp.mode.decision.v1".into(),
policy_version: "1.0.0".into(),
configuration_version: "1.0.0".into(),
outcome_positive: true,
supersedes: Some(CommitmentRef {
session_id: "prior-sess".into(),
commitment_hash:
"sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41".into(),
}),
};
assert_eq!(
commitment_hash(&payload),
"sha256:7cc490432ad6b25e9c19fc7c3a84f1e33abe497fca1fd5266ff0275db3650f9d",
"cmt_hash_002_supersedes: hash mismatch"
);
}
#[test]
fn cmt_hash_003_all_empty() {
let payload = base_payload();
assert_eq!(
commitment_hash(&payload),
"sha256:3240d1a7adb7bd9420ad5490182227ce699c9e4e465f7934885fe2ded939f32e",
"cmt_hash_003_all_empty: hash mismatch"
);
}
#[test]
fn cmt_hash_004_empty_supersedes() {
let mut payload = base_payload();
payload.supersedes = Some(CommitmentRef {
session_id: String::new(),
commitment_hash: String::new(),
});
assert_eq!(
commitment_hash(&payload),
"sha256:9776c22ef165f26817f89bb456cf6bc56a659eb1561a576f6ea9a435bd3291d7",
"cmt_hash_004_empty_supersedes: hash mismatch"
);
}
#[test]
fn cmt_hash_003_and_004_hashes_differ() {
let without = commitment_hash(&base_payload());
let mut with_empty_supersedes = base_payload();
with_empty_supersedes.supersedes = Some(CommitmentRef {
session_id: String::new(),
commitment_hash: String::new(),
});
let with = commitment_hash(&with_empty_supersedes);
assert_ne!(
without, with,
"supersedes:None and supersedes:Some(empty) must hash differently"
);
}
#[test]
fn cmt_hash_005_escapes() {
let payload = CommitmentPayload {
commitment_id: "c5".into(),
action: "decision.\"appro\\ved\"".into(),
authority_scope: "café".into(),
reason: "ré\tsumé\n— naïve \u{1F702}".into(),
mode_version: "macp.mode.decision.v1".into(),
policy_version: "1.0.0".into(),
configuration_version: "1.0.0".into(),
outcome_positive: false,
supersedes: None,
};
let jcs_hex = "7b22616374696f6e223a226465636973696f6e2e5c22617070726f5c5c7665645c22222c22617574686f726974795f73636f7065223a22636166c3a9222c22636f6d6d69746d656e745f6964223a226335222c22636f6e66696775726174696f6e5f76657273696f6e223a22312e302e30222c226d6f64655f76657273696f6e223a226d6163702e6d6f64652e6465636973696f6e2e7631222c226f7574636f6d655f706f736974697665223a66616c73652c22706f6c6963795f76657273696f6e223a22312e302e30222c22726561736f6e223a2272c3a95c7473756dc3a95c6ee28094206e61c3af766520f09f9c82227d";
assert_eq!(
canonicalize(&payload),
decode_hex(jcs_hex),
"cmt_hash_005_escapes: JCS bytes mismatch"
);
assert_eq!(
commitment_hash(&payload),
"sha256:03f8ac2b8172958504092ce9fe5154dbcfe300fd30a350453d4e4bd715822ab2",
"cmt_hash_005_escapes: hash mismatch"
);
}
fn escape(value: &str) -> String {
let mut out = String::new();
push_json_string(&mut out, value);
out
}
#[test]
fn escapes_embedded_quote() {
assert_eq!(escape("a\"b"), "\"a\\\"b\"");
}
#[test]
fn escapes_embedded_backslash() {
assert_eq!(escape("a\\b"), "\"a\\\\b\"");
}
#[test]
fn escapes_tab() {
assert_eq!(escape("a\tb"), "\"a\\tb\"");
}
#[test]
fn escapes_newline() {
assert_eq!(escape("a\nb"), "\"a\\nb\"");
}
#[test]
fn escapes_other_c0_control_as_u00xx() {
assert_eq!(escape("a\u{01}b"), "\"a\\u0001b\"");
}
#[test]
fn non_ascii_bmp_char_is_emitted_literally() {
assert_eq!(escape("café"), "\"café\"");
}
#[test]
fn astral_plane_codepoint_is_emitted_literally_not_as_surrogate_pair() {
let value = "\u{1F702}";
let escaped = escape(value);
assert_eq!(escaped, "\"\u{1F702}\"");
assert!(!escaped.contains("\\u"));
}
}