SELF-808: Implement document camera MRZ demo screen (#1204)

This commit is contained in:
Justin Hernandez
2025-10-06 03:16:42 -07:00
committed by GitHub
parent bea9b7eff5
commit e4dab8b820
29 changed files with 1293 additions and 798 deletions

View File

@@ -117,7 +117,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sort-exports": "^0.9.1",
"jsdom": "^24.0.0",
"jsdom": "^25.0.1",
"prettier": "^3.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -126,7 +126,7 @@
"tamagui": "1.126.14",
"tsup": "^8.0.1",
"typescript": "^5.9.2",
"vitest": "^1.6.0"
"vitest": "^2.1.8"
},
"peerDependencies": {
"react": "^18.3.1",

View File

@@ -4,8 +4,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { DocumentCatalog, DocumentMetadata } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import HomeScreen from './src/screens/HomeScreen';

View File

@@ -1,183 +0,0 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { describe, expect, it } from 'vitest';
import { computeHmac, pbkdf2, randomBytes, sha256, sha512 } from '../src/utils/ethers';
describe('Crypto Polyfills', () => {
describe('randomBytes', () => {
it('should generate random bytes of specified length', () => {
const bytes = randomBytes(32);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(32);
});
it('should generate different random bytes on each call', () => {
const bytes1 = randomBytes(16);
const bytes2 = randomBytes(16);
expect(bytes1).not.toEqual(bytes2);
});
it('should handle different lengths', () => {
const bytes8 = randomBytes(8);
const bytes64 = randomBytes(64);
expect(bytes8.length).toBe(8);
expect(bytes64.length).toBe(64);
});
});
describe('sha256', () => {
it('should hash data correctly', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = sha256(data);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(32); // SHA-256 produces 32 bytes
});
it('should produce consistent hashes for same input', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash1 = sha256(data);
const hash2 = sha256(data);
expect(hash1).toEqual(hash2);
});
it('should produce different hashes for different inputs', () => {
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
const hash1 = sha256(data1);
const hash2 = sha256(data2);
expect(hash1).not.toEqual(hash2);
});
});
describe('sha512', () => {
it('should hash data correctly', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = sha512(data);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(64); // SHA-512 produces 64 bytes
});
it('should produce consistent hashes for same input', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash1 = sha512(data);
const hash2 = sha512(data);
expect(hash1).toEqual(hash2);
});
it('should produce different hashes for different inputs', () => {
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
const hash1 = sha512(data1);
const hash2 = sha512(data2);
expect(hash1).not.toEqual(hash2);
});
});
describe('computeHmac', () => {
it('should compute HMAC-SHA256 correctly', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac = computeHmac('sha256', key, data);
expect(hmac).toBeInstanceOf(Uint8Array);
expect(hmac.length).toBe(32); // HMAC-SHA256 produces 32 bytes
});
it('should compute HMAC-SHA512 correctly', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac = computeHmac('sha512', key, data);
expect(hmac).toBeInstanceOf(Uint8Array);
expect(hmac.length).toBe(64); // HMAC-SHA512 produces 64 bytes
});
it('should produce consistent HMAC for same inputs', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac1 = computeHmac('sha256', key, data);
const hmac2 = computeHmac('sha256', key, data);
expect(hmac1).toEqual(hmac2);
});
it('should produce different HMAC for different keys', () => {
const key1 = new Uint8Array([1, 2, 3, 4]);
const key2 = new Uint8Array([1, 2, 3, 5]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac1 = computeHmac('sha256', key1, data);
const hmac2 = computeHmac('sha256', key2, data);
expect(hmac1).not.toEqual(hmac2);
});
});
describe('pbkdf2', () => {
it('should derive key using PBKDF2-SHA256', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key = pbkdf2(password, salt, 1000, 32, 'sha256');
expect(key).toBeInstanceOf(Uint8Array);
expect(key.length).toBe(32);
});
it('should derive key using PBKDF2-SHA512', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key = pbkdf2(password, salt, 1000, 64, 'sha512');
expect(key).toBeInstanceOf(Uint8Array);
expect(key.length).toBe(64);
});
it('should produce consistent keys for same inputs', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt, 1000, 32, 'sha256');
expect(key1).toEqual(key2);
});
it('should produce different keys for different salts', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt1 = new Uint8Array([5, 6, 7, 8]);
const salt2 = new Uint8Array([5, 6, 7, 9]);
const key1 = pbkdf2(password, salt1, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt2, 1000, 32, 'sha256');
expect(key1).not.toEqual(key2);
});
it('should handle different iteration counts', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt, 2000, 32, 'sha256');
expect(key1).not.toEqual(key2);
});
});
describe('ethers integration', () => {
it('should have ethers.randomBytes registered', () => {
// This test verifies that ethers.js is using our polyfill
const { ethers } = require('ethers');
expect(typeof ethers.randomBytes).toBe('function');
const bytes = ethers.randomBytes(16);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(16);
});
});
});

View File

