@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 { DG1File } from "./lds/icao/dg1File.js";
8
8
  import { DG2File } from "./lds/icao/dg2File.js";
9
9
  import { Buffer } from 'buffer';
10
10
  import { InputStream } from "./java/inputStream.js";
11
+ import { debugLog } from "../Libs/debug.utils.js";
12
+ import { diagnostics } from "../Libs/diagnostics.js";
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 => {
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 => {
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.
@@ -147,7 +175,7 @@ function convertJP2IfNeeded(imageBuffer, mimeType) {
147
175
  mimeType
148
176
  };
149
177
  }
150
- console.debug('[EID] Face image is JP2, attempting conversion…');
178
+ debugLog('EID', '[EID] Face image is JP2, attempting conversion…');
151
179
  try {
152
180
  // Lazy-load jpeg2000 to avoid crashing if it fails to import
153
181
  const {
@@ -156,16 +184,16 @@ function convertJP2IfNeeded(imageBuffer, mimeType) {
156
184
  const jpx = new JpxImage();
157
185
  jpx.parse(imageBuffer);
158
186
  const tile = jpx.tiles[0];
159
- console.debug(`[EID] JP2 decoded: ${tile.width}x${tile.height}, ${jpx.componentsCount} channels, ${tile.items.length} bytes`);
187
+ debugLog('EID', `[EID] JP2 decoded: ${tile.width}x${tile.height}, ${jpx.componentsCount} channels, ${tile.items.length} bytes`);
160
188
  const pngBytes = pixelsToPng(tile.items, tile.width, tile.height, jpx.componentsCount);
161
189
  const pngBase64 = Buffer.from(pngBytes).toString('base64');
162
- console.debug(`[EID] Converted JP2 → PNG (${pngBytes.length} bytes, ${pngBase64.length} base64 chars)`);
190
+ debugLog('EID', `[EID] Converted JP2 → PNG (${pngBytes.length} bytes, ${pngBase64.length} base64 chars)`);
163
191
  return {
164
192
  base64: pngBase64,
165
193
  mimeType: 'image/png'
166
194
  };
167
195
  } catch (err) {
168
- console.debug('[EID] JP2 conversion failed:', err);
196
+ debugLog('EID', '[EID] JP2 conversion failed:', err);
169
197
  return {
170
198
  base64: imageBuffer.toString('base64'),
171
199
  mimeType
@@ -274,8 +302,40 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
274
302
  let progress = 0;
275
303
  const nfcManagerCardService = new NFCManagerCardService();
276
304
  const passportService = new EIDService(nfcManagerCardService, EIDService.NORMAL_MAX_TRANSCEIVE_LENGTH, EIDService.DEFAULT_MAX_BLOCKSIZE, false, true);
305
+
306
+ // Structured NFC diagnostics: record each step's outcome + timing + APDU SW
307
+ // into the session collector so the result screen can surface device-specific
308
+ // read failures. Best-effort and side-effect-only — never alters the flow.
309
+ diagnostics.nfcAttemptStarted();
310
+ const stepTimer = () => {
311
+ const t0 = Date.now();
312
+ return (step, ok, error) => {
313
+ const msg = error instanceof Error ? error.message : error ? String(error) : undefined;
314
+ // Store only the APDU status word and a COARSE category — never the raw
315
+ // error text, which could in theory embed field/PII values. The SW + step
316
+ // name + category carry all the actionable signal.
317
+ diagnostics.nfcStep({
318
+ step,
319
+ ok,
320
+ ms: Date.now() - t0,
321
+ sw: msg ? extractStatusWord(msg) : undefined,
322
+ error: msg ? categorizeNfcError(msg) : undefined
323
+ });
324
+ };
325
+ };
277
326
  try {
278
- await passportService.open();
327
+ {
328
+ const done = stepTimer();
329
+ try {
330
+ await passportService.open();
331
+ // Reaching open() without throwing means the chip powered on / was seen.
332
+ diagnostics.nfcChipDetected();
333
+ done('open', true);
334
+ } catch (e) {
335
+ done('open', false, e);
336
+ throw e;
337
+ }
338
+ }
279
339
  progress = 1;
280
340
  if (progressCallback) {
281
341
  progressCallback(progress);
@@ -285,12 +345,17 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
285
345
  // Explicitly SELECT MF first so the SFI=0x1C context is correct,
286
346
  // regardless of which application the chip activates on power-on.
287
347
  let hasPACESucceeded = false;
288
- try {
289
- await passportService.sendSelectMF();
290
- console.debug('[EID] SELECT MF OK');
291
- } catch (mfErr) {
292
- const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
293
- console.debug(`[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`);
348
+ {
349
+ const done = stepTimer();
350
+ try {
351
+ await passportService.sendSelectMF();
352
+ done('selectMF', true);
353
+ debugLog('EID', '[EID] SELECT MF OK');
354
+ } catch (mfErr) {
355
+ done('selectMF', false, mfErr);
356
+ const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
357
+ debugLog('EID', `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`);
358
+ }
294
359
  }
295
360
  progress = 2;
296
361
  if (progressCallback) {
@@ -300,19 +365,24 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
300
365
  // Read EF.CardAccess — kept in a separate try/catch so we can distinguish
301
366
  // "file read failure" from "PACE protocol failure" in the logs.
302
367
  let cardAccessBuf = null;
303
- try {
304
- const cardAccessStream = passportService.getInputStream(EIDService.EF_CARD_ACCESS);
305
- await cardAccessStream.init();
306
- const cardAccessLen = cardAccessStream.getLength();
307
- const buf = Buffer.alloc(cardAccessLen);
308
- for (let i = 0; i < cardAccessLen; i++) {
309
- buf[i] = await cardAccessStream.read();
368
+ {
369
+ const done = stepTimer();
370
+ try {
371
+ const cardAccessStream = passportService.getInputStream(EIDService.EF_CARD_ACCESS);
372
+ await cardAccessStream.init();
373
+ const cardAccessLen = cardAccessStream.getLength();
374
+ const buf = Buffer.alloc(cardAccessLen);
375
+ for (let i = 0; i < cardAccessLen; i++) {
376
+ buf[i] = await cardAccessStream.read();
377
+ }
378
+ cardAccessBuf = buf;
379
+ done('readCardAccess', true);
380
+ debugLog('EID', `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`);
381
+ } catch (readErr) {
382
+ done('readCardAccess', false, readErr);
383
+ const readMsg = readErr instanceof Error ? readErr.message : String(readErr);
384
+ debugLog('EID', `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`);
310
385
  }
311
- cardAccessBuf = buf;
312
- console.debug(`[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`);
313
- } catch (readErr) {
314
- const readMsg = readErr instanceof Error ? readErr.message : String(readErr);
315
- console.debug(`[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`);
316
386
  }
317
387
  progress = 3;
318
388
  if (progressCallback) {
@@ -320,11 +390,17 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
320
390
  }
321
391
 
322
392
  // If EF.CardAccess was read successfully, attempt PACE.
393
+ if (cardAccessBuf === null) {
394
+ // EF.CardAccess unreadable (the readCardAccess step already recorded the
395
+ // failure/SW) → PACE can't be attempted; BAC will be used.
396
+ diagnostics.nfcPaceFellBackToBac('no_card_access');
397
+ }
323
398
  if (cardAccessBuf !== null) {
324
399
  try {
325
400
  const paceInfo = parsePACEInfoFromCardAccess(cardAccessBuf);
326
401
  if (paceInfo) {
327
- console.debug(`[EID] PACE available: oid=${paceInfo.oid} parameterId=${paceInfo.parameterId}`);
402
+ debugLog('EID', `[EID] PACE available: oid=${paceInfo.oid} parameterId=${paceInfo.parameterId}`);
403
+ diagnostics.nfcSetAuth('PACE', paceInfo.oid);
328
404
  // PACE is available - derive key from MRZ and perform PACE
329
405
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
330
406
  const paceKey = PACEKeySpec.createMRZKey(bacKey.getKeySeedForPACE());
@@ -332,18 +408,27 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
332
408
  if (progressCallback) {
333
409
  progressCallback(progress);
334
410
  }
335
- await passportService.doPACE(paceKey, paceInfo.oid, paceInfo.parameterId, progressCallback);
336
- hasPACESucceeded = true;
337
- console.debug('[EID] PACE succeeded');
411
+ const done = stepTimer();
412
+ try {
413
+ await passportService.doPACE(paceKey, paceInfo.oid, paceInfo.parameterId, progressCallback);
414
+ hasPACESucceeded = true;
415
+ done('paceAuth', true);
416
+ debugLog('EID', '[EID] PACE succeeded');
417
+ } catch (paceErr) {
418
+ done('paceAuth', false, paceErr);
419
+ throw paceErr;
420
+ }
338
421
  } else {
339
- console.debug('[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC');
422
+ diagnostics.nfcPaceFellBackToBac('no_pace_info');
423
+ debugLog('EID', '[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC');
340
424
  }
341
425
  } catch (paceError) {
342
426
  // PACE protocol exchange failed — fall back to BAC
427
+ diagnostics.nfcPaceFellBackToBac('pace_failed');
343
428
  const msg = paceError instanceof Error ? paceError.message : String(paceError);
344
- console.debug(`[EID] PACE protocol failed (${msg}), falling back to BAC`);
429
+ debugLog('EID', `[EID] PACE protocol failed (${msg}), falling back to BAC`);
345
430
  if (paceError instanceof Error && paceError.stack) {
346
- console.debug('[EID] PACE error stack:', paceError.stack);
431
+ debugLog('EID', '[EID] PACE error stack:', paceError.stack);
347
432
  }
348
433
  }
349
434
  }
@@ -353,7 +438,16 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
353
438
  }
354
439
 
355
440
  // Select Applet for MRTD
356
- await passportService.sendSelectApplet(hasPACESucceeded);
441
+ {
442
+ const done = stepTimer();
443
+ try {
444
+ await passportService.sendSelectApplet(hasPACESucceeded);
445
+ done('selectApplet', true);
446
+ } catch (e) {
447
+ done('selectApplet', false, e);
448
+ throw e;
449
+ }
450
+ }
357
451
  progress = 10;
358
452
  if (progressCallback) {
359
453
  progressCallback(progress);
@@ -366,8 +460,16 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
366
460
  await efComStream.read();
367
461
  } catch (error) {
368
462
  // EF_COM not available -> try to do BAC
463
+ diagnostics.nfcSetAuth('BAC');
369
464
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
370
- await passportService.doBAC(bacKey);
465
+ const done = stepTimer();
466
+ try {
467
+ await passportService.doBAC(bacKey);
468
+ done('bacAuth', true);
469
+ } catch (e) {
470
+ done('bacAuth', false, e);
471
+ throw e;
472
+ }
371
473
  }
372
474
  }
373
475
  progress = 20;
@@ -376,16 +478,25 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
376
478
  }
377
479
 
378
480
  // Read DG1 (MRZ Info)
379
- const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
380
- await dg1InputStream.init();
381
- const dg1File = new DG1File(undefined, dg1InputStream);
382
- const mrzInfo = await dg1File.getMRZInfo();
481
+ const dg1Done = stepTimer();
482
+ let mrzInfo;
483
+ try {
484
+ const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
485
+ await dg1InputStream.init();
486
+ const dg1File = new DG1File(undefined, dg1InputStream);
487
+ mrzInfo = await dg1File.getMRZInfo();
488
+ dg1Done('readDG1', true);
489
+ } catch (e) {
490
+ dg1Done('readDG1', false, e);
491
+ throw e;
492
+ }
383
493
  progress = 30;
384
494
  if (progressCallback) {
385
495
  progressCallback(progress);
386
496
  }
387
497
 
388
498
  // Read DG2 (Face Image)
499
+ const dg2Done = stepTimer();
389
500
  const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
390
501
  await dg2InputStream.init();
391
502
  InputStream.onRead((totalBytesRead, fileLength) => {
@@ -398,7 +509,14 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
398
509
  let imageAsBase64 = '';
399
510
  let mimeType = '';
400
511
  const allFaceImageInfos = [];
401
- const faceInfos = await dg2File.getFaceInfos();
512
+ let faceInfos;
513
+ try {
514
+ faceInfos = await dg2File.getFaceInfos();
515
+ dg2Done('readDG2', true);
516
+ } catch (e) {
517
+ dg2Done('readDG2', false, e);
518
+ throw e;
519
+ }
402
520
  for (let faceInfo of faceInfos) {
403
521
  allFaceImageInfos.push(...faceInfo.getFaceImageInfos());
404
522
  }
@@ -417,13 +535,16 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
417
535
  if (progressCallback) {
418
536
  progressCallback(progress);
419
537
  }
538
+ diagnostics.nfcResult(true);
420
539
  return {
421
540
  mrz: mrzInfo,
422
541
  faceImage: imageAsBase64,
423
542
  mimeType: mimeType
424
543
  };
425
544
  } catch (error) {
426
- console.debug('Error reading passport data', error);
545
+ const msg = error instanceof Error ? error.message : String(error);
546
+ diagnostics.nfcResult(false, categorizeNfcError(msg));
547
+ debugLog('EID', 'Error reading passport data', error);
427
548
  } finally {
428
549
  await passportService.close();
429
550
  }
@@ -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,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import EventSource from 'react-native-sse';
4
+ import { debugLog } from "./debug.utils.js";
4
5
  export class SignalingClient {
5
6
  eventSource = null;
6
7
  constructor(baseUrl, sessionId, onMessage, identificationId, onSessionEnded) {
@@ -19,13 +20,13 @@ export class SignalingClient {
19
20
  urlParams.append('identificationId', this.identificationId);
20
21
  }
21
22
  const url = `${this.baseUrl}/api/app/mobile/video-sessions/${this.sessionId}/signaling/stream?${urlParams.toString()}`;
22
- console.log('[SignalingClient] Connecting to SSE:', url);
23
+ debugLog('SignalingClient', '[SignalingClient] Connecting to SSE:', url);
23
24
  this.eventSource = new EventSource(url, {
24
25
  pollingInterval: 0 // 0 means default/no polling if SSE is real
25
26
  });
26
27
  const listener = event => {
27
28
  if (event.type === 'open') {
28
- console.log('[SignalingClient] Connected');
29
+ debugLog('SignalingClient', '[SignalingClient] Connected');
29
30
  this.onConnected?.();
30
31
  } else if (event.type === 'error') {
31
32
  console.error('[SignalingClient] Connection error:', JSON.stringify(event, null, 2));
@@ -64,8 +65,8 @@ export class SignalingClient {
64
65
  };
65
66
 
66
67
  // Ping handler - server sends pings to keep connection alive
67
- const pingListener = event => {
68
- console.log('[SignalingClient] Received ping');
68
+ const pingListener = _event => {
69
+ debugLog('SignalingClient', '[SignalingClient] Received ping');
69
70
  // Just acknowledge by doing nothing, server-side heartbeat keeps connection alive
70
71
  };
71
72
 
@@ -73,7 +74,7 @@ export class SignalingClient {
73
74
  const sessionEndedListener = event => {
74
75
  try {
75
76
  const data = JSON.parse(event.data || '{}');
76
- console.log('[SignalingClient] Session ended:', data.state);
77
+ debugLog('SignalingClient', '[SignalingClient] Session ended:', data.state);
77
78
  if (this.onSessionEnded) {
78
79
  this.onSessionEnded(data.state);
79
80
  }
@@ -5,19 +5,41 @@ import { Buffer } from 'buffer';
5
5
  import { gcm } from '@noble/ciphers/aes';
6
6
  import { randomBytes } from '@noble/ciphers/webcrypto';
7
7
  import { secp256k1 } from '@noble/curves/secp256k1';
8
- import httpClient from "./http-client.js";
8
+ import { hkdf } from '@noble/hashes/hkdf';
9
+ import { sha256 } from '@noble/hashes/sha2';
10
+ import httpClient, { setSessionToken } from "./http-client.js";
11
+
12
+ // Must stay byte-for-byte identical to the backend derivation
13
+ // (web-app/src/lib/crypto-utils.ts).
14
+ const SESSION_KEY_KDF_INFO = 'trustchex-session-key-v2';
15
+ const SESSION_KEY_KDF_VERSION = 2;
9
16
  const getSessionKey = async (apiUrl, sessionId) => {
10
17
  const clientPrivateKey = secp256k1.utils.randomPrivateKey();
11
18
  const clientPublicKey = secp256k1.getPublicKey(clientPrivateKey);
12
19
  const serverResponse = await httpClient.post(`${apiUrl}/verification-sessions/${sessionId}/auth`, {
13
- clientPublicKey: Buffer.from(clientPublicKey).toString('hex')
20
+ clientPublicKey: Buffer.from(clientPublicKey).toString('hex'),
21
+ kdfVersion: SESSION_KEY_KDF_VERSION
14
22
  });
23
+ if (serverResponse.sessionToken) {
24
+ setSessionToken(serverResponse.sessionToken);
25
+ }
15
26
  const sharedKey = secp256k1.getSharedSecret(clientPrivateKey, Buffer.from(serverResponse.serverPublicKey, 'hex').toString('hex'));
27
+
28
+ // The server echoes the kdfVersion it actually used; older backends don't
29
+ // send the field and derive the legacy key.
30
+ if (serverResponse.kdfVersion === SESSION_KEY_KDF_VERSION) {
31
+ // sharedKey is a compressed secp256k1 point; byte 0 is the 0x02/0x03
32
+ // parity prefix and carries no secret entropy.
33
+ const derived = hkdf(sha256, Buffer.from(sharedKey).subarray(1), Buffer.from(sessionId, 'utf-8'), SESSION_KEY_KDF_INFO, 32);
34
+ return Buffer.from(derived).toString('hex');
35
+ }
16
36
  const sessionKey = (Buffer.from(new Date().getUTCDate().toString(), 'utf-8').toString('hex') + Buffer.from(sharedKey).toString('hex')).substring(0, 32);
17
37
  return sessionKey;
18
38
  };
19
39
  const encryptWithAes = (data, key) => {
20
- const nonce = randomBytes(32);
40
+ // Standard 96-bit GCM IV; the backend reads the nonce from the payload, so
41
+ // this stays compatible with servers that previously saw 32-byte nonces.
42
+ const nonce = randomBytes(12);
21
43
  const dataBytes = Buffer.from(data, 'utf-8');
22
44
  const aes = gcm(Buffer.from(key, 'hex'), nonce);
23
45
  const ciphertext = aes.encrypt(dataBytes);
@@ -1,12 +1,30 @@
1
1
  "use strict";
2
2
 
3
- import { debugLog } from "./debug.utils.js";
3
+ import { debugLog, logWarn } from "./debug.utils.js";
4
+
5
+ // Hostname (optionally with port) — rejects paths, credentials, and other
6
+ // URL components smuggled into the app-url segment.
7
+ const HOST_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9.-]*(:\d{1,5})?$/;
8
+ const getUrlScheme = url => {
9
+ const match = url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
10
+ return match ? match[1].toLowerCase() : '';
11
+ };
12
+
13
+ // The SDK uploads identity documents and biometrics to the derived baseUrl, so
14
+ // cleartext destinations are only allowed in development builds.
15
+ const isAllowedScheme = scheme => {
16
+ return scheme === 'https' || __DEV__ && scheme === 'http';
17
+ };
18
+ export const getUrlHost = url => {
19
+ const withoutScheme = url.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '');
20
+ return (withoutScheme.split('/')[0] ?? '').toLowerCase();
21
+ };
4
22
  const handleDeepLink = ({
5
23
  url
6
24
  }) => {
7
25
  debugLog('handleDeepLink', 'Received URL:', url);
8
- const isHttps = url.includes('https');
9
- const route = url.replace(/http(s)?(:)?(\/\/)?/g, '');
26
+ const scheme = getUrlScheme(url);
27
+ const route = scheme ? url.slice(`${scheme}://`.length) : url;
10
28
  const segments = route.split('/');
11
29
  debugLog('handleDeepLink', 'Parsed segments:', segments);
12
30
  let baseUrl = '';
@@ -16,16 +34,28 @@ const handleDeepLink = ({
16
34
  sessionId = segments[i + 1] ?? '';
17
35
  debugLog('handleDeepLink', 'Found sessionId:', sessionId);
18
36
  } else if (segments[i] === 'app-url') {
19
- baseUrl = `${isHttps ? 'https' : 'http'}://${segments[i + 1]}`;
37
+ const host = segments[i + 1] ?? '';
38
+ if (!HOST_PATTERN.test(host)) {
39
+ logWarn('handleDeepLink', 'Rejected invalid app-url host');
40
+ continue;
41
+ }
42
+ const hostScheme = scheme === 'http' ? 'http' : 'https';
43
+ if (!isAllowedScheme(hostScheme)) {
44
+ logWarn('handleDeepLink', 'Rejected non-HTTPS app-url');
45
+ continue;
46
+ }
47
+ baseUrl = `${hostScheme}://${host}`;
20
48
  debugLog('handleDeepLink', 'Found baseUrl:', baseUrl);
21
49
  }
22
50
  }
23
51
 
24
52
  // If no app-url segment found, derive baseUrl from the URL itself
25
53
  if (!baseUrl && sessionId) {
26
- const match = url.match(/^(https?:\/\/[^/]+)/);
27
- if (match) {
28
- baseUrl = match[1];
54
+ const match = url.match(/^(https?):\/\/([^/]+)/);
55
+ if (match && isAllowedScheme(match[1].toLowerCase())) {
56
+ baseUrl = `${match[1].toLowerCase()}://${match[2]}`;
57
+ } else if (match) {
58
+ logWarn('handleDeepLink', 'Rejected non-HTTPS deep link URL');
29
59
  }
30
60
  }
31
61
  debugLog('handleDeepLink', 'Returning:', {
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Builds the support diagnostic report from a scan session.
5
+ *
6
+ * Produces three things the result screen turns into a reviewable email:
7
+ * - `diagnostics.json` — technical signals (device, camera, MRZ, NFC steps).
8
+ * - `scan-data.json` — the scanned identity data + raw MRZ (PII; the user
9
+ * reviews it in the draft and chooses to send).
10
+ * - a short, size-bounded plain-text BODY summary that always fits an email.
11
+ *
12
+ * The full detail lives in the JSON attachments; the email body is intentionally
13
+ * compact so the draft opens reliably on every mail client.
14
+ */
15
+
16
+ /** ICAO 9303 7-3-1 weighted check digit (local copy; mirrors mrz.utils). */
17
+ const icaoCheckDigit = str => {
18
+ const weights = [7, 3, 1];
19
+ let sum = 0;
20
+ for (let i = 0; i < str.length; i++) {
21
+ const c = str[i];
22
+ const v = c === '<' ? 0 : c >= '0' && c <= '9' ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
23
+ sum = (sum + weights[i % 3] * v) % 10;
24
+ }
25
+ return String(sum);
26
+ };
27
+ /** Compute per-field check-digit pass/fail for a TD1 MRZ (best-effort). */
28
+ const computeTd1CheckDigits = mrzText => {
29
+ if (!mrzText) return undefined;
30
+ const lines = mrzText.split('\n').map(l => l.trim()).filter(Boolean);
31
+ if (lines.length < 2 || lines[0].length < 30 || lines[1].length < 30) {
32
+ return undefined; // only TD1 handled here; other formats omitted from summary
33
+ }
34
+ const l1 = lines[0];
35
+ const l2 = lines[1];
36
+ const result = {};
37
+ const check = (data, printed) => icaoCheckDigit(data) === printed ? 'pass' : 'fail';
38
+
39
+ // Turkish convention: a doc number may carry a leading serial LETTER, and the
40
+ // doc-number check digit is computed over the DIGITS ONLY (letter dropped).
41
+ // The TD1 composite then also diverges from strict ICAO for such cards (no
42
+ // interpretation of the letter reproduces it). To avoid reporting a misleading
43
+ // "fail" on a card the SDK legitimately accepted, detect the letter-prefix and
44
+ // treat both the doc-number and composite as validated under that convention.
45
+ const docData = l1.slice(5, 14);
46
+ const docPrinted = l1[14];
47
+ const hasLetterPrefix = /^[A-Z]/.test(docData);
48
+ const strictDocOk = icaoCheckDigit(docData) === docPrinted;
49
+ const turkishDocOk = hasLetterPrefix && icaoCheckDigit(docData.slice(1)) === docPrinted;
50
+ const docOk = strictDocOk || turkishDocOk;
51
+ result.documentNumber = docOk ? 'pass' : 'fail';
52
+ result.birthDate = check(l2.slice(0, 6), l2[6]);
53
+ result.expiry = check(l2.slice(8, 14), l2[14]);
54
+ const composite = l1.slice(5, 30) + l2.slice(0, 7) + l2.slice(8, 15) + l2.slice(18, 29);
55
+ const compStrict = check(composite, l2[29]);
56
+ // If the strict composite fails ONLY because of the Turkish letter-prefix
57
+ // doc number (which itself passed under the digits-only rule), don't surface a
58
+ // misleading failure — the SDK accepts these cards.
59
+ result.composite = compStrict === 'fail' && hasLetterPrefix && docOk ? 'pass' : compStrict;
60
+ // The Turkish convention was the deciding factor only when strict ICAO would
61
+ // have failed the doc number but the digits-only rule passes it.
62
+ const turkishLetterPrefix = !strictDocOk && turkishDocOk;
63
+ return {
64
+ checkDigits: result,
65
+ turkishLetterPrefix
66
+ };
67
+ };
68
+ const MAX_BODY_BYTES = 4096;
69
+ const yesNo = b => b ? 'yes' : 'no';
70
+
71
+ /** Assemble the full report bundle. Pure — no I/O, no native calls. */
72
+ export const buildDiagnosticReport = params => {
73
+ const {
74
+ diag,
75
+ device,
76
+ scan,
77
+ sdkVersion,
78
+ sessionId
79
+ } = params;
80
+ const computed = computeTd1CheckDigits(scan.mrzText);
81
+ const checkDigits = diag.mrz.checkDigits ?? computed?.checkDigits;
82
+ // Set the Turkish-letter-prefix flag from the final accepted MRZ (the collector
83
+ // can't know it during scanning; the report does, from the MRZ text).
84
+ const turkishLetterPrefixApplied = diag.mrz.turkishLetterPrefixApplied || computed?.turkishLetterPrefix === true;
85
+
86
+ // --- diagnostics.json (technical, low-PII) ---
87
+ const diagnosticsObj = {
88
+ schemaVersion: diag.schemaVersion,
89
+ generatedAt: diag.generatedAt,
90
+ sessionId: sessionId ?? null,
91
+ sdk: {
92
+ platform: device.platform,
93
+ sdkVersion,
94
+ buildNumber: device.buildNumber
95
+ },
96
+ device,
97
+ scan: {
98
+ documentType: scan.documentType ?? diag.documentType ?? null,
99
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
100
+ totalDurationMs: diag.totalDurationMs,
101
+ camera: diag.camera
102
+ },
103
+ mrz: {
104
+ ...diag.mrz,
105
+ checkDigits: checkDigits ?? null,
106
+ turkishLetterPrefixApplied
107
+ },
108
+ nfc: diag.nfc,
109
+ errors: diag.errors
110
+ };
111
+
112
+ // --- scan-data.json (PII; user reviews before sending) ---
113
+ const scanDataObj = {
114
+ documentType: scan.documentType ?? diag.documentType ?? null,
115
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
116
+ mrzText: scan.mrzText ?? null,
117
+ fields: scan.mrzFields ?? null,
118
+ barcodeValue: scan.barcodeValue ?? null
119
+ };
120
+
121
+ // --- compact email body (always under MAX_BODY_BYTES) ---
122
+ const nfc = diag.nfc;
123
+ const nfcLine = nfc.attempted ? `${nfc.authProtocol}${nfc.paceFellBackToBac ? '→BAC' : ''} · ${nfc.succeeded ? 'success' : `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'})`}` : 'not attempted';
124
+ const checksLine = checkDigits ? Object.entries(checkDigits).map(([k, v]) => `${k}:${v}`).join(' ') : 'n/a';
125
+ const body = ['TRUSTCHEX SCAN REPORT', `Device: ${device.brand} ${device.model} · ${device.systemName} ${device.systemVersion}${device.apiLevel ? ` (API ${device.apiLevel})` : ''}`, `SDK: ${sdkVersion} (build ${device.buildNumber}) · ${device.manufacturer} · ${device.deviceId}`, `Document: ${scanDataObj.documentType ?? 'unknown'} · source: ${scanDataObj.dataSource ?? 'unknown'}`, `MRZ: ${diag.mrz.reachedStable ? 'stable' : 'NOT stable'} after ${diag.mrz.framesProcessed} frames · checks: ${checksLine}`, `Camera: ${diag.camera.frames} frames · brightness ${diag.camera.avgBrightness} (low:${yesNo(diag.camera.hadLowBrightness)} blur:${yesNo(diag.camera.hadBlurryFrames)})`, `NFC: ${nfcLine}`, sessionId ? `Session: ${sessionId}` : '', '', 'Describe the issue here:', '', '', '— Full diagnostics and document images are attached. Please review before sending; do not edit the attachments. —'].filter(Boolean).join('\n');
126
+ const subject = `Trustchex scan report — ${scanDataObj.documentType ?? 'doc'} — ${device.brand} ${device.model} — v${sdkVersion}${sessionId ? ` — ${sessionId}` : ''}`;
127
+ return {
128
+ diagnosticsJson: JSON.stringify(diagnosticsObj, null, 2),
129
+ scanDataJson: JSON.stringify(scanDataObj, null, 2),
130
+ body: body.length > MAX_BODY_BYTES ? body.slice(0, MAX_BODY_BYTES - 1) + '…' : body,
131
+ subject
132
+ };
133
+ };