@trustchex/react-native-sdk 1.475.1 → 1.481.1

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 (108) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +76 -24
  2. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
  3. package/ios/Camera/TrustchexCameraView.swift +20 -15
  4. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
  5. package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
  6. package/lib/module/Screens/Static/ResultScreen.js +145 -113
  7. package/lib/module/Shared/Components/DebugOverlay.js +5 -2
  8. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  9. package/lib/module/Shared/Components/IdentityDocumentCamera.js +185 -30
  10. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
  11. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  12. package/lib/module/Shared/EIDReader/eidReader.js +159 -38
  13. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  14. package/lib/module/Shared/Libs/SignalingClient.js +6 -5
  15. package/lib/module/Shared/Libs/crypto.utils.js +25 -3
  16. package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
  17. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  18. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  19. package/lib/module/Shared/Libs/http-client.js +19 -6
  20. package/lib/module/Shared/Libs/mrz.utils.js +193 -33
  21. package/lib/module/Shared/Libs/mrzFrameAggregator.js +74 -3
  22. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  23. package/lib/module/Shared/Libs/promise.utils.js +4 -3
  24. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  25. package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
  26. package/lib/module/Shared/Services/AnalyticsService.js +8 -2
  27. package/lib/module/Shared/Services/DataUploadService.js +114 -127
  28. package/lib/module/Shared/Services/VideoSessionService.js +4 -3
  29. package/lib/module/Translation/Resources/en.js +6 -0
  30. package/lib/module/Translation/Resources/tr.js +6 -0
  31. package/lib/module/Trustchex.js +20 -4
  32. package/lib/module/version.js +1 -1
  33. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  34. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  35. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  38. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  39. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  41. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  42. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
  43. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  44. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  45. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  46. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  48. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
  49. package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
  50. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
  51. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  52. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  53. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  54. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  55. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  56. package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
  57. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  58. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  59. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +55 -0
  60. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  61. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  62. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  63. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  64. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  65. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  66. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
  67. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
  68. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  69. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  70. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
  71. package/lib/typescript/src/Translation/Resources/en.d.ts +6 -0
  72. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  73. package/lib/typescript/src/Translation/Resources/tr.d.ts +6 -0
  74. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  75. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  76. package/lib/typescript/src/version.d.ts +1 -1
  77. package/package.json +17 -3
  78. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
  79. package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
  80. package/src/Screens/Static/ResultScreen.tsx +172 -162
  81. package/src/Shared/Components/DebugOverlay.tsx +5 -2
  82. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  83. package/src/Shared/Components/IdentityDocumentCamera.tsx +200 -29
  84. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  85. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
  86. package/src/Shared/Components/NavigationManager.tsx +7 -7
  87. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  88. package/src/Shared/EIDReader/eidReader.ts +181 -52
  89. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  90. package/src/Shared/Libs/SignalingClient.ts +10 -5
  91. package/src/Shared/Libs/crypto.utils.ts +41 -4
  92. package/src/Shared/Libs/deeplink.utils.ts +39 -7
  93. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  94. package/src/Shared/Libs/diagnostics.ts +251 -0
  95. package/src/Shared/Libs/http-client.ts +24 -8
  96. package/src/Shared/Libs/mrz.utils.ts +200 -34
  97. package/src/Shared/Libs/mrzFrameAggregator.ts +112 -2
  98. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  99. package/src/Shared/Libs/promise.utils.ts +11 -5
  100. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  101. package/src/Shared/Libs/shuffle.utils.ts +15 -0
  102. package/src/Shared/Services/AnalyticsService.ts +17 -1
  103. package/src/Shared/Services/DataUploadService.ts +155 -166
  104. package/src/Shared/Services/VideoSessionService.ts +15 -3
  105. package/src/Translation/Resources/en.ts +7 -0
  106. package/src/Translation/Resources/tr.ts +7 -0
  107. package/src/Trustchex.tsx +26 -3
  108. package/src/version.ts +1 -1