@@ -2,6 +2,7 @@
"name": "mobile-sdk-demo",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.js",
"scripts": {
"analyze:bundle:android": "yarn prebuild && node scripts/bundle-analyze-ci.cjs android",
@@ -9,7 +10,7 @@
"preandroid": "yarn prebuild",
"android": "react-native run-android --verbose",
"prebuild": "yarn workspace @selfxyz/mobile-sdk-alpha build",
"build": "tsc -p tsconfig.json --noEmit --pretty false",
"build": "yarn prebuild && tsc -p tsconfig.json --noEmit --pretty false",
"clean": "rm -rf ios/build android/app/build android/build && cd android && ./gradlew clean && cd ..",
"format": "prettier --write .",
"ia": "yarn install-app",
@@ -23,7 +24,7 @@
"start": "react-native start",
"test": "vitest run",
"test:watch": "vitest",
"types": "tsc --noEmit"
"types": "yarn prebuild && tsc --noEmit"
},
"dependencies": {
"@babel/runtime": "^7.28.3",

View File

@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { buildValidationRows, humanizeDocumentType, type NormalizedMRZResult } from '../utils/camera';
interface Props {
result: NormalizedMRZResult;
}
export default function DocumentScanResultCard({ result }: Props) {
const validationRows = useMemo(() => buildValidationRows(result.info.validation), [result]);
return (
<View style={styles.resultCard} accessible accessibilityRole="summary">
<Text style={styles.resultTitle}>Scan summary</Text>
<View style={styles.resultRow}>
<Text style={styles.resultLabel}>Document number</Text>
<Text style={styles.resultValue}>{result.info.documentNumber}</Text>
</View>
<View style={styles.resultRow}>
<Text style={styles.resultLabel}>Document type</Text>
<Text style={styles.resultValue}>{humanizeDocumentType(result.info.documentType)}</Text>
</View>
<View style={styles.resultRow}>
<Text style={styles.resultLabel}>Issuing country</Text>
<Text style={styles.resultValue}>{result.info.issuingCountry || 'Unknown'}</Text>
</View>
<View style={styles.resultRow}>
<Text style={styles.resultLabel}>Date of birth</Text>
<Text style={styles.resultValue}>{result.readableBirthDate}</Text>
</View>
<View style={styles.resultRow}>
<Text style={styles.resultLabel}>Expiry date</Text>
<Text style={styles.resultValue}>{result.readableExpiryDate}</Text>
</View>
<View style={styles.validationSection}>
<Text style={styles.validationTitle}>Validation checks</Text>
{validationRows ? (
validationRows.map(row => (
<View key={row.label} style={styles.validationRow}>
<Text style={styles.validationLabel}>{row.label}</Text>
<Text
style={[styles.validationBadge, row.value ? styles.validationPass : styles.validationFail]}
accessibilityRole="text"
>
{row.value ? '✓ Pass' : '✗ Fail'}
</Text>
</View>
))
) : (
<Text style={styles.validationPlaceholder}>Validation details are not available for this scan yet.</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
resultCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
borderWidth: 1,
borderColor: '#e2e8f0',
marginBottom: 16,
shadowColor: '#0f172a',
shadowOpacity: 0.08,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 6,
elevation: 2,
},
resultTitle: {
fontSize: 16,
fontWeight: '700',
color: '#0f172a',
marginBottom: 12,
},
resultRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
resultLabel: {
color: '#334155',
fontWeight: '500',
fontSize: 14,
},
resultValue: {
color: '#0f172a',
fontSize: 14,
fontWeight: '600',
},
validationSection: {
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#e2e8f0',
},
validationTitle: {
fontSize: 15,
fontWeight: '600',
color: '#0f172a',
marginBottom: 8,
},
validationRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
validationLabel: {
color: '#1f2937',
fontSize: 14,
flex: 1,
marginRight: 12,
},
validationBadge: {
minWidth: 90,
textAlign: 'center',
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 999,
fontWeight: '600',
fontSize: 12,
color: '#ffffff',
},
validationPass: {
backgroundColor: '#16a34a',
},
validationFail: {
backgroundColor: '#b91c1c',
},
validationPlaceholder: {
color: '#475569',
fontSize: 13,
fontStyle: 'italic',
},
});

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { updateAfterDelete } from '../lib/catalog';

View File

@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AccessibilityInfo, PermissionsAndroid, Platform } from 'react-native';
import type { MRZInfo } from '@selfxyz/mobile-sdk-alpha';
import { useReadMRZ } from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz';
import { normalizeMRZPayload, type NormalizedMRZResult } from '../utils/camera';
type PermissionState = 'loading' | 'granted' | 'denied';
type ScanState = 'idle' | 'scanning' | 'success' | 'error';
function announceForAccessibility(message: string) {
if (!message) {
return;
}
try {
AccessibilityInfo.announceForAccessibility?.(message);
} catch {
// Intentionally swallow to avoid crashing accessibility users on announce failures.
}
}
export interface DocumentScannerCopy {
instructions: string;
success: string;
error: string;
permissionDenied: string;
resetAnnouncement: string;
}
export interface DocumentScannerState {
permissionStatus: PermissionState;
scanState: ScanState;
mrzResult: NormalizedMRZResult | null;
error: string | null;
requestPermission: () => Promise<void>;
handleMRZDetected: (payload: MRZInfo) => void;
handleScannerError: (error: string) => void;
handleScanAgain: () => void;
}
export function useMRZScanner(copy: DocumentScannerCopy): DocumentScannerState {
const [permissionStatus, setPermissionStatus] = useState<PermissionState>('loading');
const [scanState, setScanState] = useState<ScanState>('idle');
const [mrzResult, setMrzResult] = useState<NormalizedMRZResult | null>(null);
const [error, setError] = useState<string | null>(null);
const scanStartTimeRef = useRef<number>(Date.now());
const { onPassportRead } = useReadMRZ(scanStartTimeRef);
const requestPermission = useCallback(async () => {
setPermissionStatus('loading');
setError(null);
if (Platform.OS === 'android') {
try {
const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA, {
title: 'Camera permission',
message: 'We need your permission to access the camera for MRZ scanning.',
buttonPositive: 'Allow',
buttonNegative: 'Cancel',
buttonNeutral: 'Ask me later',
});
if (result === PermissionsAndroid.RESULTS.GRANTED) {
setPermissionStatus('granted');
} else {
setPermissionStatus('denied');
}
} catch {
setPermissionStatus('denied');
setError('Camera permission request failed. Please try again.');
}
} else {
setPermissionStatus('granted');
}
}, []);
useEffect(() => {
requestPermission();
}, [requestPermission]);
useEffect(() => {
if (permissionStatus === 'granted') {
announceForAccessibility(copy.instructions);
setScanState(current => {
if (current === 'success') {
return current;
}
scanStartTimeRef.current = Date.now();
return 'scanning';
});
} else if (permissionStatus === 'denied') {
announceForAccessibility(copy.permissionDenied);
setScanState('idle');
}
}, [copy.instructions, copy.permissionDenied, permissionStatus]);
useEffect(() => {
if (scanState === 'success') {
announceForAccessibility(copy.success);
} else if (scanState === 'error') {
announceForAccessibility(copy.error);
}
}, [copy.error, copy.success, scanState]);
useEffect(() => {
if (error) {
announceForAccessibility(error);
}
}, [error]);
const handleMRZDetected = useCallback(
(payload: MRZInfo) => {
setError(null);
setScanState(current => {
if (current === 'success') {
return current;
}
return 'scanning';
});
try {
const normalized = normalizeMRZPayload(payload);
setMrzResult(normalized);
setScanState('success');
onPassportRead(null, normalized.info);
} catch {
setScanState('error');
setError('Unable to validate the MRZ data from the scan.');
}
},
[onPassportRead],
);
const handleScannerError = useCallback((scannerError: string) => {
setScanState('error');
setError(scannerError || 'An unexpected camera error occurred.');
}, []);
const handleScanAgain = useCallback(() => {
if (permissionStatus === 'denied') {
requestPermission();
return;
}
scanStartTimeRef.current = Date.now();
setMrzResult(null);
setError(null);
setScanState('scanning');
announceForAccessibility(copy.resetAnnouncement);
}, [copy.resetAnnouncement, permissionStatus, requestPermission]);
return useMemo(
() => ({
permissionStatus,
scanState,
mrzResult,
error,
requestPermission,
handleMRZDetected,
handleScannerError,
handleScanAgain,
}),
[
error,
handleMRZDetected,
handleScanAgain,
handleScannerError,
mrzResult,
permissionStatus,
requestPermission,
scanState,
],
);
}

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { IDDocument } from '@selfxyz/common/utils/types';
import { SdkEvents, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
export type RegistrationState = {

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog } from '@selfxyz/common/utils/types';
export function updateAfterDelete(catalog: DocumentCatalog, deletedId: string): DocumentCatalog {
const remaining = (catalog.documents || []).filter(doc => doc.id !== deletedId);

View File

@@ -2,26 +2,233 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import React, { useCallback } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import PlaceholderScreen from '../components/PlaceholderScreen';
import { MRZScannerView } from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz';
import ScreenLayout from '../components/ScreenLayout';
import DocumentScanResultCard from '../components/DocumentScanResultCard';
import { useMRZScanner } from '../hooks/useMRZScanner';
type Props = {
onBack: () => void;
};
const instructionsText = 'Align the machine-readable text with the frame and hold steady while we scan.';
const successMessage = 'Document scan successful. Review the details below.';
const errorMessage = 'We could not read your document. Adjust lighting and try again.';
const permissionDeniedMessage = 'Camera access was denied. Enable permissions to scan your document.';
export default function DocumentCamera({ onBack }: Props) {
const scannerCopy = {
instructions: instructionsText,
success: successMessage,
error: errorMessage,
permissionDenied: permissionDeniedMessage,
resetAnnouncement: 'Ready to scan again. Align the document in the viewfinder.',
} as const;
const {
permissionStatus,
scanState,
mrzResult,
error,
requestPermission,
handleMRZDetected,
handleScannerError,
handleScanAgain,
} = useMRZScanner(scannerCopy);
const handleSaveDocument = useCallback(() => {
if (!mrzResult) {
Alert.alert('Save Document', 'Scan a document before attempting to save.');
return;
}
Alert.alert(
'Save Document',
'Document storage will be available in a future release. Your scan is ready when you need it.',
);
}, [mrzResult]);
const renderPermissionDenied = () => (
<View style={styles.centeredState}>
<Text style={styles.permissionText}>{permissionDeniedMessage}</Text>
<TouchableOpacity accessibilityRole="button" style={styles.secondaryButton} onPress={requestPermission}>
<Text style={styles.secondaryButtonText}>Request Permission</Text>
</TouchableOpacity>
</View>
);
const renderLoading = () => (
<View style={styles.centeredState}>
<ActivityIndicator accessibilityLabel="Loading camera" color="#0f172a" />
<Text style={styles.statusText}>Preparing camera</Text>
</View>
);
return (
<PlaceholderScreen
<ScreenLayout
title="Document Camera"
onBack={onBack}
description="This screen would handle camera-based document scanning for passports and ID cards."
features={[
'Camera integration for document scanning',
'MRZ (Machine Readable Zone) detection',
'Document validation and parsing',
'Real-time feedback and guidance',
]}
/>
onBack={() => {
onBack();
}}
contentStyle={styles.screenContent}
rightAction={
<TouchableOpacity accessibilityRole="button" onPress={handleSaveDocument}>
<Text style={styles.headerAction}>Save Document</Text>
</TouchableOpacity>
}
>
{permissionStatus === 'loading' && renderLoading()}
{permissionStatus === 'denied' && renderPermissionDenied()}
{permissionStatus === 'granted' && (
<View style={styles.contentWrapper}>
<View style={styles.cameraWrapper}>
<MRZScannerView style={styles.scanner} onMRZDetected={handleMRZDetected} onError={handleScannerError} />
<View style={styles.overlay} accessibilityLiveRegion="polite" pointerEvents="none">
<Text style={styles.overlayTitle}>Position your document</Text>
<Text style={styles.overlayText}>{instructionsText}</Text>
</View>
</View>
<View style={styles.statusContainer}>
{scanState === 'scanning' && !error && (
<View style={styles.statusRow}>
<ActivityIndicator accessibilityLabel="Scanning" color="#2563eb" size="small" />
<Text style={styles.statusText}>Scanning for MRZ data</Text>
</View>
)}
{scanState === 'success' && mrzResult && (
<Text style={[styles.statusText, styles.successText]}>{successMessage}</Text>
)}
{scanState === 'error' && error && <Text style={[styles.statusText, styles.errorText]}>{error}</Text>}
</View>
{mrzResult && <DocumentScanResultCard result={mrzResult} />}
<View style={styles.actions}>
<TouchableOpacity accessibilityRole="button" onPress={handleScanAgain} style={styles.secondaryButton}>
<Text style={styles.secondaryButtonText}>Scan Again</Text>
</TouchableOpacity>
<TouchableOpacity accessibilityRole="button" onPress={handleSaveDocument} style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Save Document</Text>
</TouchableOpacity>
</View>
</View>
)}
</ScreenLayout>
);
}
const styles = StyleSheet.create({
screenContent: {
gap: 16,
},
contentWrapper: {
flex: 1,
},
cameraWrapper: {
backgroundColor: '#0f172a',
borderRadius: 16,
overflow: 'hidden',
minHeight: 260,
marginBottom: 16,
},
scanner: {
flex: 1,
width: '100%',
height: '100%',
backgroundColor: '#0f172a',
},
overlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(15, 23, 42, 0.75)',
paddingHorizontal: 16,
paddingVertical: 12,
},
overlayTitle: {
color: '#f8fafc',
fontWeight: '600',
fontSize: 16,
marginBottom: 4,
},
overlayText: {
color: '#e2e8f0',
fontSize: 14,
},
statusContainer: {
marginBottom: 16,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statusText: {
color: '#0f172a',
fontSize: 14,
marginTop: 8,
},
successText: {
color: '#15803d',
fontWeight: '600',
},
errorText: {
color: '#b91c1c',
fontWeight: '600',
},
actions: {
flexDirection: 'row',
gap: 12,
},
primaryButton: {
flex: 1,
backgroundColor: '#0f172a',
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontSize: 15,
fontWeight: '600',
},
secondaryButton: {
flex: 1,
backgroundColor: '#e2e8f0',
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
secondaryButtonText: {
color: '#0f172a',
fontSize: 15,
fontWeight: '600',
},
headerAction: {
color: '#2563eb',
fontWeight: '600',
},
centeredState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
permissionText: {
color: '#0f172a',
textAlign: 'center',
fontSize: 15,
lineHeight: 22,
},
});

View File

@@ -5,7 +5,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog } from '@selfxyz/common/utils/types';
import { extractNameFromDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import ScreenLayout from '../components/ScreenLayout';

View File

@@ -7,7 +7,7 @@ import { ActivityIndicator, Alert, Button, Platform, StyleSheet, Switch, Text, T
import { faker } from '@faker-js/faker';
import { calculateContentHash, inferDocumentCategory, isMRZDocument } from '@selfxyz/common';
import type { DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
import { generateMockDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import SafeAreaScrollView from '../components/SafeAreaScrollView';

View File

@@ -5,7 +5,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Alert, Button, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { DocumentCatalog, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog, IDDocument } from '@selfxyz/common/utils/types';
import { extractNameFromDocument, getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { PickerField } from '../components/PickerField';

View File

@@ -4,7 +4,7 @@
import type { ComponentType } from 'react';
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
export type ScreenId = 'generate' | 'register' | 'prove' | 'camera' | 'nfc' | 'documents';

View File

@@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { type MRZInfo, type MRZValidation, extractMRZInfo } from '@selfxyz/mobile-sdk-alpha';
export type MRZPayload = MRZInfo | (MRZInfo & { rawMRZ?: string; raw?: string; mrzString?: string }) | string;
export interface NormalizedMRZResult {
info: MRZInfo;
readableBirthDate: string;
readableExpiryDate: string;
}
export interface ValidationRow {
label: string;
value: boolean | undefined | null;
}
export function humanizeDocumentType(documentType: string): string {
if (documentType === 'P') {
return 'Passport';
}
if (documentType === 'I') {
return 'ID Card';
}
if (!documentType) {
return 'Unknown';
}
return documentType.trim().toUpperCase();
}
export function buildValidationRows(validation?: MRZValidation): ValidationRow[] | null {
if (!validation) {
return null;
}
return [
{ label: 'Format', value: validation.format },
{ label: 'Document number checksum', value: validation.passportNumberChecksum },
{ label: 'Date of birth checksum', value: validation.dateOfBirthChecksum },
{ label: 'Expiry date checksum', value: validation.dateOfExpiryChecksum },
{ label: 'Composite checksum', value: validation.compositeChecksum },
{ label: 'Overall validation', value: validation.overall },
];
}
export function formatMRZDate(mrzDate: string, locale: string = 'default'): string {
if (!/^\d{6}$/.test(mrzDate)) {
return 'Unknown';
}
const yearPart = parseInt(mrzDate.slice(0, 2), 10);
const monthPart = parseInt(mrzDate.slice(2, 4), 10);
const dayPart = parseInt(mrzDate.slice(4, 6), 10);
if (Number.isNaN(yearPart) || monthPart < 1 || monthPart > 12 || dayPart < 1 || dayPart > 31) {
return 'Unknown';
}
const currentYear = new Date().getFullYear() % 100;
const century = yearPart > currentYear ? 1900 : 2000;
const fullYear = century + yearPart;
const date = new Date(Date.UTC(fullYear, monthPart - 1, dayPart));
if (Number.isNaN(date.getTime())) {
return 'Unknown';
}
try {
if (typeof Intl !== 'undefined') {
const localeOption = locale === 'default' ? undefined : locale;
return new Intl.DateTimeFormat(localeOption, {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}).format(date);
}
} catch {
// Fallback to ISO-like format below.
}
const monthString = `${monthPart}`.padStart(2, '0');
const dayString = `${dayPart}`.padStart(2, '0');
return `${fullYear}-${monthString}-${dayString}`;
}
export function normalizeMRZPayload(payload: MRZPayload): NormalizedMRZResult {
let info: MRZInfo;
if (typeof payload === 'string') {
info = extractMRZInfo(payload);
} else {
const withRaw = payload as MRZInfo & { rawMRZ?: string; raw?: string; mrzString?: string };
const rawCandidate = withRaw.rawMRZ ?? withRaw.raw ?? withRaw.mrzString;
if (typeof rawCandidate === 'string' && rawCandidate.trim().length > 0) {
info = extractMRZInfo(rawCandidate);
} else {
info = {
documentNumber: withRaw.documentNumber,
dateOfBirth: withRaw.dateOfBirth,
dateOfExpiry: withRaw.dateOfExpiry,
issuingCountry: withRaw.issuingCountry,
documentType: withRaw.documentType,
validation: withRaw.validation,
};
}
}
return {
info,
readableBirthDate: formatMRZDate(info.dateOfBirth),
readableExpiryDate: formatMRZDate(info.dateOfExpiry),
};
}

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { DocumentMetadata } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentMetadata } from '@selfxyz/common/utils/types';
export function humanizeDocumentType(documentType: string): string {
const toTitle = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase());

View File

@@ -5,7 +5,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { DocumentsAdapter } from '@selfxyz/mobile-sdk-alpha';
import type { DocumentCatalog, IDDocument, PassportData } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog, IDDocument, PassportData } from '@selfxyz/common/utils/types';
import { getSKIPEM, initPassportDataParsing } from '@selfxyz/common';
const CATALOG_KEY = '@self_demo:document_catalog';

View File

@@ -1,242 +0,0 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* Tests for crypto-polyfill.js demonstrating functional bugs:
* 1. Method chaining breaks due to incorrect `this` binding
* 2. RNG import fails - react-native-get-random-values doesn't export getRandomValues
* 3. Buffer polyfill missing
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Preserve original crypto
const originalCrypto = global.crypto;
// Mock crypto.getRandomValues in jsdom environment
if (typeof global.crypto === 'undefined' || !global.crypto.getRandomValues) {
const mockGetRandomValues = vi.fn((array: Uint8Array) => {
// Fill with predictable values for testing
for (let i = 0; i < array.length; i++) {
array[i] = i % 256;
}
return array;
});
Object.defineProperty(global, 'crypto', {
value: {
getRandomValues: mockGetRandomValues,
},
writable: true,
configurable: true,
});
}
// Mock Buffer globally to simulate React Native environment where Buffer is undefined
const originalBuffer = global.Buffer;
describe('Crypto Polyfill Functional Bugs', () => {
let crypto: any;
beforeEach(() => {
// Clear module cache to get fresh instance
vi.resetModules();
vi.restoreAllMocks();
vi.clearAllMocks();
});
afterEach(() => {
// Restore Buffer if we removed it
global.Buffer = originalBuffer;
// Restore crypto (use Object.defineProperty for read-only properties)
if (originalCrypto) {
Object.defineProperty(global, 'crypto', {
value: originalCrypto,
writable: true,
configurable: true,
});
}
});
describe('Method Chaining Bug', () => {
it('should allow method chaining with update() calls', async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// This should work but currently fails due to `this` binding issue
expect(() => {
const hasher = crypto.createHash('sha256');
const result = hasher.update('Hello ').update('World').digest('hex');
expect(typeof result).toBe('string');
expect(result.length).toBe(64); // SHA256 hex length
}).not.toThrow();
});
it('should return the hasher instance from update() for chaining', async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const hasher = crypto.createHash('sha256');
const updateResult = hasher.update('test');
// This should be the same object for chaining
expect(updateResult).toBe(hasher);
expect(updateResult.update).toBeInstanceOf(Function);
expect(updateResult.digest).toBeInstanceOf(Function);
});
it('should produce the same result for chained vs separate calls', async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// Chained approach
const chainedResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
// Separate calls approach
const hasher = crypto.createHash('sha256');
hasher.update('Hello ');
hasher.update('World');
const separateResult = hasher.digest('hex');
expect(chainedResult).toBe(separateResult);
});
});
describe('RNG Import Bug', () => {
it('should not try to destructure getRandomValues from react-native-get-random-values', async () => {
// Mock the require to simulate the actual package behavior
vi.doMock('react-native-get-random-values', () => {
// This package doesn't export getRandomValues - it just polyfills globalThis.crypto
global.crypto = global.crypto || ({} as typeof crypto);
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
for (let i = 0; i < array.length; i++) {
array[i] = i % 256;
}
return array;
}) as any;
return {}; // Empty export
});
// Should now work because we use globalThis.crypto.getRandomValues, not destructuring
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const result = crypto.randomBytes(16);
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBe(16);
});
it('should throw helpful error when crypto.getRandomValues is not available', async () => {
// Clear module cache and remove crypto polyfill
vi.resetModules();
vi.doMock('react-native-get-random-values', () => {
// Mock a broken polyfill that doesn't install crypto
return {};
});
// Remove crypto to simulate missing polyfill
const originalCrypto = global.crypto;
delete (global as any).crypto;
await expect(async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
crypto.randomBytes(16);
}).rejects.toThrow(/globalThis.crypto.getRandomValues is not available/);
global.crypto = originalCrypto;
});
});
describe('Buffer Polyfill Bug', () => {
it('should gracefully handle Buffer availability check', async () => {
// This test verifies that the crypto polyfill checks for Buffer availability
// Note: We can't actually delete Buffer because Vitest's worker threads need it
// Instead, we verify that the polyfill works correctly with and without Buffer
// Import the polyfill normally
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// Test that randomBytes returns a typed array
const result = crypto.randomBytes(32);
// The result should be either a Buffer or Uint8Array (Buffer extends Uint8Array)
// Buffer is available in Node.js environment, so we expect Buffer here
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBe(32);
// Verify it's a valid typed array with proper values
expect(result.every((byte: number) => byte >= 0 && byte <= 255)).toBe(true);
});
it('should work with Buffer polyfill imported', async () => {
// Reset mocks for this test
vi.unmock('buffer');
vi.resetModules();
vi.restoreAllMocks();
// Simulate proper Buffer polyfill
global.Buffer = require('buffer').Buffer;
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const result = crypto.createHash('sha256').update('test').digest('hex');
expect(typeof result).toBe('string');
expect(result.length).toBe(64);
});
it('should handle different data types correctly with Buffer polyfill', async () => {
// Reset mocks for this test
vi.unmock('buffer');
vi.resetModules();
vi.restoreAllMocks();
global.Buffer = require('buffer').Buffer;
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const hasher = crypto.createHash('sha256');
// Should handle strings
hasher.update('string data');
// Should handle Uint8Array
hasher.update(new Uint8Array([1, 2, 3, 4]));
// Should handle Buffer
hasher.update(Buffer.from('buffer data'));
const result = hasher.digest('hex');
expect(typeof result).toBe('string');
expect(result.length).toBe(64);
});
});
describe('Integration Tests', () => {
it('should work end-to-end with all fixes applied', async () => {
// Reset mocks for this test
vi.unmock('buffer');
vi.resetModules();
vi.restoreAllMocks();
// Set up proper environment
global.Buffer = require('buffer').Buffer;
global.crypto = global.crypto || ({} as typeof crypto);
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
for (let i = 0; i < array.length; i++) {
array[i] = i % 256;
}
return array;
}) as any;
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// Test hash chaining
const hashResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
expect(typeof hashResult).toBe('string');
expect(hashResult.length).toBe(64);
// Test randomBytes
const randomResult = crypto.randomBytes(32);
expect(randomResult).toBeInstanceOf(Buffer);
expect(randomResult.length).toBe(32);
});
});
});

