@trustchex/react-native-sdk 1.475.1 → 1.478.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +19 -21
- package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
- package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
- package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
- package/lib/module/Screens/Static/ResultScreen.js +103 -107
- package/lib/module/Shared/Components/DebugOverlay.js +5 -2
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +53 -22
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
- package/lib/module/Shared/EIDReader/eidReader.js +15 -14
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +34 -0
- package/lib/module/Shared/Libs/SignalingClient.js +6 -5
- package/lib/module/Shared/Libs/crypto.utils.js +25 -3
- package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
- package/lib/module/Shared/Libs/http-client.js +19 -6
- package/lib/module/Shared/Libs/mrz.utils.js +193 -33
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +45 -1
- package/lib/module/Shared/Libs/promise.utils.js +4 -3
- package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
- package/lib/module/Shared/Services/AnalyticsService.js +8 -2
- package/lib/module/Shared/Services/DataUploadService.js +114 -127
- package/lib/module/Shared/Services/VideoSessionService.js +4 -3
- package/lib/module/Translation/Resources/en.js +1 -0
- package/lib/module/Translation/Resources/tr.js +1 -0
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
- package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
- package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +22 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
- package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/en.d.ts +1 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +1 -0
- package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +13 -3
- package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
- package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
- package/src/Screens/Static/ResultScreen.tsx +126 -161
- package/src/Shared/Components/DebugOverlay.tsx +5 -2
- package/src/Shared/Components/IdentityDocumentCamera.tsx +55 -22
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
- package/src/Shared/EIDReader/eidReader.ts +23 -14
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +34 -0
- package/src/Shared/Libs/SignalingClient.ts +10 -5
- package/src/Shared/Libs/crypto.utils.ts +41 -4
- package/src/Shared/Libs/deeplink.utils.ts +39 -7
- package/src/Shared/Libs/http-client.ts +24 -8
- package/src/Shared/Libs/mrz.utils.ts +200 -34
- package/src/Shared/Libs/mrzFrameAggregator.ts +53 -1
- package/src/Shared/Libs/promise.utils.ts +11 -5
- package/src/Shared/Libs/shuffle.utils.ts +15 -0
- package/src/Shared/Services/AnalyticsService.ts +17 -1
- package/src/Shared/Services/DataUploadService.ts +155 -166
- package/src/Shared/Services/VideoSessionService.ts +15 -3
- package/src/Translation/Resources/en.ts +1 -0
- package/src/Translation/Resources/tr.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -127,6 +127,11 @@ const IdentityDocumentCamera = ({
|
|
|
127
127
|
const [status, setStatus] = useState<
|
|
128
128
|
'SEARCHING' | 'SCANNING' | 'SCANNED' | 'INCORRECT'
|
|
129
129
|
>('SEARCHING');
|
|
130
|
+
// True once the multi-frame voted MRZ consensus is STABLE + valid (the same
|
|
131
|
+
// reading matched across `stabilityTarget` consecutive frames, with a clear
|
|
132
|
+
// vote margin on contested cells). Drives the green "GO" signal — we never show
|
|
133
|
+
// green on an early/transient valid read, only on a reliably-detected MRZ.
|
|
134
|
+
const [mrzReliable, setMrzReliable] = useState(false);
|
|
130
135
|
const [nextStep, setNextStep] = useState<
|
|
131
136
|
'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM' | 'COMPLETED'
|
|
132
137
|
>('SCAN_ID_FRONT_OR_PASSPORT');
|
|
@@ -148,7 +153,10 @@ const IdentityDocumentCamera = ({
|
|
|
148
153
|
// Multi-frame per-character voting: fuses OCR across frames so a single-frame
|
|
149
154
|
// misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
|
|
150
155
|
// resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
|
|
151
|
-
|
|
156
|
+
// stabilityTarget: 3 — the voted consensus must read the SAME valid MRZ across
|
|
157
|
+
// 3 consecutive contributing frames before we accept, so a transient look-alike
|
|
158
|
+
// misread (e.g. OCR-B "4" vs "A") can never lock in on one or two early frames.
|
|
159
|
+
const mrzAggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 3 }));
|
|
152
160
|
|
|
153
161
|
// Document type stability tracking - require consistent detections from good quality frames
|
|
154
162
|
const lastDetectedDocType = useRef<
|
|
@@ -283,6 +291,7 @@ const IdentityDocumentCamera = ({
|
|
|
283
291
|
lastValidMRZFields.current = null;
|
|
284
292
|
validMRZConsecutiveCount.current = 0;
|
|
285
293
|
mrzAggregator.current.reset();
|
|
294
|
+
setMrzReliable(false);
|
|
286
295
|
cachedBarcode.current = null;
|
|
287
296
|
isCompletionCallbackInvoked.current = false;
|
|
288
297
|
isStepTransitionInProgress.current = false;
|
|
@@ -609,6 +618,7 @@ const IdentityDocumentCamera = ({
|
|
|
609
618
|
lastValidMRZFields.current = null;
|
|
610
619
|
// New MRZ expected on the back — start voting fresh.
|
|
611
620
|
mrzAggregator.current.reset();
|
|
621
|
+
setMrzReliable(false);
|
|
612
622
|
}
|
|
613
623
|
validMRZConsecutiveCount.current = 0;
|
|
614
624
|
cachedBarcode.current = null; // Clear cached barcode on step change
|
|
@@ -1009,15 +1019,6 @@ const IdentityDocumentCamera = ({
|
|
|
1009
1019
|
// During SCAN_HOLOGRAM, allow processing even if text is not readable
|
|
1010
1020
|
}
|
|
1011
1021
|
|
|
1012
|
-
// Capture test mode data
|
|
1013
|
-
if (testMode && text && text.includes('<')) {
|
|
1014
|
-
const mrzOnlyText = scannedText?.mrzOnlyText || text;
|
|
1015
|
-
setTestModeData({
|
|
1016
|
-
mrzText: mrzOnlyText,
|
|
1017
|
-
timestamp: Date.now(),
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
1022
|
// MRZ stability: require consistent valid reads across frames
|
|
1022
1023
|
// Skip during SCAN_HOLOGRAM - document type already locked
|
|
1023
1024
|
const mrzHasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
|
|
@@ -1041,14 +1042,39 @@ const IdentityDocumentCamera = ({
|
|
|
1041
1042
|
}
|
|
1042
1043
|
// else: Invalid/no MRZ - skip frame without resetting (allows temporary OCR noise)
|
|
1043
1044
|
|
|
1044
|
-
//
|
|
1045
|
+
// Accept ONLY when the multi-frame voted consensus is stable.
|
|
1046
|
+
//
|
|
1047
|
+
// Early frames can OCR an ambiguous glyph wrongly yet still produce a
|
|
1048
|
+
// check-digit-VALID reading — e.g. a Turkish document number "A53032352"
|
|
1049
|
+
// misread as "453032352" (the OCR-B 4 looks like A, and 453032352 also
|
|
1050
|
+
// passes its check digit). The old code also accepted on a legacy path of
|
|
1051
|
+
// "N identical valid single-frame reads", which those transient frames
|
|
1052
|
+
// satisfy — locking in the WRONG number before the voted consensus settles
|
|
1053
|
+
// on the correct one. We now require `consensus.stable`: the per-character
|
|
1054
|
+
// vote must be unchanged across `stabilityTarget` consecutive contributing
|
|
1055
|
+
// frames, which is exactly the "only accept once stable" guarantee.
|
|
1056
|
+
// `addFrame` runs for every frame above, so the consensus is always current.
|
|
1045
1057
|
const mrzStableAndValid =
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1058
|
+
consensus.stable && parsedMRZData?.valid === true;
|
|
1059
|
+
|
|
1060
|
+
// Surface the reliable-detection signal to the render so the UI can show a
|
|
1061
|
+
// green "GO" the moment (and only once) the consensus is stable + valid.
|
|
1062
|
+
// Never flips green on an early/transient valid read.
|
|
1063
|
+
setMrzReliable((prev) => (prev === mrzStableAndValid ? prev : mrzStableAndValid));
|
|
1064
|
+
|
|
1065
|
+
// Test-mode panel: show the MRZ ONLY once it is stable + valid (the same
|
|
1066
|
+
// consensus matched across `stabilityTarget` consecutive frames). Showing
|
|
1067
|
+
// the raw per-frame OCR here would flash transient, possibly mis-converted
|
|
1068
|
+
// readings (e.g. a momentary "4" for "A") before the vote settles — the
|
|
1069
|
+
// opposite of what the screen should convey. We display the corrected,
|
|
1070
|
+
// check-digit-valid consensus and never an unstable intermediate.
|
|
1071
|
+
if (testMode && mrzStableAndValid && mrzText) {
|
|
1072
|
+
setTestModeData((prev) =>
|
|
1073
|
+
prev?.mrzText === mrzText
|
|
1074
|
+
? prev
|
|
1075
|
+
: { mrzText, timestamp: Date.now() }
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1052
1078
|
|
|
1053
1079
|
// ============================================================================
|
|
1054
1080
|
// SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
|
|
@@ -2430,8 +2456,11 @@ const IdentityDocumentCamera = ({
|
|
|
2430
2456
|
style={[
|
|
2431
2457
|
styles.topZoneText,
|
|
2432
2458
|
// Priority order for coloring (later styles override earlier ones)
|
|
2433
|
-
// 1. Success (green) - scan completed
|
|
2434
|
-
|
|
2459
|
+
// 1. Success/GO (green) - scan completed OR MRZ reliably detected
|
|
2460
|
+
// (stable consensus). This is the "GO" signal; never shown on a
|
|
2461
|
+
// transient/early valid read.
|
|
2462
|
+
(nextStep === 'COMPLETED' || mrzReliable) &&
|
|
2463
|
+
styles.topZoneTextSuccess,
|
|
2435
2464
|
// 2. Error (red) - wrong side - with flash opacity
|
|
2436
2465
|
status === 'INCORRECT' && styles.topZoneTextError,
|
|
2437
2466
|
status === 'INCORRECT' && {
|
|
@@ -2457,7 +2486,8 @@ const IdentityDocumentCamera = ({
|
|
|
2457
2486
|
isFrameBlurry,
|
|
2458
2487
|
allElementsDetected,
|
|
2459
2488
|
elementsOutsideScanArea,
|
|
2460
|
-
t
|
|
2489
|
+
t,
|
|
2490
|
+
mrzReliable
|
|
2461
2491
|
)}
|
|
2462
2492
|
</AnimatedText>
|
|
2463
2493
|
</View>
|
|
@@ -2469,7 +2499,9 @@ const IdentityDocumentCamera = ({
|
|
|
2469
2499
|
styles.scanArea,
|
|
2470
2500
|
{
|
|
2471
2501
|
borderColor:
|
|
2472
|
-
|
|
2502
|
+
// Green once the scan is done OR the MRZ is reliably detected
|
|
2503
|
+
// (stable consensus) — the "GO" signal. Never green before that.
|
|
2504
|
+
nextStep === 'COMPLETED' || mrzReliable
|
|
2473
2505
|
? '#4CAF50'
|
|
2474
2506
|
: status === 'INCORRECT'
|
|
2475
2507
|
? '#f44336'
|
|
@@ -2478,7 +2510,8 @@ const IdentityDocumentCamera = ({
|
|
|
2478
2510
|
: isBrightnessLow || isFrameBlurry
|
|
2479
2511
|
? '#FFC107'
|
|
2480
2512
|
: 'white',
|
|
2481
|
-
borderWidth:
|
|
2513
|
+
borderWidth:
|
|
2514
|
+
mrzReliable || status === 'SCANNING' ? 3 : 2,
|
|
2482
2515
|
},
|
|
2483
2516
|
]}
|
|
2484
2517
|
>
|
|
@@ -96,12 +96,20 @@ export function getStatusMessage(
|
|
|
96
96
|
isFrameBlurry: boolean,
|
|
97
97
|
allElementsDetected: boolean,
|
|
98
98
|
elementsOutsideScanArea: string[],
|
|
99
|
-
t: (key: string, params?: Record<string, unknown>) => string
|
|
99
|
+
t: (key: string, params?: Record<string, unknown>) => string,
|
|
100
|
+
mrzReliable: boolean = false
|
|
100
101
|
): string {
|
|
101
102
|
if (nextStep === 'COMPLETED') {
|
|
102
103
|
return t('identityDocumentCamera.scanCompleted');
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
// MRZ reliably detected (stable consensus) — the green "GO" message. Ranks
|
|
107
|
+
// above the in-progress states so the user gets a clear positive signal the
|
|
108
|
+
// moment the reading has settled, but never on an early/transient valid read.
|
|
109
|
+
if (mrzReliable) {
|
|
110
|
+
return t('identityDocumentCamera.mrzDetected');
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
if (status === 'INCORRECT') {
|
|
106
114
|
if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
|
|
107
115
|
return t('identityDocumentCamera.alignIDFront');
|
|
@@ -8,6 +8,7 @@ import { FaceImageInfo } from './lds/iso19794/faceImageInfo';
|
|
|
8
8
|
import { Buffer } from 'buffer';
|
|
9
9
|
import { MRZInfo } from './lds/icao/mrzInfo';
|
|
10
10
|
import { InputStream } from './java/inputStream';
|
|
11
|
+
import { debugLog } from '../Libs/debug.utils';
|
|
11
12
|
|
|
12
13
|
// --- Minimal PNG encoder (pure JS, no native deps) ---
|
|
13
14
|
// Uses deflate stored (uncompressed) blocks for O(n) encoding speed.
|
|
@@ -161,14 +162,15 @@ function convertJP2IfNeeded(
|
|
|
161
162
|
return { base64: imageBuffer.toString('base64'), mimeType };
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
|
|
165
|
+
debugLog('EID', '[EID] Face image is JP2, attempting conversion…');
|
|
165
166
|
try {
|
|
166
167
|
// Lazy-load jpeg2000 to avoid crashing if it fails to import
|
|
167
168
|
const { JpxImage } = require('jpeg2000');
|
|
168
169
|
const jpx = new JpxImage();
|
|
169
170
|
jpx.parse(imageBuffer);
|
|
170
171
|
const tile = jpx.tiles[0];
|
|
171
|
-
|
|
172
|
+
debugLog(
|
|
173
|
+
'EID',
|
|
172
174
|
`[EID] JP2 decoded: ${tile.width}x${tile.height}, ${jpx.componentsCount} channels, ${tile.items.length} bytes`
|
|
173
175
|
);
|
|
174
176
|
const pngBytes = pixelsToPng(
|
|
@@ -178,12 +180,13 @@ function convertJP2IfNeeded(
|
|
|
178
180
|
jpx.componentsCount
|
|
179
181
|
);
|
|
180
182
|
const pngBase64 = Buffer.from(pngBytes).toString('base64');
|
|
181
|
-
|
|
183
|
+
debugLog(
|
|
184
|
+
'EID',
|
|
182
185
|
`[EID] Converted JP2 → PNG (${pngBytes.length} bytes, ${pngBase64.length} base64 chars)`
|
|
183
186
|
);
|
|
184
187
|
return { base64: pngBase64, mimeType: 'image/png' };
|
|
185
188
|
} catch (err) {
|
|
186
|
-
|
|
189
|
+
debugLog('EID', '[EID] JP2 conversion failed:', err);
|
|
187
190
|
return { base64: imageBuffer.toString('base64'), mimeType };
|
|
188
191
|
}
|
|
189
192
|
}
|
|
@@ -319,10 +322,11 @@ const eidReader = async (
|
|
|
319
322
|
|
|
320
323
|
try {
|
|
321
324
|
await passportService.sendSelectMF();
|
|
322
|
-
|
|
325
|
+
debugLog('EID', '[EID] SELECT MF OK');
|
|
323
326
|
} catch (mfErr) {
|
|
324
327
|
const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
|
|
325
|
-
|
|
328
|
+
debugLog(
|
|
329
|
+
'EID',
|
|
326
330
|
`[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`
|
|
327
331
|
);
|
|
328
332
|
}
|
|
@@ -346,13 +350,15 @@ const eidReader = async (
|
|
|
346
350
|
buf[i] = await cardAccessStream.read();
|
|
347
351
|
}
|
|
348
352
|
cardAccessBuf = buf;
|
|
349
|
-
|
|
353
|
+
debugLog(
|
|
354
|
+
'EID',
|
|
350
355
|
`[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`
|
|
351
356
|
);
|
|
352
357
|
} catch (readErr) {
|
|
353
358
|
const readMsg =
|
|
354
359
|
readErr instanceof Error ? readErr.message : String(readErr);
|
|
355
|
-
|
|
360
|
+
debugLog(
|
|
361
|
+
'EID',
|
|
356
362
|
`[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`
|
|
357
363
|
);
|
|
358
364
|
}
|
|
@@ -368,7 +374,8 @@ const eidReader = async (
|
|
|
368
374
|
const paceInfo = parsePACEInfoFromCardAccess(cardAccessBuf);
|
|
369
375
|
|
|
370
376
|
if (paceInfo) {
|
|
371
|
-
|
|
377
|
+
debugLog(
|
|
378
|
+
'EID',
|
|
372
379
|
`[EID] PACE available: oid=${paceInfo.oid} parameterId=${paceInfo.parameterId}`
|
|
373
380
|
);
|
|
374
381
|
// PACE is available - derive key from MRZ and perform PACE
|
|
@@ -387,9 +394,10 @@ const eidReader = async (
|
|
|
387
394
|
progressCallback
|
|
388
395
|
);
|
|
389
396
|
hasPACESucceeded = true;
|
|
390
|
-
|
|
397
|
+
debugLog('EID', '[EID] PACE succeeded');
|
|
391
398
|
} else {
|
|
392
|
-
|
|
399
|
+
debugLog(
|
|
400
|
+
'EID',
|
|
393
401
|
'[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC'
|
|
394
402
|
);
|
|
395
403
|
}
|
|
@@ -397,11 +405,12 @@ const eidReader = async (
|
|
|
397
405
|
// PACE protocol exchange failed — fall back to BAC
|
|
398
406
|
const msg =
|
|
399
407
|
paceError instanceof Error ? paceError.message : String(paceError);
|
|
400
|
-
|
|
408
|
+
debugLog(
|
|
409
|
+
'EID',
|
|
401
410
|
`[EID] PACE protocol failed (${msg}), falling back to BAC`
|
|
402
411
|
);
|
|
403
412
|
if (paceError instanceof Error && paceError.stack) {
|
|
404
|
-
|
|
413
|
+
debugLog('EID', '[EID] PACE error stack:', paceError.stack);
|
|
405
414
|
}
|
|
406
415
|
}
|
|
407
416
|
}
|
|
@@ -490,7 +499,7 @@ const eidReader = async (
|
|
|
490
499
|
mimeType: mimeType,
|
|
491
500
|
};
|
|
492
501
|
} catch (error) {
|
|
493
|
-
|
|
502
|
+
debugLog('EID', 'Error reading passport data', error);
|
|
494
503
|
} finally {
|
|
495
504
|
await passportService.close();
|
|
496
505
|
}
|
|
@@ -56,6 +56,40 @@ primary safeguard. A single noisy frame is not trusted; the consensus is.
|
|
|
56
56
|
|
|
57
57
|
---
|
|
58
58
|
|
|
59
|
+
## 2a. Turkish e-ID document numbers: leading serial LETTER must be preserved
|
|
60
|
+
|
|
61
|
+
**Issue.** Turkish identity cards put a **serial letter** at the start of the
|
|
62
|
+
9-char document number (e.g. `A53032352`, `A49M38410`). Empirically — verified
|
|
63
|
+
against real cards — **Türkiye computes the document-number check digit over the
|
|
64
|
+
number with the LEADING letter EXCLUDED** (interior letters are kept), which
|
|
65
|
+
diverges from strict ICAO 9303 (where `A`=10 is weighted in). So a genuine card
|
|
66
|
+
`A53032352` (printed check digit `5`) **fails** the strict ICAO check (which
|
|
67
|
+
wants `7`), even though the card is valid — `icaoCheckDigit("53032352") === "5"`.
|
|
68
|
+
The TD1 **composite** on these cards is likewise non-standard (no interpretation
|
|
69
|
+
of the leading letter reproduces it), so it is **not** a reliable anchor.
|
|
70
|
+
|
|
71
|
+
**Why it matters.** The leading letter is **real** and **must be preserved**: the
|
|
72
|
+
**NFC eID chip derives its BAC/PACE access key from the document number exactly
|
|
73
|
+
as stored** (with the letter). Rewriting `A53032352`→`453032352` — as the
|
|
74
|
+
ambiguous-char recovery would, to satisfy strict ICAO — makes the **chip read
|
|
75
|
+
FAIL**. (The OCR-B `4` glyph closely resembles `A`, which is why the recovery
|
|
76
|
+
used to "correct" it.)
|
|
77
|
+
|
|
78
|
+
**Mitigation.** `acceptsTurkishLetterPrefix` (in `mrz.utils.ts`; ported to
|
|
79
|
+
`mrz_utils.dart`) accepts a TUR TD1 whose only failing checks are
|
|
80
|
+
document-number/composite **when** the document number starts with a letter
|
|
81
|
+
**and** `icaoCheckDigit(documentNumber.slice(1))` matches the printed
|
|
82
|
+
document-number check digit. The parse is then accepted **as-is**, preserving the
|
|
83
|
+
letter; the composite is deliberately not required. The subsequent **NFC read is
|
|
84
|
+
the authoritative confirmation** — it only succeeds with the correct
|
|
85
|
+
(letter-preserving) number. Contrast `A49M38410`, whose letters validate under
|
|
86
|
+
*standard* ICAO and so are kept by the normal path.
|
|
87
|
+
|
|
88
|
+
Covered by the "Turkish e-ID … leading serial LETTER" tests in
|
|
89
|
+
`mrz.ambiguous.test.ts` (RN) and `mrz_ambiguous_test.dart` (Flutter).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
59
93
|
## 3. Filler-letter normalisation is conservative in name fields (by design)
|
|
60
94
|
|
|
61
95
|
**Issue.** The `<` filler is frequently OCR'd as a letter (`K`, and under glare
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import EventSource, { type EventSourceListener } from 'react-native-sse';
|
|
2
|
+
import { debugLog } from './debug.utils';
|
|
2
3
|
|
|
3
4
|
export type SignalingMessageType =
|
|
4
5
|
| 'offer'
|
|
@@ -51,7 +52,7 @@ export class SignalingClient {
|
|
|
51
52
|
}
|
|
52
53
|
const url = `${this.baseUrl}/api/app/mobile/video-sessions/${this.sessionId}/signaling/stream?${urlParams.toString()}`;
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
debugLog('SignalingClient', '[SignalingClient] Connecting to SSE:', url);
|
|
55
56
|
|
|
56
57
|
this.eventSource = new EventSource(url, {
|
|
57
58
|
pollingInterval: 0, // 0 means default/no polling if SSE is real
|
|
@@ -59,7 +60,7 @@ export class SignalingClient {
|
|
|
59
60
|
|
|
60
61
|
const listener: EventSourceListener = (event) => {
|
|
61
62
|
if (event.type === 'open') {
|
|
62
|
-
|
|
63
|
+
debugLog('SignalingClient', '[SignalingClient] Connected');
|
|
63
64
|
this.onConnected?.();
|
|
64
65
|
} else if (event.type === 'error') {
|
|
65
66
|
console.error(
|
|
@@ -108,8 +109,8 @@ export class SignalingClient {
|
|
|
108
109
|
};
|
|
109
110
|
|
|
110
111
|
// Ping handler - server sends pings to keep connection alive
|
|
111
|
-
const pingListener = (
|
|
112
|
-
|
|
112
|
+
const pingListener = (_event: any) => {
|
|
113
|
+
debugLog('SignalingClient', '[SignalingClient] Received ping');
|
|
113
114
|
// Just acknowledge by doing nothing, server-side heartbeat keeps connection alive
|
|
114
115
|
};
|
|
115
116
|
|
|
@@ -117,7 +118,11 @@ export class SignalingClient {
|
|
|
117
118
|
const sessionEndedListener = (event: any) => {
|
|
118
119
|
try {
|
|
119
120
|
const data = JSON.parse(event.data || '{}');
|
|
120
|
-
|
|
121
|
+
debugLog(
|
|
122
|
+
'SignalingClient',
|
|
123
|
+
'[SignalingClient] Session ended:',
|
|
124
|
+
data.state
|
|
125
|
+
);
|
|
121
126
|
if (this.onSessionEnded) {
|
|
122
127
|
this.onSessionEnded(data.state);
|
|
123
128
|
}
|
|
@@ -3,21 +3,56 @@ import { Buffer } from 'buffer';
|
|
|
3
3
|
import { gcm } from '@noble/ciphers/aes';
|
|
4
4
|
import { randomBytes } from '@noble/ciphers/webcrypto';
|
|
5
5
|
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
6
|
-
import
|
|
6
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
7
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
8
|
+
import httpClient, { setSessionToken } from './http-client';
|
|
9
|
+
|
|
10
|
+
// Must stay byte-for-byte identical to the backend derivation
|
|
11
|
+
// (web-app/src/lib/crypto-utils.ts).
|
|
12
|
+
const SESSION_KEY_KDF_INFO = 'trustchex-session-key-v2';
|
|
13
|
+
const SESSION_KEY_KDF_VERSION = 2;
|
|
14
|
+
|
|
15
|
+
type AuthResponse = {
|
|
16
|
+
serverPublicKey: string;
|
|
17
|
+
kdfVersion?: number;
|
|
18
|
+
sessionToken?: string;
|
|
19
|
+
};
|
|
7
20
|
|
|
8
21
|
const getSessionKey = async (apiUrl: string, sessionId: string) => {
|
|
9
22
|
const clientPrivateKey = secp256k1.utils.randomPrivateKey();
|
|
10
23
|
const clientPublicKey = secp256k1.getPublicKey(clientPrivateKey);
|
|
11
24
|
const serverResponse = await httpClient.post<
|
|
12
|
-
|
|
13
|
-
{ clientPublicKey: string }
|
|
25
|
+
AuthResponse,
|
|
26
|
+
{ clientPublicKey: string; kdfVersion: number }
|
|
14
27
|
>(`${apiUrl}/verification-sessions/${sessionId}/auth`, {
|
|
15
28
|
clientPublicKey: Buffer.from(clientPublicKey).toString('hex'),
|
|
29
|
+
kdfVersion: SESSION_KEY_KDF_VERSION,
|
|
16
30
|
});
|
|
31
|
+
|
|
32
|
+
if (serverResponse.sessionToken) {
|
|
33
|
+
setSessionToken(serverResponse.sessionToken);
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
const sharedKey = secp256k1.getSharedSecret(
|
|
18
37
|
clientPrivateKey,
|
|
19
38
|
Buffer.from(serverResponse.serverPublicKey, 'hex').toString('hex')
|
|
20
39
|
);
|
|
40
|
+
|
|
41
|
+
// The server echoes the kdfVersion it actually used; older backends don't
|
|
42
|
+
// send the field and derive the legacy key.
|
|
43
|
+
if (serverResponse.kdfVersion === SESSION_KEY_KDF_VERSION) {
|
|
44
|
+
// sharedKey is a compressed secp256k1 point; byte 0 is the 0x02/0x03
|
|
45
|
+
// parity prefix and carries no secret entropy.
|
|
46
|
+
const derived = hkdf(
|
|
47
|
+
sha256,
|
|
48
|
+
Buffer.from(sharedKey).subarray(1),
|
|
49
|
+
Buffer.from(sessionId, 'utf-8'),
|
|
50
|
+
SESSION_KEY_KDF_INFO,
|
|
51
|
+
32
|
|
52
|
+
);
|
|
53
|
+
return Buffer.from(derived).toString('hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
21
56
|
const sessionKey = (
|
|
22
57
|
Buffer.from(new Date().getUTCDate().toString(), 'utf-8').toString('hex') +
|
|
23
58
|
Buffer.from(sharedKey).toString('hex')
|
|
@@ -27,7 +62,9 @@ const getSessionKey = async (apiUrl: string, sessionId: string) => {
|
|
|
27
62
|
};
|
|
28
63
|
|
|
29
64
|
const encryptWithAes = (data: string, key: string) => {
|
|
30
|
-
|
|
65
|
+
// Standard 96-bit GCM IV; the backend reads the nonce from the payload, so
|
|
66
|
+
// this stays compatible with servers that previously saw 32-byte nonces.
|
|
67
|
+
const nonce = randomBytes(12);
|
|
31
68
|
const dataBytes = Buffer.from(data, 'utf-8');
|
|
32
69
|
const aes = gcm(Buffer.from(key, 'hex'), nonce);
|
|
33
70
|
const ciphertext = aes.encrypt(dataBytes);
|
|
@@ -1,9 +1,29 @@
|
|
|
1
|
-
import { debugLog } from './debug.utils';
|
|
1
|
+
import { debugLog, logWarn } from './debug.utils';
|
|
2
|
+
|
|
3
|
+
// Hostname (optionally with port) — rejects paths, credentials, and other
|
|
4
|
+
// URL components smuggled into the app-url segment.
|
|
5
|
+
const HOST_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9.-]*(:\d{1,5})?$/;
|
|
6
|
+
|
|
7
|
+
const getUrlScheme = (url: string): string => {
|
|
8
|
+
const match = url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
|
|
9
|
+
return match ? match[1].toLowerCase() : '';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// The SDK uploads identity documents and biometrics to the derived baseUrl, so
|
|
13
|
+
// cleartext destinations are only allowed in development builds.
|
|
14
|
+
const isAllowedScheme = (scheme: string): boolean => {
|
|
15
|
+
return scheme === 'https' || (__DEV__ && scheme === 'http');
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const getUrlHost = (url: string): string => {
|
|
19
|
+
const withoutScheme = url.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '');
|
|
20
|
+
return (withoutScheme.split('/')[0] ?? '').toLowerCase();
|
|
21
|
+
};
|
|
2
22
|
|
|
3
23
|
const handleDeepLink = ({ url }: { url: string }) => {
|
|
4
24
|
debugLog('handleDeepLink', 'Received URL:', url);
|
|
5
|
-
const
|
|
6
|
-
const route = url.
|
|
25
|
+
const scheme = getUrlScheme(url);
|
|
26
|
+
const route = scheme ? url.slice(`${scheme}://`.length) : url;
|
|
7
27
|
const segments = route.split('/');
|
|
8
28
|
debugLog('handleDeepLink', 'Parsed segments:', segments);
|
|
9
29
|
let baseUrl = '';
|
|
@@ -17,16 +37,28 @@ const handleDeepLink = ({ url }: { url: string }) => {
|
|
|
17
37
|
sessionId = segments[i + 1] ?? '';
|
|
18
38
|
debugLog('handleDeepLink', 'Found sessionId:', sessionId);
|
|
19
39
|
} else if (segments[i] === 'app-url') {
|
|
20
|
-
|
|
40
|
+
const host = segments[i + 1] ?? '';
|
|
41
|
+
if (!HOST_PATTERN.test(host)) {
|
|
42
|
+
logWarn('handleDeepLink', 'Rejected invalid app-url host');
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const hostScheme = scheme === 'http' ? 'http' : 'https';
|
|
46
|
+
if (!isAllowedScheme(hostScheme)) {
|
|
47
|
+
logWarn('handleDeepLink', 'Rejected non-HTTPS app-url');
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
baseUrl = `${hostScheme}://${host}`;
|
|
21
51
|
debugLog('handleDeepLink', 'Found baseUrl:', baseUrl);
|
|
22
52
|
}
|
|
23
53
|
}
|
|
24
54
|
|
|
25
55
|
// If no app-url segment found, derive baseUrl from the URL itself
|
|
26
56
|
if (!baseUrl && sessionId) {
|
|
27
|
-
const match = url.match(/^(https
|
|
28
|
-
if (match) {
|
|
29
|
-
baseUrl = match[1]
|
|
57
|
+
const match = url.match(/^(https?):\/\/([^/]+)/);
|
|
58
|
+
if (match && isAllowedScheme(match[1].toLowerCase())) {
|
|
59
|
+
baseUrl = `${match[1].toLowerCase()}://${match[2]}`;
|
|
60
|
+
} else if (match) {
|
|
61
|
+
logWarn('handleDeepLink', 'Rejected non-HTTPS deep link URL');
|
|
30
62
|
}
|
|
31
63
|
}
|
|
32
64
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { trackApiCall, trackError } from './analytics.utils';
|
|
2
|
+
import { debugLog } from './debug.utils';
|
|
2
3
|
|
|
3
4
|
interface ErrorResponse {
|
|
4
5
|
message: string;
|
|
@@ -46,6 +47,17 @@ export class InternalServerError extends HttpClientError {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// Short-lived per-verification token issued by the backend during the session
|
|
51
|
+
// key exchange; sent on every subsequent call. Older backends don't issue one,
|
|
52
|
+
// in which case requests go out without the header (backwards compatible).
|
|
53
|
+
let sessionToken: string | null = null;
|
|
54
|
+
|
|
55
|
+
export const setSessionToken = (token: string | null) => {
|
|
56
|
+
sessionToken = token;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const getSessionToken = () => sessionToken;
|
|
60
|
+
|
|
49
61
|
const request = async <TResponse, TBody>(
|
|
50
62
|
httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
51
63
|
url: string,
|
|
@@ -64,10 +76,11 @@ const request = async <TResponse, TBody>(
|
|
|
64
76
|
let statusCode = 0;
|
|
65
77
|
let success = false;
|
|
66
78
|
|
|
67
|
-
|
|
79
|
+
debugLog('HTTP', `${httpMethod} ${url}`);
|
|
68
80
|
if (body) {
|
|
69
|
-
|
|
70
|
-
'
|
|
81
|
+
debugLog(
|
|
82
|
+
'HTTP',
|
|
83
|
+
'Request body:',
|
|
71
84
|
JSON.stringify(body).substring(0, 200) + '...'
|
|
72
85
|
);
|
|
73
86
|
}
|
|
@@ -77,14 +90,16 @@ const request = async <TResponse, TBody>(
|
|
|
77
90
|
method: httpMethod,
|
|
78
91
|
headers: {
|
|
79
92
|
'Content-Type': 'application/json',
|
|
93
|
+
...(sessionToken ? { 'X-Session-Token': sessionToken } : {}),
|
|
80
94
|
},
|
|
81
95
|
body: body ? JSON.stringify(body) : undefined,
|
|
82
96
|
});
|
|
83
97
|
|
|
84
98
|
statusCode = response.status;
|
|
85
99
|
success = response.ok;
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
debugLog(
|
|
101
|
+
'HTTP',
|
|
102
|
+
`Response status: ${statusCode} ${response.ok ? '✓' : '✗'}`
|
|
88
103
|
);
|
|
89
104
|
|
|
90
105
|
let responseJson = null;
|
|
@@ -92,14 +107,15 @@ const request = async <TResponse, TBody>(
|
|
|
92
107
|
try {
|
|
93
108
|
responseJson = await response.json();
|
|
94
109
|
if (responseJson) {
|
|
95
|
-
|
|
96
|
-
'
|
|
110
|
+
debugLog(
|
|
111
|
+
'HTTP',
|
|
112
|
+
'Response body:',
|
|
97
113
|
JSON.stringify(responseJson).substring(0, 200) + '...'
|
|
98
114
|
);
|
|
99
115
|
}
|
|
100
116
|
} catch (error) {
|
|
101
117
|
// Invalid JSON response
|
|
102
|
-
|
|
118
|
+
debugLog('HTTP', 'Response body: (non-JSON)');
|
|
103
119
|
}
|
|
104
120
|
|
|
105
121
|
// Track API call performance (non-blocking)
|