use sha2::{Digest, Sha256};
const DIGEST_FIELD: &str = "digest";
const SCHEME_FIELD: &str = "redacted";
const SCHEME_SHA256: &str = "sha256";
pub trait AuditRedactor: Send + Sync {
fn redact(&self, value: &serde_json::Value) -> serde_json::Value;
}
pub fn opaque_digest(value: &serde_json::Value) -> serde_json::Value {
let canonical = serde_json::to_vec(&canonicalize(value))
.expect("a serde_json::Value is always serializable");
let hex = hex_lower(&Sha256::digest(&canonical));
serde_json::json!({
SCHEME_FIELD: SCHEME_SHA256,
DIGEST_FIELD: hex,
})
}
fn canonicalize(value: &serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match value {
Value::Object(map) => {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut sorted = serde_json::Map::with_capacity(entries.len());
for (key, child) in entries {
sorted.insert(key.clone(), canonicalize(child));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
other => other.clone(),
}
}
fn hex_lower(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opaque_digest_is_deterministic_for_equal_input() {
let value = serde_json::json!({"iban": "DE89", "email": "a@b.de"});
assert_eq!(opaque_digest(&value), opaque_digest(&value));
}
#[test]
fn opaque_digest_differs_for_distinct_input() {
let a = serde_json::json!({"iban": "DE89"});
let b = serde_json::json!({"iban": "DE90"});
assert_ne!(opaque_digest(&a), opaque_digest(&b));
}
#[test]
fn opaque_digest_carries_scheme_marker_and_hex_digest() {
let out = opaque_digest(&serde_json::json!("secret"));
assert_eq!(out[SCHEME_FIELD], serde_json::json!(SCHEME_SHA256));
let digest = out[DIGEST_FIELD].as_str().expect("digest is a string");
assert_eq!(digest.len(), 64, "sha256 hex is 64 chars");
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn opaque_digest_does_not_echo_input_bytes() {
let iban = "DE89370400440532013000";
let out = opaque_digest(&serde_json::json!({"iban": iban}));
assert!(!serde_json::to_string(&out).unwrap().contains(iban));
}
#[test]
fn opaque_digest_is_key_order_invariant() {
let a = serde_json::json!({"x": 1, "y": 2});
let b = serde_json::json!({"y": 2, "x": 1});
assert_eq!(opaque_digest(&a), opaque_digest(&b));
}
#[test]
fn opaque_digest_is_key_order_invariant_when_nested() {
let a = serde_json::json!({"outer": {"x": 1, "y": 2}, "list": [{"a": 1, "b": 2}]});
let b = serde_json::json!({"list": [{"b": 2, "a": 1}], "outer": {"y": 2, "x": 1}});
assert_eq!(opaque_digest(&a), opaque_digest(&b));
}
}