View File

@@ -0,0 +1,421 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* Comprehensive tests for crypto polyfills including:
* 1. Functional bug tests for crypto-polyfill.js
* 2. Ethers integration tests for crypto utilities
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { computeHmac, pbkdf2, randomBytes, sha256, sha512 } from '../src/utils/ethers';
// Preserve original crypto
const originalCrypto = global.crypto;
// Mock crypto.getRandomValues in jsdom environment
if (typeof global.crypto === 'undefined' || !global.crypto.getRandomValues) {
const mockGetRandomValues = vi.fn((array: Uint8Array) => {
// Fill with predictable values for testing
for (let i = 0; i < array.length; i++) {
array[i] = i % 256;
}
return array;
});
Object.defineProperty(global, 'crypto', {
value: {
getRandomValues: mockGetRandomValues,
},
writable: true,
configurable: true,
});
}
// Mock Buffer globally to simulate React Native environment where Buffer is undefined
const originalBuffer = global.Buffer;
describe('Crypto Polyfills', () => {
let crypto: any;
beforeEach(() => {
// Clear module cache to get fresh instance
vi.resetModules();
vi.restoreAllMocks();
vi.clearAllMocks();
});
afterEach(() => {
// Restore Buffer if we removed it
global.Buffer = originalBuffer;
// Restore crypto (use Object.defineProperty for read-only properties)
if (originalCrypto) {
Object.defineProperty(global, 'crypto', {
value: originalCrypto,
writable: true,
configurable: true,
});
}
});
describe('Ethers Crypto Utilities', () => {
describe('randomBytes', () => {
it('should generate random bytes of specified length', () => {
const bytes = randomBytes(32);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(32);
});
it('should generate different random bytes on each call', () => {
const bytes1 = randomBytes(16);
const bytes2 = randomBytes(16);
expect(bytes1).not.toEqual(bytes2);
});
it('should handle different lengths', () => {
const bytes8 = randomBytes(8);
const bytes64 = randomBytes(64);
expect(bytes8.length).toBe(8);
expect(bytes64.length).toBe(64);
});
});
describe('sha256', () => {
it('should hash data correctly', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = sha256(data);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(32); // SHA-256 produces 32 bytes
});
it('should produce consistent hashes for same input', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash1 = sha256(data);
const hash2 = sha256(data);
expect(hash1).toEqual(hash2);
});
it('should produce different hashes for different inputs', () => {
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
const hash1 = sha256(data1);
const hash2 = sha256(data2);
expect(hash1).not.toEqual(hash2);
});
});
describe('sha512', () => {
it('should hash data correctly', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = sha512(data);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(64); // SHA-512 produces 64 bytes
});
it('should produce consistent hashes for same input', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash1 = sha512(data);
const hash2 = sha512(data);
expect(hash1).toEqual(hash2);
});
it('should produce different hashes for different inputs', () => {
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
const hash1 = sha512(data1);
const hash2 = sha512(data2);
expect(hash1).not.toEqual(hash2);
});
});
describe('computeHmac', () => {
it('should compute HMAC-SHA256 correctly', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac = computeHmac('sha256', key, data);
expect(hmac).toBeInstanceOf(Uint8Array);
expect(hmac.length).toBe(32); // HMAC-SHA256 produces 32 bytes
});
it('should compute HMAC-SHA512 correctly', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac = computeHmac('sha512', key, data);
expect(hmac).toBeInstanceOf(Uint8Array);
expect(hmac.length).toBe(64); // HMAC-SHA512 produces 64 bytes
});
it('should produce consistent HMAC for same inputs', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac1 = computeHmac('sha256', key, data);
const hmac2 = computeHmac('sha256', key, data);
expect(hmac1).toEqual(hmac2);
});
it('should produce different HMAC for different keys', () => {
const key1 = new Uint8Array([1, 2, 3, 4]);
const key2 = new Uint8Array([1, 2, 3, 5]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac1 = computeHmac('sha256', key1, data);
const hmac2 = computeHmac('sha256', key2, data);
expect(hmac1).not.toEqual(hmac2);
});
});
describe('pbkdf2', () => {
it('should derive key using PBKDF2-SHA256', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key = pbkdf2(password, salt, 1000, 32, 'sha256');
expect(key).toBeInstanceOf(Uint8Array);
expect(key.length).toBe(32);
});
it('should derive key using PBKDF2-SHA512', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key = pbkdf2(password, salt, 1000, 64, 'sha512');
expect(key).toBeInstanceOf(Uint8Array);
expect(key.length).toBe(64);
});
it('should produce consistent keys for same inputs', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt, 1000, 32, 'sha256');
expect(key1).toEqual(key2);
});
it('should produce different keys for different salts', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt1 = new Uint8Array([5, 6, 7, 8]);
const salt2 = new Uint8Array([5, 6, 7, 9]);
const key1 = pbkdf2(password, salt1, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt2, 1000, 32, 'sha256');
expect(key1).not.toEqual(key2);
});
it('should handle different iteration counts', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt, 2000, 32, 'sha256');
expect(key1).not.toEqual(key2);
});
});
describe('ethers integration', () => {
it('should have ethers.randomBytes registered', () => {
// This test verifies that ethers.js is using our polyfill
const { ethers } = require('ethers');
expect(typeof ethers.randomBytes).toBe('function');
const bytes = ethers.randomBytes(16);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(16);
});
});
});
describe('Crypto Polyfill Functional Bugs', () => {
describe('Method Chaining Bug', () => {
it('should allow method chaining with update() calls', async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// This should work but currently fails due to `this` binding issue
expect(() => {
const hasher = crypto.createHash('sha256');
const result = hasher.update('Hello ').update('World').digest('hex');
expect(typeof result).toBe('string');
expect(result.length).toBe(64); // SHA256 hex length
}).not.toThrow();
});
it('should return the hasher instance from update() for chaining', async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const hasher = crypto.createHash('sha256');
const updateResult = hasher.update('test');
// This should be the same object for chaining
expect(updateResult).toBe(hasher);
expect(updateResult.update).toBeInstanceOf(Function);
expect(updateResult.digest).toBeInstanceOf(Function);
});
it('should produce the same result for chained vs separate calls', async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// Chained approach
const chainedResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
// Separate calls approach
const hasher = crypto.createHash('sha256');
hasher.update('Hello ');
hasher.update('World');
const separateResult = hasher.digest('hex');
expect(chainedResult).toBe(separateResult);
});
});
describe('RNG Import Bug', () => {
it('should not try to destructure getRandomValues from react-native-get-random-values', async () => {
// Mock the require to simulate the actual package behavior
vi.doMock('react-native-get-random-values', () => {
// This package doesn't export getRandomValues - it just polyfills globalThis.crypto
global.crypto = global.crypto || ({} as typeof crypto);
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
for (let i = 0; i < array.length; i++) {
array[i] = i % 256;
}
return array;
}) as any;
return {}; // Empty export
});
// Should now work because we use globalThis.crypto.getRandomValues, not destructuring
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const result = crypto.randomBytes(16);
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBe(16);
});
it('should throw helpful error when crypto.getRandomValues is not available', async () => {
// Clear module cache and remove crypto polyfill
vi.resetModules();
vi.doMock('react-native-get-random-values', () => {
// Mock a broken polyfill that doesn't install crypto
return {};
});
// Remove crypto to simulate missing polyfill
const originalCrypto = global.crypto;
delete (global as any).crypto;
await expect(async () => {
crypto = await import('../src/polyfills/cryptoPolyfill.js');
crypto.randomBytes(16);
}).rejects.toThrow(/globalThis.crypto.getRandomValues is not available/);
global.crypto = originalCrypto;
});
});
describe('Buffer Polyfill Bug', () => {
it('should gracefully handle Buffer availability check', async () => {
// This test verifies that the crypto polyfill checks for Buffer availability
// Note: We can't actually delete Buffer because Vitest's worker threads need it
// Instead, we verify that the polyfill works correctly with and without Buffer
// Import the polyfill normally
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// Test that randomBytes returns a typed array
const result = crypto.randomBytes(32);
// The result should be either a Buffer or Uint8Array (Buffer extends Uint8Array)
// Buffer is available in Node.js environment, so we expect Buffer here
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBe(32);
// Verify it's a valid typed array with proper values
expect(result.every((byte: number) => byte >= 0 && byte <= 255)).toBe(true);
});
it('should work with Buffer polyfill imported', async () => {
// Reset mocks for this test
vi.unmock('buffer');
vi.resetModules();
vi.restoreAllMocks();
// Simulate proper Buffer polyfill
global.Buffer = require('buffer').Buffer;
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const result = crypto.createHash('sha256').update('test').digest('hex');
expect(typeof result).toBe('string');
expect(result.length).toBe(64);
});
it('should handle different data types correctly with Buffer polyfill', async () => {
// Reset mocks for this test
vi.unmock('buffer');
vi.resetModules();
vi.restoreAllMocks();
global.Buffer = require('buffer').Buffer;
crypto = await import('../src/polyfills/cryptoPolyfill.js');
const hasher = crypto.createHash('sha256');
// Should handle strings
hasher.update('string data');
// Should handle Uint8Array
hasher.update(new Uint8Array([1, 2, 3, 4]));
// Should handle Buffer
hasher.update(Buffer.from('buffer data'));
const result = hasher.digest('hex');
expect(typeof result).toBe('string');
expect(result.length).toBe(64);
});
});
describe('Integration Tests', () => {
it('should work end-to-end with all fixes applied', async () => {
// Reset mocks for this test
vi.unmock('buffer');
vi.resetModules();
vi.restoreAllMocks();
// Set up proper environment
global.Buffer = require('buffer').Buffer;
global.crypto = global.crypto || ({} as typeof crypto);
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
for (let i = 0; i < array.length; i++) {
array[i] = i % 256;
}
return array;
}) as any;
crypto = await import('../src/polyfills/cryptoPolyfill.js');
// Test hash chaining
const hashResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
expect(typeof hashResult).toBe('string');
expect(hashResult.length).toBe(64);
// Test randomBytes
const randomResult = crypto.randomBytes(32);
expect(randomResult).toBeInstanceOf(Buffer);
expect(randomResult.length).toBe(32);
});
});
});
});

