Skip to main content

rfe_types/
hashing.rs

1use crate::blake3_hash;
2use alloc::string::ToString;
3use alloc::vec::Vec;
4
5#[derive(Debug, thiserror::Error)]
6pub enum CanonicalHashError {
7    #[error("canonical serialization failed")]
8    SerializationFailed,
9}
10
11/// Canonicalize reports into a deterministic byte stream (compact JSON).
12///
13/// Diagnostic helper for inspection/debugging only.
14/// For authoritative Seal v1 results hash, use `hash_canonical_reports`.
15pub fn canonical_json(
16    reports: &mut [crate::ComplianceReport],
17) -> Result<Vec<u8>, CanonicalHashError> {
18    // 1. Deterministic sort by transaction_id
19    reports.sort();
20
21    // 2. Serialize to compact JSON
22    // Note: rfe-types ensures decimals are used instead of floats.
23    serde_json::to_vec(reports).map_err(|_| CanonicalHashError::SerializationFailed)
24}
25
26/// Helper to compute canonical result hash for Seal v1.
27///
28/// Sorts `reports` in-place by `transaction_id` before hashing.
29pub fn hash_canonical_reports(
30    reports: &mut [crate::ComplianceReport],
31) -> Result<[u8; 32], CanonicalHashError> {
32    reports.sort();
33
34    let mut hasher = blake3::Hasher::new();
35    hasher.update(b"[");
36    for (idx, report) in reports.iter().enumerate() {
37        if idx > 0 {
38            hasher.update(b",");
39        }
40        hash_report_canonical(&mut hasher, report);
41    }
42    hasher.update(b"]");
43    Ok(*hasher.finalize().as_bytes())
44}
45
46/// Helper to hash a raw request byte stream (Lockstep Invariant).
47pub fn hash_raw_request(data: &[u8]) -> [u8; 32] {
48    blake3_hash(data)
49}
50
51fn hash_report_canonical(hasher: &mut blake3::Hasher, report: &crate::ComplianceReport) {
52    hasher.update(b"{\"transaction_id\":\"");
53    hash_json_escaped(hasher, report.transaction_id.as_bytes());
54    hasher.update(b"\",\"pdn_ratio\":\"");
55    hasher.update(report.pdn_ratio.to_string().as_bytes());
56    hasher.update(b"\",\"is_pdn_risky\":");
57    if report.is_pdn_risky {
58        hasher.update(b"true");
59    } else {
60        hasher.update(b"false");
61    }
62    hasher.update(b",\"fraud_signs\":[");
63    for (idx, sign) in report.fraud_signs.iter().enumerate() {
64        if idx > 0 {
65            hasher.update(b",");
66        }
67        hasher.update(b"\"");
68        match sign {
69            crate::FraudSign::ReceiverInDatabase => {
70                hasher.update(b"ReceiverInDatabase");
71            }
72            crate::FraudSign::DeviceInDatabase => {
73                hasher.update(b"DeviceInDatabase");
74            }
75            crate::FraudSign::AtypicalTransaction => {
76                hasher.update(b"AtypicalTransaction");
77            }
78            crate::FraudSign::SuspiciousSbpTransfer => {
79                hasher.update(b"SuspiciousSbpTransfer");
80            }
81            crate::FraudSign::SuspiciousNfcActivity => {
82                hasher.update(b"SuspiciousNfcActivity");
83            }
84            crate::FraudSign::MultipleAccountsFromSingleDevice => {
85                hasher.update(b"MultipleAccountsFromSingleDevice");
86            }
87            crate::FraudSign::InconsistentGeolocation => {
88                hasher.update(b"InconsistentGeolocation");
89            }
90            crate::FraudSign::HighVelocityTransfersInShortWindow => {
91                hasher.update(b"HighVelocityTransfersInShortWindow");
92            }
93            crate::FraudSign::RemoteAccessToolDetected => {
94                hasher.update(b"RemoteAccessToolDetected");
95            }
96            crate::FraudSign::KnownProxyOrVpnEndpoint => {
97                hasher.update(b"KnownProxyOrVpnEndpoint");
98            }
99            crate::FraudSign::SocialEngineeringPatternDetected => {
100                hasher.update(b"SocialEngineeringPatternDetected");
101            }
102            crate::FraudSign::ExternalOperatorSignal => {
103                hasher.update(b"ExternalOperatorSignal");
104            }
105            crate::FraudSign::Other(v) => {
106                hash_json_escaped(hasher, v.as_bytes());
107            }
108        }
109        hasher.update(b"\"");
110    }
111    hasher.update(b"],\"recommendation\":\"");
112    hash_json_escaped(hasher, report.recommendation.as_bytes());
113    hasher.update(b"\",\"created_at_micros\":");
114    hasher.update(report.created_at_micros.to_string().as_bytes());
115    hasher.update(b"}");
116}
117
118fn hash_json_escaped(hasher: &mut blake3::Hasher, bytes: &[u8]) {
119    for &b in bytes {
120        match b {
121            b'"' => {
122                hasher.update(b"\\\"");
123            }
124            b'\\' => {
125                hasher.update(b"\\\\");
126            }
127            b'\n' => {
128                hasher.update(b"\\n");
129            }
130            b'\r' => {
131                hasher.update(b"\\r");
132            }
133            b'\t' => {
134                hasher.update(b"\\t");
135            }
136            0x00..=0x1F => {
137                let mut esc: [u8; 6] = [b'\\', b'u', b'0', b'0', b'0', b'0'];
138                let hi = (b >> 4) & 0x0F;
139                let lo = b & 0x0F;
140                esc[4] = if hi < 10 { b'0' + hi } else { b'a' + (hi - 10) };
141                esc[5] = if lo < 10 { b'0' + lo } else { b'a' + (lo - 10) };
142                hasher.update(&esc);
143            }
144            _ => {
145                hasher.update(&[b]);
146            }
147        }
148    }
149}