@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.
Files changed (75) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +19 -21
  2. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
  3. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
  4. package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
  5. package/lib/module/Screens/Static/ResultScreen.js +103 -107
  6. package/lib/module/Shared/Components/DebugOverlay.js +5 -2
  7. package/lib/module/Shared/Components/IdentityDocumentCamera.js +53 -22
  8. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
  9. package/lib/module/Shared/EIDReader/eidReader.js +15 -14
  10. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +34 -0
  11. package/lib/module/Shared/Libs/SignalingClient.js +6 -5
  12. package/lib/module/Shared/Libs/crypto.utils.js +25 -3
  13. package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
  14. package/lib/module/Shared/Libs/http-client.js +19 -6
  15. package/lib/module/Shared/Libs/mrz.utils.js +193 -33
  16. package/lib/module/Shared/Libs/mrzFrameAggregator.js +45 -1
  17. package/lib/module/Shared/Libs/promise.utils.js +4 -3
  18. package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
  19. package/lib/module/Shared/Services/AnalyticsService.js +8 -2
  20. package/lib/module/Shared/Services/DataUploadService.js +114 -127
  21. package/lib/module/Shared/Services/VideoSessionService.js +4 -3
  22. package/lib/module/Translation/Resources/en.js +1 -0
  23. package/lib/module/Translation/Resources/tr.js +1 -0
  24. package/lib/module/version.js +1 -1
  25. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  26. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  27. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  30. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
  31. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  32. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  33. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
  34. package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
  35. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
  36. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
  38. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  39. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +22 -0
  41. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  42. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  43. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
  44. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
  45. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  46. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
  48. package/lib/typescript/src/Translation/Resources/en.d.ts +1 -0
  49. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  50. package/lib/typescript/src/Translation/Resources/tr.d.ts +1 -0
  51. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  52. package/lib/typescript/src/version.d.ts +1 -1
  53. package/package.json +13 -3
  54. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
  55. package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
  56. package/src/Screens/Static/ResultScreen.tsx +126 -161
  57. package/src/Shared/Components/DebugOverlay.tsx +5 -2
  58. package/src/Shared/Components/IdentityDocumentCamera.tsx +55 -22
  59. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
  60. package/src/Shared/EIDReader/eidReader.ts +23 -14
  61. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +34 -0
  62. package/src/Shared/Libs/SignalingClient.ts +10 -5
  63. package/src/Shared/Libs/crypto.utils.ts +41 -4
  64. package/src/Shared/Libs/deeplink.utils.ts +39 -7
  65. package/src/Shared/Libs/http-client.ts +24 -8
  66. package/src/Shared/Libs/mrz.utils.ts +200 -34
  67. package/src/Shared/Libs/mrzFrameAggregator.ts +53 -1
  68. package/src/Shared/Libs/promise.utils.ts +11 -5
  69. package/src/Shared/Libs/shuffle.utils.ts +15 -0
  70. package/src/Shared/Services/AnalyticsService.ts +17 -1
  71. package/src/Shared/Services/DataUploadService.ts +155 -166
  72. package/src/Shared/Services/VideoSessionService.ts +15 -3
  73. package/src/Translation/Resources/en.ts +1 -0
  74. package/src/Translation/Resources/tr.ts +1 -0
  75. 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
- const mrzAggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 2 }));
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
- // Check if we have enough consistent valid reads
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
- // Either: the multi-frame voted consensus has converged (valid + stable),
1047
- (consensus.stable && parsedMRZData?.valid === true) ||
1048
- // or the legacy N-identical-reads path is satisfied.
1049
- (validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS &&
1050
- parsedMRZData?.valid === true &&
1051
- areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields));
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
- nextStep === 'COMPLETED' && styles.topZoneTextSuccess,
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
- nextStep === 'COMPLETED'
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: status === 'SCANNING' ? 3 : 2,
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
- console.debug('[EID] Face image is JP2, attempting conversion…');
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
- console.debug(
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
- console.debug(
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
- console.debug('[EID] JP2 conversion failed:', err);
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
- console.debug('[EID] SELECT MF OK');
325
+ debugLog('EID', '[EID] SELECT MF OK');
323
326
  } catch (mfErr) {
324
327
  const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
325
- console.debug(
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
- console.debug(
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
- console.debug(
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
- console.debug(
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
- console.debug('[EID] PACE succeeded');
397
+ debugLog('EID', '[EID] PACE succeeded');
391
398
  } else {
392
- console.debug(
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
- console.debug(
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
- console.debug('[EID] PACE error stack:', paceError.stack);
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
- console.debug('Error reading passport data', error);
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
- console.log('[SignalingClient] Connecting to SSE:', url);
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
- console.log('[SignalingClient] Connected');
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 = (event: any) => {
112
- console.log('[SignalingClient] Received ping');
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
- console.log('[SignalingClient] Session ended:', data.state);
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 httpClient from './http-client';
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
- { serverPublicKey: string },
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
- const nonce = randomBytes(32);
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 isHttps = url.includes('https');
6
- const route = url.replace(/http(s)?(:)?(\/\/)?/g, '');
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
- baseUrl = `${isHttps ? 'https' : 'http'}://${segments[i + 1]}`;
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
- console.log(`[HTTP] ${httpMethod} ${url}`);
79
+ debugLog('HTTP', `${httpMethod} ${url}`);
68
80
  if (body) {
69
- console.log(
70
- '[HTTP] Request body:',
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
- console.log(
87
- `[HTTP] Response status: ${statusCode} ${response.ok ? '✓' : '✗'}`
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
- console.log(
96
- '[HTTP] Response body:',
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
- console.log('[HTTP] Response body: (non-JSON)');
118
+ debugLog('HTTP', 'Response body: (non-JSON)');
103
119
  }
104
120
 
105
121
  // Track API call performance (non-blocking)