View File

@@ -12,7 +12,7 @@
import { describe, expect, it } from 'vitest';
import type { PassportData } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { PassportData } from '@selfxyz/common/utils/types';
describe('documentStore - BigInt serialization (simplified)', () => {
it('should demonstrate the BigInt serialization problem with JSON.stringify/parse', () => {

View File

@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentCatalog } from '@selfxyz/common/utils/types';
import { selectDocument, updateAfterDelete } from '../../src/lib/catalog';

View File

@@ -0,0 +1,2 @@
export { extractMRZInfo, formatDateToYYMMDD } from '../../../mobile-sdk-alpha/src/processing/mrz';
export type { MRZInfo, MRZValidation } from '../../../mobile-sdk-alpha/src/types/public';

View File

@@ -0,0 +1,45 @@
export const AccessibilityInfo = {
announceForAccessibility: () => Promise.resolve(),
};
export const ActivityIndicator = () => null;
export const Alert = { alert: () => undefined };
export const PermissionsAndroid = {
PERMISSIONS: { CAMERA: 'android.permission.CAMERA' },
RESULTS: { GRANTED: 'granted', DENIED: 'denied' },
request: async () => 'granted' as const,
};
export const Platform = {
OS: 'ios',
select<T>(mapping: Record<string, T> & { default?: T }): T {
if (Object.prototype.hasOwnProperty.call(mapping, 'ios')) {
return mapping.ios as T;
}
if (Object.prototype.hasOwnProperty.call(mapping, 'default')) {
return mapping.default as T;
}
return Object.values(mapping)[0] as T;
},
};
export const StyleSheet = {
create: <T extends Record<string, object>>(styles: T) => styles,
};
export const Text = () => null;
export const TouchableOpacity = () => null;
export const View = () => null;
export default {
AccessibilityInfo,
ActivityIndicator,
Alert,
PermissionsAndroid,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
};

View File

@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { describe, expect, it } from 'vitest';
import type { MRZInfo } from '@selfxyz/mobile-sdk-alpha';
import { buildValidationRows, formatMRZDate, humanizeDocumentType, normalizeMRZPayload } from '../../src/utils/camera';
describe('formatMRZDate', () => {
it('formats valid YYMMDD strings into readable dates', () => {
expect(formatMRZDate('740812', 'en-US')).toBe('August 12, 1974');
expect(formatMRZDate('010101', 'en-US')).toBe('January 1, 2001');
});
it('returns Unknown for invalid values', () => {
expect(formatMRZDate('991332', 'en-US')).toBe('Unknown');
expect(formatMRZDate('abc123', 'en-US')).toBe('Unknown');
});
});
describe('normalizeMRZPayload', () => {
it('parses raw MRZ strings and surfaces validation data', () => {
const rawMRZ = 'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10';
const normalized = normalizeMRZPayload(rawMRZ);
expect(normalized.info.documentNumber).toBe('L898902C3');
expect(normalized.info.dateOfBirth).toBe('740812');
expect(normalized.info.dateOfExpiry).toBe('120415');
expect(normalized.info.validation?.overall).toBe(true);
});
it('preserves provided MRZ info when validation already exists', () => {
const info: MRZInfo = {
documentNumber: 'X1234567',
dateOfBirth: '010101',
dateOfExpiry: '251231',
issuingCountry: 'UTO',
documentType: 'P',
validation: {
format: true,
passportNumberChecksum: true,
dateOfBirthChecksum: true,
dateOfExpiryChecksum: true,
compositeChecksum: true,
overall: true,
},
};
const normalized = normalizeMRZPayload(info);
expect(normalized.info).toEqual(info);
expect(normalized.readableBirthDate).toBe(formatMRZDate('010101', 'en-US'));
});
});
describe('humanizeDocumentType', () => {
it('maps known document codes to friendly labels', () => {
expect(humanizeDocumentType('P')).toBe('Passport');
expect(humanizeDocumentType('I')).toBe('ID Card');
});
it('falls back to normalized text for unknown values', () => {
expect(humanizeDocumentType(' visa ')).toBe('VISA');
expect(humanizeDocumentType('')).toBe('Unknown');
});
});
describe('buildValidationRows', () => {
it('returns null when validation is unavailable', () => {
expect(buildValidationRows(undefined)).toBeNull();
});
it('maps MRZ validation flags into labelled rows', () => {
const rows = buildValidationRows({
format: true,
passportNumberChecksum: true,
dateOfBirthChecksum: false,
dateOfExpiryChecksum: true,
compositeChecksum: true,
overall: false,
});
expect(rows).toMatchObject([
{ label: 'Format', value: true },
{ label: 'Document number checksum', value: true },
{ label: 'Date of birth checksum', value: false },
{ label: 'Expiry date checksum', value: true },
{ label: 'Composite checksum', value: true },
{ label: 'Overall validation', value: false },
]);
});
});

View File

@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import type { DocumentMetadata } from '@selfxyz/common/dist/esm/src/utils/types.js';
import type { DocumentMetadata } from '@selfxyz/common/utils/types';
import { formatDataPreview, humanizeDocumentType, maskId } from '../../src/utils/document';

View File

@@ -1,9 +1,20 @@
{
"extends": "@tsconfig/react-native/tsconfig.json",
"compilerOptions": {
"module": "ES2020",
"strict": true,
"allowJs": true,
"baseUrl": ".",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
"paths": {
"@selfxyz/mobile-sdk-alpha": ["../../packages/mobile-sdk-alpha/dist/esm/index"],
"@selfxyz/mobile-sdk-alpha/onboarding/*": ["../../packages/mobile-sdk-alpha/dist/esm/flows/onboarding/*"],
"@selfxyz/mobile-sdk-alpha/disclosing/*": ["../../packages/mobile-sdk-alpha/dist/esm/flows/disclosing/*"]
},
"types": ["react-native", "vitest/globals"]
},
"include": ["src", "App.tsx", "index.js", "types/**/*", "__tests__/**/*", "tests/**/*"]
"include": ["src", "App.tsx", "index.js", "types/**/*", "__tests__/**/*", "tests/**/*"],
"exclude": ["node_modules", "../../packages/mobile-sdk-alpha/src"]
}

View File

@@ -7,14 +7,13 @@ import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '../..');
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['__tests__/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'],
include: ['tests/**/*.test.{ts,tsx}'],
exclude: ['node_modules/**'],
// Skip checking node_modules for faster testing
server: {
@@ -23,10 +22,22 @@ export default defineConfig({
},
},
},
esbuild: {
target: 'node18',
},
build: {
target: 'node18',
},
resolve: {
alias: {
'@selfxyz/common': resolve(repoRoot, 'common/dist/cjs/index.cjs'),
'@selfxyz/mobile-sdk-alpha': resolve(repoRoot, 'packages/mobile-sdk-alpha/src/index.ts'),
},
alias: [
{
find: '@selfxyz/mobile-sdk-alpha',
replacement: resolve(__dirname, './tests/mocks/mobile-sdk-alpha.ts'),
},
{
find: 'react-native',
replacement: resolve(__dirname, './tests/mocks/react-native.ts'),
},
],
},
});