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
11pub fn canonical_json(
16 reports: &mut [crate::ComplianceReport],
17) -> Result<Vec<u8>, CanonicalHashError> {
18 reports.sort();
20
21 serde_json::to_vec(reports).map_err(|_| CanonicalHashError::SerializationFailed)
24}
25
26pub 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
46pub 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}