1#![no_std]
7
8extern crate alloc;
9use alloc::string::String;
10
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15pub use rust_decimal;
16
17pub mod hashing;
18pub mod test_vectors;
19
20#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(transparent)]
25pub struct Sensitive<T>(pub T);
26
27impl<T> core::fmt::Debug for Sensitive<T> {
28 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
29 write!(f, "[REDACTED PII]")
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct Inn(pub Sensitive<String>);
36
37impl Inn {
38 pub fn new_unchecked(s: impl Into<String>) -> Self {
40 Self(Sensitive(s.into()))
41 }
42
43 pub fn parse(s: impl Into<String>) -> Result<Self, RfeError> {
45 let s = s.into();
46 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
47 if digits.len() == 10 || digits.len() == 12 {
48 Ok(Self(Sensitive(digits)))
49 } else {
50 Err(RfeError::InvalidInn(s))
51 }
52 }
53
54 pub fn as_str(&self) -> &str {
55 &self.0 .0
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61pub struct Ogrn(pub Sensitive<String>);
62
63impl Ogrn {
64 pub fn parse(s: impl Into<String>) -> Result<Self, RfeError> {
65 let s = s.into();
66 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
67 if digits.len() == 13 || digits.len() == 15 {
68 Ok(Self(Sensitive(digits)))
69 } else {
70 Err(RfeError::InvalidOgrn(s))
71 }
72 }
73
74 pub fn as_str(&self) -> &str {
75 &self.0 .0
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub struct RequestId(Uuid);
82
83impl RequestId {
84 #[cfg(feature = "v4")]
85 pub fn new() -> Self {
86 Self(Uuid::new_v4())
87 }
88
89 pub fn from_uuid(uuid: Uuid) -> Self {
90 Self(uuid)
91 }
92
93 pub fn nil() -> Self {
94 Self(Uuid::nil())
95 }
96
97 pub fn as_bytes(&self) -> &[u8] {
98 self.0.as_bytes()
99 }
100}
101
102impl Default for RequestId {
103 fn default() -> Self {
104 Self::nil()
105 }
106}
107
108impl core::fmt::Display for RequestId {
109 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110 write!(f, "{}", self.0)
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116pub struct LoanId(Uuid);
117
118impl LoanId {
119 #[cfg(feature = "v4")]
120 pub fn new() -> Self {
121 Self(Uuid::new_v4())
122 }
123
124 pub fn from_uuid(uuid: Uuid) -> Self {
125 Self(uuid)
126 }
127
128 pub fn nil() -> Self {
129 Self(Uuid::nil())
130 }
131
132 pub fn as_bytes(&self) -> &[u8] {
133 self.0.as_bytes()
134 }
135}
136
137impl Default for LoanId {
138 fn default() -> Self {
139 Self::nil()
140 }
141}
142
143impl core::fmt::Display for LoanId {
144 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
145 write!(f, "{}", self.0)
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub struct ClientId(Uuid);
152
153impl ClientId {
154 #[cfg(feature = "v4")]
155 pub fn new() -> Self {
156 Self(Uuid::new_v4())
157 }
158
159 pub fn from_uuid(uuid: Uuid) -> Self {
160 Self(uuid)
161 }
162
163 pub fn nil() -> Self {
164 Self(Uuid::nil())
165 }
166
167 pub fn as_bytes(&self) -> &[u8] {
168 self.0.as_bytes()
169 }
170}
171
172impl Default for ClientId {
173 fn default() -> Self {
174 Self::nil()
175 }
176}
177
178pub fn blake3_hash(data: &[u8]) -> [u8; 32] {
182 *blake3::hash(data).as_bytes()
183}
184
185pub fn blake3_chain(parts: &[&[u8]]) -> [u8; 32] {
188 let mut hasher = blake3::Hasher::new();
189 for part in parts {
190 hasher.update(part);
191 }
192 *hasher.finalize().as_bytes()
193}
194
195pub trait Hashable {
197 fn content_hash(&self) -> [u8; 32];
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct AuditEntry {
206 pub request_id: RequestId,
207 pub parent_hash: [u8; 32],
208 pub payload_hash: [u8; 32],
209 pub entry_hash: [u8; 32],
210 pub seal: [u8; 32],
211 pub created_at_micros: u64,
212 pub processing_time_micros: u64,
213 pub operator_binding_hash: [u8; 32],
215 pub session_nonce: [u8; 32],
217}
218
219pub const SEAL_DOMAIN_PREFIX: &[u8; 12] = b"NORM_SEAL_V1";
222
223#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
227pub struct SealInput {
228 pub version: u32,
229 pub nonce: [u8; 32],
230 pub request_hash: [u8; 32],
231 pub result_hash: [u8; 32],
232 pub chain_head_pre: [u8; 32],
233}
234
235impl SealInput {
236 pub fn new_v1(
237 nonce: [u8; 32],
238 request_hash: [u8; 32],
239 result_hash: [u8; 32],
240 chain_head_pre: [u8; 32],
241 ) -> Self {
242 Self {
243 version: 1,
244 nonce,
245 request_hash,
246 result_hash,
247 chain_head_pre,
248 }
249 }
250
251 pub fn compute_seal(&self) -> [u8; 32] {
252 blake3_chain(&[
253 SEAL_DOMAIN_PREFIX,
254 &self.version.to_le_bytes(),
255 &self.nonce,
256 &self.request_hash,
257 &self.result_hash,
258 &self.chain_head_pre,
259 ])
260 }
261}
262
263impl AuditEntry {
264 pub fn genesis(timestamp_micros: u64, payload: &[u8], nonce: Option<&[u8; 32]>) -> Self {
265 let parent_hash = [0u8; 32];
266 let payload_hash = blake3_hash(payload);
267 let nonce_val = nonce.cloned().unwrap_or([0u8; 32]);
268
269 let seal_input = SealInput::new_v1(
272 nonce_val,
273 payload_hash,
274 payload_hash, parent_hash,
276 );
277 let seal = seal_input.compute_seal();
278 let entry_hash = blake3_chain(&[&parent_hash, &payload_hash, &payload_hash, &seal]);
279
280 Self {
281 request_id: RequestId::nil(),
282 parent_hash,
283 payload_hash,
284 entry_hash,
285 seal,
286 created_at_micros: timestamp_micros,
287 processing_time_micros: 0,
288 operator_binding_hash: [0u8; 32],
289 session_nonce: nonce_val,
290 }
291 }
292
293 pub fn next(&self, timestamp_micros: u64, payload: &[u8], nonce: Option<&[u8; 32]>) -> Self {
294 let payload_hash = blake3_hash(payload);
295 let nonce_val = nonce.cloned().unwrap_or(self.session_nonce);
296
297 let seal_input = SealInput::new_v1(
299 nonce_val,
300 payload_hash,
301 payload_hash, self.entry_hash,
303 );
304 let seal = seal_input.compute_seal();
305
306 let entry_hash = blake3_chain(&[&self.entry_hash, &payload_hash, &payload_hash, &seal]);
307
308 Self {
309 request_id: RequestId::nil(),
310 parent_hash: self.entry_hash,
311 payload_hash,
312 entry_hash,
313 seal,
314 created_at_micros: timestamp_micros,
315 processing_time_micros: 0,
316 operator_binding_hash: self.operator_binding_hash,
317 session_nonce: nonce_val,
318 }
319 }
320
321 pub fn verify_chain(&self, parent: &AuditEntry) -> bool {
322 self.parent_hash == parent.entry_hash
323 }
324
325 pub fn hash_hex(&self) -> String {
326 let mut s = String::with_capacity(64);
327 for byte in self.entry_hash {
328 let _ = core::fmt::write(&mut s, format_args!("{:02x}", byte));
329 }
330 s
331 }
332}
333
334#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
337pub enum FraudSign {
338 ReceiverInDatabase,
339 DeviceInDatabase,
340 AtypicalTransaction,
341 SuspiciousSbpTransfer,
342 SuspiciousNfcActivity,
343 MultipleAccountsFromSingleDevice,
344 InconsistentGeolocation,
345 HighVelocityTransfersInShortWindow,
346 RemoteAccessToolDetected,
347 KnownProxyOrVpnEndpoint,
348 SocialEngineeringPatternDetected,
349 ExternalOperatorSignal,
351 Other(String),
352}
353
354#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
355pub struct ComplianceReport {
356 pub transaction_id: String,
357 pub pdn_ratio: Decimal,
358 pub is_pdn_risky: bool,
359 pub fraud_signs: alloc::vec::Vec<FraudSign>,
360 pub recommendation: String,
361 pub created_at_micros: u64,
362}
363
364impl PartialOrd for ComplianceReport {
365 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
366 Some(self.cmp(other))
367 }
368}
369
370impl Ord for ComplianceReport {
371 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
372 self.transaction_id.cmp(&other.transaction_id)
373 }
374}
375
376#[cfg(feature = "gost-export")]
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct ExportableAuditRoot {
384 pub blake3_root: [u8; 32],
386 pub streebog_root: [u8; 32],
388}
389
390#[cfg(feature = "gost-export")]
391#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
392pub enum AuditSignatureAlgorithm {
393 Blake3DetachedV1,
394}
395
396#[cfg(feature = "gost-export")]
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct SignedAuditExportEnvelope {
399 pub algorithm: AuditSignatureAlgorithm,
400 pub entry_hash: [u8; 32],
401 pub seal: [u8; 32],
402 pub blake3_root: [u8; 32],
403 pub streebog_root: [u8; 32],
404 pub operator_binding_hash: [u8; 32],
405 pub session_nonce: [u8; 32],
406 pub processing_time_micros: u64,
407 pub signature: [u8; 32],
408}
409
410#[cfg(feature = "gost-export")]
411#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
412pub enum AuditExportError {
413 #[error("invalid audit entry hash")]
414 InvalidEntryHash,
415 #[error("invalid seal")]
416 InvalidSeal,
417}
418
419#[cfg(feature = "gost-export")]
423pub fn audit_root_gost_export(blake3_root: &[u8; 32]) -> ExportableAuditRoot {
424 use streebog::{Digest, Streebog256};
425 let streebog_bytes: [u8; 32] = Streebog256::digest(blake3_root).into();
426 ExportableAuditRoot {
427 blake3_root: *blake3_root,
428 streebog_root: streebog_bytes,
429 }
430}
431
432#[cfg(feature = "gost-export")]
433pub fn build_signed_audit_export(
434 entry: &AuditEntry,
435) -> Result<SignedAuditExportEnvelope, AuditExportError> {
436 if entry.entry_hash == [0u8; 32] {
437 return Err(AuditExportError::InvalidEntryHash);
438 }
439 if entry.seal == [0u8; 32] {
440 return Err(AuditExportError::InvalidSeal);
441 }
442
443 let roots = audit_root_gost_export(&entry.entry_hash);
444 let signature = blake3_chain(&[
445 b"NORM_EXPORT_SIG_V1",
446 &entry.entry_hash,
447 &entry.seal,
448 &roots.streebog_root,
449 &entry.operator_binding_hash,
450 &entry.session_nonce,
451 &entry.processing_time_micros.to_le_bytes(),
452 ]);
453
454 Ok(SignedAuditExportEnvelope {
455 algorithm: AuditSignatureAlgorithm::Blake3DetachedV1,
456 entry_hash: entry.entry_hash,
457 seal: entry.seal,
458 blake3_root: roots.blake3_root,
459 streebog_root: roots.streebog_root,
460 operator_binding_hash: entry.operator_binding_hash,
461 session_nonce: entry.session_nonce,
462 processing_time_micros: entry.processing_time_micros,
463 signature,
464 })
465}
466
467#[cfg(feature = "gost-export")]
469impl ExportableAuditRoot {
470 pub fn streebog_hex(&self) -> String {
471 let mut s = String::with_capacity(64);
472 for byte in self.streebog_root {
473 let _ = core::fmt::write(&mut s, format_args!("{:02x}", byte));
474 }
475 s
476 }
477}
478
479pub fn round_financial(d: Decimal) -> Decimal {
484 d.round_dp(2)
485}
486
487pub fn safe_div(numerator: Decimal, denominator: Decimal) -> Decimal {
489 if denominator.is_zero() {
490 Decimal::ZERO
491 } else {
492 numerator / denominator
493 }
494}
495
496#[derive(Debug, thiserror::Error)]
499pub enum RfeError {
500 #[error("Invalid INN format: {0}")]
501 InvalidInn(String),
502 #[error("Invalid OGRN format: {0}")]
503 InvalidOgrn(String),
504 #[error("Decimal parse error: {0}")]
505 DecimalParse(String),
506}
507
508#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::alloc::string::ToString;
514
515 #[test]
516 fn inn_valid_10_digit() {
517 let inn = Inn::parse("7700000000").unwrap();
518 assert_eq!(inn.as_str(), "7700000000");
519 }
520
521 #[test]
522 fn inn_valid_12_digit() {
523 let inn = Inn::parse("770000000001").unwrap();
524 assert_eq!(inn.as_str(), "770000000001");
525 }
526
527 #[test]
528 fn inn_invalid_rejects() {
529 assert!(Inn::parse("123").is_err());
530 assert!(Inn::parse("12345678901234").is_err());
531 }
532
533 #[test]
534 fn ogrn_valid_13() {
535 assert!(Ogrn::parse("1027700000")
536 .or(Ogrn::parse("1027700000001"))
537 .is_ok());
538 }
539
540 #[test]
541 fn blake3_chain_deterministic() {
542 let h1 = blake3_chain(&[b"hello", b"world"]);
543 let h2 = blake3_chain(&[b"hello", b"world"]);
544 assert_eq!(h1, h2);
545 let h3 = blake3_chain(&[b"world", b"hello"]);
546 assert_ne!(h1, h3);
547 }
548
549 #[test]
550 fn audit_chain_verifies() {
551 let genesis = AuditEntry::genesis(0, b"genesis payload", None);
552 let next = genesis.next(100, b"test payload", None);
553 assert!(next.verify_chain(&genesis));
554 let mut tampered = next.clone();
556 tampered.parent_hash = [0xff; 32];
557 assert!(!tampered.verify_chain(&genesis));
558 }
559
560 #[test]
561 fn safe_div_zero_denominator() {
562 assert_eq!(safe_div(Decimal::new(100, 0), Decimal::ZERO), Decimal::ZERO);
563 }
564
565 #[test]
566 fn round_financial_banker() {
567 let d = Decimal::new(1005, 3); let rounded = round_financial(d);
570 assert_eq!(rounded.to_string(), "1.00");
571 }
572}