#![no_std]
extern crate alloc;
use alloc::string::String;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use rust_decimal;
pub mod hashing;
pub mod test_vectors;
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Sensitive<T>(pub T);
impl<T> core::fmt::Debug for Sensitive<T> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "[REDACTED PII]")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Inn(pub Sensitive<String>);
impl Inn {
pub fn new_unchecked(s: impl Into<String>) -> Self {
Self(Sensitive(s.into()))
}
pub fn parse(s: impl Into<String>) -> Result<Self, RfeError> {
let s = s.into();
let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() == 10 || digits.len() == 12 {
Ok(Self(Sensitive(digits)))
} else {
Err(RfeError::InvalidInn(s))
}
}
pub fn as_str(&self) -> &str {
&self.0 .0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Ogrn(pub Sensitive<String>);
impl Ogrn {
pub fn parse(s: impl Into<String>) -> Result<Self, RfeError> {
let s = s.into();
let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() == 13 || digits.len() == 15 {
Ok(Self(Sensitive(digits)))
} else {
Err(RfeError::InvalidOgrn(s))
}
}
pub fn as_str(&self) -> &str {
&self.0 .0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RequestId(Uuid);
impl RequestId {
#[cfg(feature = "v4")]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn nil() -> Self {
Self(Uuid::nil())
}
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl Default for RequestId {
fn default() -> Self {
Self::nil()
}
}
impl core::fmt::Display for RequestId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LoanId(Uuid);
impl LoanId {
#[cfg(feature = "v4")]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn nil() -> Self {
Self(Uuid::nil())
}
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl Default for LoanId {
fn default() -> Self {
Self::nil()
}
}
impl core::fmt::Display for LoanId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ClientId(Uuid);
impl ClientId {
#[cfg(feature = "v4")]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn nil() -> Self {
Self(Uuid::nil())
}
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl Default for ClientId {
fn default() -> Self {
Self::nil()
}
}
pub fn blake3_hash(data: &[u8]) -> [u8; 32] {
*blake3::hash(data).as_bytes()
}
pub fn blake3_chain(parts: &[&[u8]]) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
for part in parts {
hasher.update(part);
}
*hasher.finalize().as_bytes()
}
pub trait Hashable {
fn content_hash(&self) -> [u8; 32];
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub request_id: RequestId,
pub parent_hash: [u8; 32],
pub payload_hash: [u8; 32],
pub entry_hash: [u8; 32],
pub seal: [u8; 32],
pub created_at_micros: u64,
pub processing_time_micros: u64,
pub operator_binding_hash: [u8; 32],
pub session_nonce: [u8; 32],
}
pub const SEAL_DOMAIN_PREFIX: &[u8; 12] = b"NORM_SEAL_V1";
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SealInput {
pub version: u32,
pub nonce: [u8; 32],
pub request_hash: [u8; 32],
pub result_hash: [u8; 32],
pub chain_head_pre: [u8; 32],
}
impl SealInput {
pub fn new_v1(
nonce: [u8; 32],
request_hash: [u8; 32],
result_hash: [u8; 32],
chain_head_pre: [u8; 32],
) -> Self {
Self {
version: 1,
nonce,
request_hash,
result_hash,
chain_head_pre,
}
}
pub fn compute_seal(&self) -> [u8; 32] {
blake3_chain(&[
SEAL_DOMAIN_PREFIX,
&self.version.to_le_bytes(),
&self.nonce,
&self.request_hash,
&self.result_hash,
&self.chain_head_pre,
])
}
}
impl AuditEntry {
pub fn genesis(timestamp_micros: u64, payload: &[u8], nonce: Option<&[u8; 32]>) -> Self {
let parent_hash = [0u8; 32];
let payload_hash = blake3_hash(payload);
let nonce_val = nonce.cloned().unwrap_or([0u8; 32]);
let seal_input = SealInput::new_v1(
nonce_val,
payload_hash,
payload_hash, parent_hash,
);
let seal = seal_input.compute_seal();
let entry_hash = blake3_chain(&[&parent_hash, &payload_hash, &payload_hash, &seal]);
Self {
request_id: RequestId::nil(),
parent_hash,
payload_hash,
entry_hash,
seal,
created_at_micros: timestamp_micros,
processing_time_micros: 0,
operator_binding_hash: [0u8; 32],
session_nonce: nonce_val,
}
}
pub fn next(&self, timestamp_micros: u64, payload: &[u8], nonce: Option<&[u8; 32]>) -> Self {
let payload_hash = blake3_hash(payload);
let nonce_val = nonce.cloned().unwrap_or(self.session_nonce);
let seal_input = SealInput::new_v1(
nonce_val,
payload_hash,
payload_hash, self.entry_hash,
);
let seal = seal_input.compute_seal();
let entry_hash = blake3_chain(&[&self.entry_hash, &payload_hash, &payload_hash, &seal]);
Self {
request_id: RequestId::nil(),
parent_hash: self.entry_hash,
payload_hash,
entry_hash,
seal,
created_at_micros: timestamp_micros,
processing_time_micros: 0,
operator_binding_hash: self.operator_binding_hash,
session_nonce: nonce_val,
}
}
pub fn verify_chain(&self, parent: &AuditEntry) -> bool {
self.parent_hash == parent.entry_hash
}
pub fn hash_hex(&self) -> String {
let mut s = String::with_capacity(64);
for byte in self.entry_hash {
let _ = core::fmt::write(&mut s, format_args!("{:02x}", byte));
}
s
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FraudSign {
ReceiverInDatabase,
DeviceInDatabase,
AtypicalTransaction,
SuspiciousSbpTransfer,
SuspiciousNfcActivity,
MultipleAccountsFromSingleDevice,
InconsistentGeolocation,
HighVelocityTransfersInShortWindow,
RemoteAccessToolDetected,
KnownProxyOrVpnEndpoint,
SocialEngineeringPatternDetected,
ExternalOperatorSignal,
Other(String),
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ComplianceReport {
pub transaction_id: String,
pub pdn_ratio: Decimal,
pub is_pdn_risky: bool,
pub fraud_signs: alloc::vec::Vec<FraudSign>,
pub recommendation: String,
pub created_at_micros: u64,
}
impl PartialOrd for ComplianceReport {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ComplianceReport {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
self.transaction_id.cmp(&other.transaction_id)
}
}
#[cfg(feature = "gost-export")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportableAuditRoot {
pub blake3_root: [u8; 32],
pub streebog_root: [u8; 32],
}
#[cfg(feature = "gost-export")]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum AuditSignatureAlgorithm {
Blake3DetachedV1,
}
#[cfg(feature = "gost-export")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedAuditExportEnvelope {
pub algorithm: AuditSignatureAlgorithm,
pub entry_hash: [u8; 32],
pub seal: [u8; 32],
pub blake3_root: [u8; 32],
pub streebog_root: [u8; 32],
pub operator_binding_hash: [u8; 32],
pub session_nonce: [u8; 32],
pub processing_time_micros: u64,
pub signature: [u8; 32],
}
#[cfg(feature = "gost-export")]
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
pub enum AuditExportError {
#[error("invalid audit entry hash")]
InvalidEntryHash,
#[error("invalid seal")]
InvalidSeal,
}
#[cfg(feature = "gost-export")]
pub fn audit_root_gost_export(blake3_root: &[u8; 32]) -> ExportableAuditRoot {
use streebog::{Digest, Streebog256};
let streebog_bytes: [u8; 32] = Streebog256::digest(blake3_root).into();
ExportableAuditRoot {
blake3_root: *blake3_root,
streebog_root: streebog_bytes,
}
}
#[cfg(feature = "gost-export")]
pub fn build_signed_audit_export(
entry: &AuditEntry,
) -> Result<SignedAuditExportEnvelope, AuditExportError> {
if entry.entry_hash == [0u8; 32] {
return Err(AuditExportError::InvalidEntryHash);
}
if entry.seal == [0u8; 32] {
return Err(AuditExportError::InvalidSeal);
}
let roots = audit_root_gost_export(&entry.entry_hash);
let signature = blake3_chain(&[
b"NORM_EXPORT_SIG_V1",
&entry.entry_hash,
&entry.seal,
&roots.streebog_root,
&entry.operator_binding_hash,
&entry.session_nonce,
&entry.processing_time_micros.to_le_bytes(),
]);
Ok(SignedAuditExportEnvelope {
algorithm: AuditSignatureAlgorithm::Blake3DetachedV1,
entry_hash: entry.entry_hash,
seal: entry.seal,
blake3_root: roots.blake3_root,
streebog_root: roots.streebog_root,
operator_binding_hash: entry.operator_binding_hash,
session_nonce: entry.session_nonce,
processing_time_micros: entry.processing_time_micros,
signature,
})
}
#[cfg(feature = "gost-export")]
impl ExportableAuditRoot {
pub fn streebog_hex(&self) -> String {
let mut s = String::with_capacity(64);
for byte in self.streebog_root {
let _ = core::fmt::write(&mut s, format_args!("{:02x}", byte));
}
s
}
}
pub fn round_financial(d: Decimal) -> Decimal {
d.round_dp(2)
}
pub fn safe_div(numerator: Decimal, denominator: Decimal) -> Decimal {
if denominator.is_zero() {
Decimal::ZERO
} else {
numerator / denominator
}
}
#[derive(Debug, thiserror::Error)]
pub enum RfeError {
#[error("Invalid INN format: {0}")]
InvalidInn(String),
#[error("Invalid OGRN format: {0}")]
InvalidOgrn(String),
#[error("Decimal parse error: {0}")]
DecimalParse(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alloc::string::ToString;
#[test]
fn inn_valid_10_digit() {
let inn = Inn::parse("7700000000").unwrap();
assert_eq!(inn.as_str(), "7700000000");
}
#[test]
fn inn_valid_12_digit() {
let inn = Inn::parse("770000000001").unwrap();
assert_eq!(inn.as_str(), "770000000001");
}
#[test]
fn inn_invalid_rejects() {
assert!(Inn::parse("123").is_err());
assert!(Inn::parse("12345678901234").is_err());
}
#[test]
fn ogrn_valid_13() {
assert!(Ogrn::parse("1027700000")
.or(Ogrn::parse("1027700000001"))
.is_ok());
}
#[test]
fn blake3_chain_deterministic() {
let h1 = blake3_chain(&[b"hello", b"world"]);
let h2 = blake3_chain(&[b"hello", b"world"]);
assert_eq!(h1, h2);
let h3 = blake3_chain(&[b"world", b"hello"]);
assert_ne!(h1, h3);
}
#[test]
fn audit_chain_verifies() {
let genesis = AuditEntry::genesis(0, b"genesis payload", None);
let next = genesis.next(100, b"test payload", None);
assert!(next.verify_chain(&genesis));
let mut tampered = next.clone();
tampered.parent_hash = [0xff; 32];
assert!(!tampered.verify_chain(&genesis));
}
#[test]
fn safe_div_zero_denominator() {
assert_eq!(safe_div(Decimal::new(100, 0), Decimal::ZERO), Decimal::ZERO);
}
#[test]
fn round_financial_banker() {
let d = Decimal::new(1005, 3); let rounded = round_financial(d);
assert_eq!(rounded.to_string(), "1.00");
}
}