Skip to main content

rfe_types/
lib.rs

1//! Shared types for Rust Fintech Ecosystem (RFE).
2//!
3//! Provides `newtype` identifiers, Blake3 hashing helpers, and `Decimal`
4//! re-exports used across RFE crates. No I/O, no async.
5
6#![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// ---- Newtype IDs -------------------------------------------------------
21
22/// Wrapper to prevent PII leakage in logs.
23#[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/// Russian taxpayer identification number (INN), 10 or 12 digits.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct Inn(pub Sensitive<String>);
36
37impl Inn {
38    /// Create without validation (use `parse` for validated construction).
39    pub fn new_unchecked(s: impl Into<String>) -> Self {
40        Self(Sensitive(s.into()))
41    }
42
43    /// Validates INN length (10 for legal entities, 12 for individuals).
44    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/// Russian OGRN — primary state registration number, 13 or 15 digits.
60#[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/// Unique request/correlation identifier (UUID v4).
80#[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/// Loan/order identifier.
115#[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/// Client/customer identifier.
150#[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
178// ---- Blake3 Hashing ----------------------------------------------------
179
180/// Compute blake3 hash of a single byte slice.
181pub fn blake3_hash(data: &[u8]) -> [u8; 32] {
182    *blake3::hash(data).as_bytes()
183}
184
185/// Iterated blake3 hash over multiple byte slices (in order).
186/// Matches the pattern from zerocore-gateway/common for audit chaining.
187pub 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
195/// Any type that can produce a deterministic content hash.
196pub trait Hashable {
197    fn content_hash(&self) -> [u8; 32];
198}
199
200// ---- Audit Entry -------------------------------------------------------
201
202/// Append-only audit entry — blake3 chained for tamper evidence.
203/// Clean-room of LedgerEntry from zerocore-gateway/audit-ledger.
204#[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    /// Operator identity binding hash (deterministic, non-PII).
214    pub operator_binding_hash: [u8; 32],
215    /// Progressive Attestation: operator session binding.
216    pub session_nonce: [u8; 32],
217}
218
219/// NORM Protocol v1 Seal Input.
220/// Prefix: "NORM_SEAL_V1" (12 bytes)
221pub const SEAL_DOMAIN_PREFIX: &[u8; 12] = b"NORM_SEAL_V1";
222
223/// Frozen in `rfe-types` v0.1.0 for TrustBox compatibility.
224/// Breaking field changes require a semver-major release.
225/// Canonical fields: `nonce`, `request_hash`, `result_hash`, `chain_head_pre`.
226#[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        // Protocol v1: Evolution = blake3(head_pre || req_hash || res_hash || seal)
270        // For genesis, seal is computed from provided nonce and payload.
271        let seal_input = SealInput::new_v1(
272            nonce_val,
273            payload_hash,
274            payload_hash, // Simplified for legacy genesis; in actual TB it binds results
275            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        // Protocol v1 evolution: next_head = blake3(head_pre || req_hash || res_hash || seal)
298        let seal_input = SealInput::new_v1(
299            nonce_val,
300            payload_hash,
301            payload_hash, // Placeholder
302            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// ---- Canonical Protocol Types ------------------------------------------
335
336#[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    /// Signal received from another payment system operator (OD-2506 sign 12).
350    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// ---- GOST Export (optional) --------------------------------------------
377
378/// Dual-hash audit root for regulatory export (GOST R 34.11-2012 / Streebog-256).
379/// The blake3_root is the internal chain root; streebog_root is its Streebog-256 re-hash
380/// required for CBR submissions and SMEV 4 payload signatures.
381#[cfg(feature = "gost-export")]
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct ExportableAuditRoot {
384    /// Internal BLAKE3 chain root (high-speed, no_std).
385    pub blake3_root: [u8; 32],
386    /// GOST R 34.11-2012 (Streebog-256) re-hash of blake3_root for regulatory submissions.
387    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/// Produce an ExportableAuditRoot from an existing BLAKE3 chain root.
420/// This is the only function in the codebase that computes Streebog-256.
421/// Call exclusively at the export boundary (SMEV 4 payload, CBR submission header).
422#[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/// Hex-encode a 32-byte hash to a stack-allocated String (no_std compatible).
468#[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
479// ---- Decimal Utilities -------------------------------------------------
480
481/// Round to 2 decimal places using banker's rounding (HALF_EVEN).
482/// Used for financial calculations per CBR requirements.
483pub fn round_financial(d: Decimal) -> Decimal {
484    d.round_dp(2)
485}
486
487/// Safe division returning zero instead of panicking on zero denominator.
488pub 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// ---- Errors ------------------------------------------------------------
497
498#[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// ---- Tests -------------------------------------------------------------
509
510#[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        // Tampered parent fails
555        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        // 1.005 rounds to 1.00 with HALF_EVEN (banker's rounding)
568        let d = Decimal::new(1005, 3); // 1.005
569        let rounded = round_financial(d);
570        assert_eq!(rounded.to_string(), "1.00");
571    }
572}