@@ -8,6 +8,34 @@ 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';
12
+ import { diagnostics } from '../Libs/diagnostics';
13
+
14
+ /**
15
+ * Extract an APDU status word (hex, e.g. "6982") from an error message if the
16
+ * protocol layer included one. Returns undefined when none is present. Kept
17
+ * deliberately loose — the protocol senders embed "SW=...." / "(6982)" forms.
18
+ */
19
+ const extractStatusWord = (msg: string): string | undefined => {
20
+ const m = msg.match(/\b(?:SW[:= ]?)?([0-9a-fA-F]{4})\b/);
21
+ return m ? m[1].toUpperCase() : undefined;
22
+ };
23
+
24
+ /** Categorise an NFC error message into a coarse, non-PII bucket. */
25
+ const categorizeNfcError = (msg: string): string => {
26
+ const m = msg.toLowerCase();
27
+ if (m.includes('cancel')) return 'user_cancelled';
28
+ if (m.includes('lost') || m.includes('steady')) return 'tag_lost';
29
+ if (m.includes('timeout')) return 'timeout';
30
+ if (m.includes('not supported') || m.includes('unsupported')) {
31
+ return 'unsupported';
32
+ }
33
+ if (m.includes('not enabled') || m.includes('disabled')) return 'not_enabled';
34
+ if (m.includes('auth') || m.includes('mutual') || m.includes('security')) {
35
+ return 'auth_failed';
36
+ }
37
+ return 'reading_error';
38
+ };
11
39
 
12
40
  // --- Minimal PNG encoder (pure JS, no native deps) ---
13
41
  // Uses deflate stored (uncompressed) blocks for O(n) encoding speed.
@@ -161,14 +189,15 @@ function convertJP2IfNeeded(
161
189
  return { base64: imageBuffer.toString('base64'), mimeType };
162
190
  }
163
191
 
164
- console.debug('[EID] Face image is JP2, attempting conversion…');
192
+ debugLog('EID', '[EID] Face image is JP2, attempting conversion…');
165
193
  try {
166
194
  // Lazy-load jpeg2000 to avoid crashing if it fails to import
167
195
  const { JpxImage } = require('jpeg2000');
168
196
  const jpx = new JpxImage();
169
197
  jpx.parse(imageBuffer);
170
198
  const tile = jpx.tiles[0];
171
- console.debug(
199
+ debugLog(
200
+ 'EID',
172
201
  `[EID] JP2 decoded: ${tile.width}x${tile.height}, ${jpx.componentsCount} channels, ${tile.items.length} bytes`
173
202
  );
174
203
  const pngBytes = pixelsToPng(
@@ -178,12 +207,13 @@ function convertJP2IfNeeded(
178
207
  jpx.componentsCount
179
208
  );
180
209
  const pngBase64 = Buffer.from(pngBytes).toString('base64');
181
- console.debug(
210
+ debugLog(
211
+ 'EID',
182
212
  `[EID] Converted JP2 → PNG (${pngBytes.length} bytes, ${pngBase64.length} base64 chars)`
183
213
  );
184
214
  return { base64: pngBase64, mimeType: 'image/png' };
185
215
  } catch (err) {
186
- console.debug('[EID] JP2 conversion failed:', err);
216
+ debugLog('EID', '[EID] JP2 conversion failed:', err);
187
217
  return { base64: imageBuffer.toString('base64'), mimeType };
188
218
  }
189
219
  }
@@ -304,8 +334,40 @@ const eidReader = async (
304
334
  true
305
335
  );
306
336
 
337
+ // Structured NFC diagnostics: record each step's outcome + timing + APDU SW
338
+ // into the session collector so the result screen can surface device-specific
339
+ // read failures. Best-effort and side-effect-only — never alters the flow.
340
+ diagnostics.nfcAttemptStarted();
341
+ const stepTimer = () => {
342
+ const t0 = Date.now();
343
+ return (step: string, ok: boolean, error?: unknown) => {
344
+ const msg = error instanceof Error ? error.message : error ? String(error) : undefined;
345
+ // Store only the APDU status word and a COARSE category — never the raw
346
+ // error text, which could in theory embed field/PII values. The SW + step
347
+ // name + category carry all the actionable signal.
348
+ diagnostics.nfcStep({
349
+ step,
350
+ ok,
351
+ ms: Date.now() - t0,
352
+ sw: msg ? extractStatusWord(msg) : undefined,
353
+ error: msg ? categorizeNfcError(msg) : undefined,
354
+ });
355
+ };
356
+ };
357
+
307
358
  try {
308
- await passportService.open();
359
+ {
360
+ const done = stepTimer();
361
+ try {
362
+ await passportService.open();
363
+ // Reaching open() without throwing means the chip powered on / was seen.
364
+ diagnostics.nfcChipDetected();
365
+ done('open', true);
366
+ } catch (e) {
367
+ done('open', false, e);
368
+ throw e;
369
+ }
370
+ }
309
371
 
310
372
  progress = 1;
311
373
  if (progressCallback) {
@@ -317,14 +379,20 @@ const eidReader = async (
317
379
  // regardless of which application the chip activates on power-on.
318
380
  let hasPACESucceeded = false;
319
381
 
320
- try {
321
- await passportService.sendSelectMF();
322
- console.debug('[EID] SELECT MF OK');
323
- } catch (mfErr) {
324
- const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
325
- console.debug(
326
- `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`
327
- );
382
+ {
383
+ const done = stepTimer();
384
+ try {
385
+ await passportService.sendSelectMF();
386
+ done('selectMF', true);
387
+ debugLog('EID', '[EID] SELECT MF OK');
388
+ } catch (mfErr) {
389
+ done('selectMF', false, mfErr);
390
+ const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
391
+ debugLog(
392
+ 'EID',
393
+ `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`
394
+ );
395
+ }
328
396
  }
329
397
 
330
398
  progress = 2;
@@ -335,26 +403,33 @@ const eidReader = async (
335
403
  // Read EF.CardAccess — kept in a separate try/catch so we can distinguish
336
404
  // "file read failure" from "PACE protocol failure" in the logs.
337
405
  let cardAccessBuf: Buffer | null = null;
338
- try {
339
- const cardAccessStream = passportService.getInputStream(
340
- EIDService.EF_CARD_ACCESS
341
- );
342
- await cardAccessStream.init();
343
- const cardAccessLen = cardAccessStream.getLength();
344
- const buf = Buffer.alloc(cardAccessLen);
345
- for (let i = 0; i < cardAccessLen; i++) {
346
- buf[i] = await cardAccessStream.read();
406
+ {
407
+ const done = stepTimer();
408
+ try {
409
+ const cardAccessStream = passportService.getInputStream(
410
+ EIDService.EF_CARD_ACCESS
411
+ );
412
+ await cardAccessStream.init();
413
+ const cardAccessLen = cardAccessStream.getLength();
414
+ const buf = Buffer.alloc(cardAccessLen);
415
+ for (let i = 0; i < cardAccessLen; i++) {
416
+ buf[i] = await cardAccessStream.read();
417
+ }
418
+ cardAccessBuf = buf;
419
+ done('readCardAccess', true);
420
+ debugLog(
421
+ 'EID',
422
+ `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`
423
+ );
424
+ } catch (readErr) {
425
+ done('readCardAccess', false, readErr);
426
+ const readMsg =
427
+ readErr instanceof Error ? readErr.message : String(readErr);
428
+ debugLog(
429
+ 'EID',
430
+ `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`
431
+ );
347
432
  }
348
- cardAccessBuf = buf;
349
- console.debug(
350
- `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`
351
- );
352
- } catch (readErr) {
353
- const readMsg =
354
- readErr instanceof Error ? readErr.message : String(readErr);
355
- console.debug(
356
- `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`
357
- );
358
433
  }
359
434
 
360
435
  progress = 3;
@@ -363,14 +438,21 @@ const eidReader = async (
363
438
  }
364
439
 
365
440
  // If EF.CardAccess was read successfully, attempt PACE.
441
+ if (cardAccessBuf === null) {
442
+ // EF.CardAccess unreadable (the readCardAccess step already recorded the
443
+ // failure/SW) → PACE can't be attempted; BAC will be used.
444
+ diagnostics.nfcPaceFellBackToBac('no_card_access');
445
+ }
366
446
  if (cardAccessBuf !== null) {
367
447
  try {
368
448
  const paceInfo = parsePACEInfoFromCardAccess(cardAccessBuf);
369
449
 
370
450
  if (paceInfo) {
371
- console.debug(
451
+ debugLog(
452
+ 'EID',
372
453
  `[EID] PACE available: oid=${paceInfo.oid} parameterId=${paceInfo.parameterId}`
373
454
  );
455
+ diagnostics.nfcSetAuth('PACE', paceInfo.oid);
374
456
  // PACE is available - derive key from MRZ and perform PACE
375
457
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
376
458
  const paceKey = PACEKeySpec.createMRZKey(bacKey.getKeySeedForPACE());
@@ -380,28 +462,39 @@ const eidReader = async (
380
462
  progressCallback(progress);
381
463
  }
382
464
 
383
- await passportService.doPACE(
384
- paceKey,
385
- paceInfo.oid,
386
- paceInfo.parameterId,
387
- progressCallback
388
- );
389
- hasPACESucceeded = true;
390
- console.debug('[EID] PACE succeeded');
465
+ const done = stepTimer();
466
+ try {
467
+ await passportService.doPACE(
468
+ paceKey,
469
+ paceInfo.oid,
470
+ paceInfo.parameterId,
471
+ progressCallback
472
+ );
473
+ hasPACESucceeded = true;
474
+ done('paceAuth', true);
475
+ debugLog('EID', '[EID] PACE succeeded');
476
+ } catch (paceErr) {
477
+ done('paceAuth', false, paceErr);
478
+ throw paceErr;
479
+ }
391
480
  } else {
392
- console.debug(
481
+ diagnostics.nfcPaceFellBackToBac('no_pace_info');
482
+ debugLog(
483
+ 'EID',
393
484
  '[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC'
394
485
  );
395
486
  }
396
487
  } catch (paceError) {
397
488
  // PACE protocol exchange failed — fall back to BAC
489
+ diagnostics.nfcPaceFellBackToBac('pace_failed');
398
490
  const msg =
399
491
  paceError instanceof Error ? paceError.message : String(paceError);
400
- console.debug(
492
+ debugLog(
493
+ 'EID',
401
494
  `[EID] PACE protocol failed (${msg}), falling back to BAC`
402
495
  );
403
496
  if (paceError instanceof Error && paceError.stack) {
404
- console.debug('[EID] PACE error stack:', paceError.stack);
497
+ debugLog('EID', '[EID] PACE error stack:', paceError.stack);
405
498
  }
406
499
  }
407
500
  }
@@ -412,7 +505,16 @@ const eidReader = async (
412
505
  }
413
506
 
414
507
  // Select Applet for MRTD
415
- await passportService.sendSelectApplet(hasPACESucceeded);
508
+ {
509
+ const done = stepTimer();
510
+ try {
511
+ await passportService.sendSelectApplet(hasPACESucceeded);
512
+ done('selectApplet', true);
513
+ } catch (e) {
514
+ done('selectApplet', false, e);
515
+ throw e;
516
+ }
517
+ }
416
518
 
417
519
  progress = 10;
418
520
  if (progressCallback) {
@@ -427,8 +529,16 @@ const eidReader = async (
427
529
  await efComStream.read();
428
530
  } catch (error) {
429
531
  // EF_COM not available -> try to do BAC
532
+ diagnostics.nfcSetAuth('BAC');
430
533
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
431
- await passportService.doBAC(bacKey);
534
+ const done = stepTimer();
535
+ try {
536
+ await passportService.doBAC(bacKey);
537
+ done('bacAuth', true);
538
+ } catch (e) {
539
+ done('bacAuth', false, e);
540
+ throw e;
541
+ }
432
542
  }
433
543
  }
434
544
 
@@ -438,10 +548,18 @@ const eidReader = async (
438
548
  }
439
549
 
440
550
  // Read DG1 (MRZ Info)
441
- const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
442
- await dg1InputStream.init();
443
- const dg1File = new DG1File(undefined, dg1InputStream);
444
- const mrzInfo = await dg1File.getMRZInfo();
551
+ const dg1Done = stepTimer();
552
+ let mrzInfo: MRZInfo;
553
+ try {
554
+ const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
555
+ await dg1InputStream.init();
556
+ const dg1File = new DG1File(undefined, dg1InputStream);
557
+ mrzInfo = await dg1File.getMRZInfo();
558
+ dg1Done('readDG1', true);
559
+ } catch (e) {
560
+ dg1Done('readDG1', false, e);
561
+ throw e;
562
+ }
445
563
 
446
564
  progress = 30;
447
565
  if (progressCallback) {
@@ -449,6 +567,7 @@ const eidReader = async (
449
567
  }
450
568
 
451
569
  // Read DG2 (Face Image)
570
+ const dg2Done = stepTimer();
452
571
  const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
453
572
  await dg2InputStream.init();
454
573
  InputStream.onRead((totalBytesRead, fileLength) => {
@@ -462,7 +581,14 @@ const eidReader = async (
462
581
  let mimeType = '';
463
582
 
464
583
  const allFaceImageInfos: FaceImageInfo[] = [];
465
- const faceInfos = await dg2File.getFaceInfos();
584
+ let faceInfos;
585
+ try {
586
+ faceInfos = await dg2File.getFaceInfos();
587
+ dg2Done('readDG2', true);
588
+ } catch (e) {
589
+ dg2Done('readDG2', false, e);
590
+ throw e;
591
+ }
466
592
  for (let faceInfo of faceInfos) {
467
593
  allFaceImageInfos.push(...faceInfo.getFaceImageInfos());
468
594
  }
@@ -484,13 +610,16 @@ const eidReader = async (
484
610
  progressCallback(progress);
485
611
  }
486
612
 
613
+ diagnostics.nfcResult(true);
487
614
  return {
488
615
  mrz: mrzInfo,
489
616
  faceImage: imageAsBase64,
490
617
  mimeType: mimeType,
491
618
  };
492
619
  } catch (error) {
493
- console.debug('Error reading passport data', error);
620
+ const msg = error instanceof Error ? error.message : String(error);
621
+ diagnostics.nfcResult(false, categorizeNfcError(msg));
622
+ debugLog('EID', 'Error reading passport data', error);
494
623
  } finally {
495
624
  await passportService.close();
496
625
  }
@@ -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
@@ -84,6 +118,13 @@ Covered by the "real letters in names are not eaten" tests in `mrz.utils.test.ts
84
118
  30–44-char MRZ line below that floor, so the native pipeline **crops to the
85
119
  on-screen scan frame and upscales + grayscale/contrast-enhances** before OCR.
86
120
  The MRZ band must be reasonably filled within the on-screen guide.
121
+ - Two distinct regions exist in the native code (don't confuse them):
122
+ the **MRZ-OCR crop** (`buildScanFrameRoiImage`: top 25% / bottom 12% / sides
123
+ 4%, upscaled ≤2.5× to ≤2200px, grayscale, contrast 1.6×) and the
124
+ **brightness-sampling region** (the on-screen guide: 36% / 36% / 5%). Both are
125
+ the same on iOS and Android. The diagnostic report records the measured MRZ
126
+ band pixel height so you can tell when a device leaves the band below the
127
+ ~16 px/char floor.
87
128
  - **Glare / lamination.** Heavy glare on a glossy laminated card still degrades
88
129
  OCR; the enhancement helps but does not eliminate it. Steadying the card and
89
130
  reducing direct glare improves convergence.
@@ -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