@trustchex/react-native-sdk 1.486.0 → 1.488.0

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 (70) hide show
  1. package/lib/module/Shared/Components/EIDScanner.js +97 -37
  2. package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +9 -1
  3. package/lib/module/Shared/EIDReader/cardFileInputStream.js +25 -2
  4. package/lib/module/Shared/EIDReader/defaultFileSystem.js +56 -6
  5. package/lib/module/Shared/EIDReader/eidReader.js +124 -14
  6. package/lib/module/Shared/EIDReader/lds/icao/dg11File.js +23 -6
  7. package/lib/module/Shared/EIDReader/nfcManagerCardService.js +79 -9
  8. package/lib/module/Shared/EIDReader/protocol/paceDH.js +101 -0
  9. package/lib/module/Shared/EIDReader/protocol/paceErrors.js +34 -0
  10. package/lib/module/Shared/EIDReader/protocol/paceProtocol.js +53 -4
  11. package/lib/module/Shared/EIDReader/protocol/readBinaryAPDUSender.js +7 -3
  12. package/lib/module/Shared/Libs/country-display.utils.js +13 -1
  13. package/lib/module/Shared/Libs/diagnosticReport.js +24 -1
  14. package/lib/module/Shared/Libs/diagnostics.js +30 -8
  15. package/lib/module/Shared/Libs/mrz.utils.js +172 -25
  16. package/lib/module/Shared/Libs/mrzFrameAggregator.js +29 -1
  17. package/lib/module/Shared/Libs/mrzTransliteration.js +14 -1
  18. package/lib/module/Translation/Resources/en.js +12 -0
  19. package/lib/module/Translation/Resources/tr.js +12 -0
  20. package/lib/module/version.js +1 -1
  21. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -1
  23. package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts +3 -0
  24. package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts.map +1 -1
  25. package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts +4 -0
  26. package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts +9 -1
  30. package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts.map +1 -1
  31. package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts +56 -0
  32. package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts.map +1 -0
  33. package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts +26 -0
  34. package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts.map +1 -0
  35. package/lib/typescript/src/Shared/EIDReader/protocol/paceProtocol.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/EIDReader/protocol/readBinaryAPDUSender.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Libs/country-display.utils.d.ts.map +1 -1
  38. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -1
  39. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +9 -1
  40. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -1
  41. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +1 -0
  42. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  43. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  44. package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts.map +1 -1
  45. package/lib/typescript/src/Translation/Resources/en.d.ts +12 -0
  46. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  47. package/lib/typescript/src/Translation/Resources/tr.d.ts +12 -0
  48. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  49. package/lib/typescript/src/version.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/src/Shared/Components/EIDScanner.tsx +149 -68
  52. package/src/Shared/Components/IdentityDocumentCamera.constants.ts +9 -1
  53. package/src/Shared/EIDReader/cardFileInputStream.ts +30 -6
  54. package/src/Shared/EIDReader/defaultFileSystem.ts +75 -17
  55. package/src/Shared/EIDReader/eidReader.ts +154 -21
  56. package/src/Shared/EIDReader/lds/icao/dg11File.ts +28 -13
  57. package/src/Shared/EIDReader/nfcManagerCardService.ts +91 -13
  58. package/src/Shared/EIDReader/protocol/paceDH.ts +183 -0
  59. package/src/Shared/EIDReader/protocol/paceErrors.ts +41 -0
  60. package/src/Shared/EIDReader/protocol/paceProtocol.ts +72 -6
  61. package/src/Shared/EIDReader/protocol/readBinaryAPDUSender.ts +7 -4
  62. package/src/Shared/Libs/country-display.utils.ts +12 -1
  63. package/src/Shared/Libs/diagnosticReport.ts +32 -1
  64. package/src/Shared/Libs/diagnostics.ts +38 -14
  65. package/src/Shared/Libs/mrz.utils.ts +193 -28
  66. package/src/Shared/Libs/mrzFrameAggregator.ts +38 -3
  67. package/src/Shared/Libs/mrzTransliteration.ts +16 -3
  68. package/src/Translation/Resources/en.ts +19 -0
  69. package/src/Translation/Resources/tr.ts +19 -0
  70. package/src/version.ts +1 -1
@@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
6
  import NFCManager from 'react-native-nfc-manager';
7
7
  import DeviceInfo from 'react-native-device-info';
8
8
  import PermissionManager from "../Libs/permissions.utils.js";
9
+ import mrzUtils from "../Libs/mrz.utils.js";
9
10
  import { debugError } from "../Libs/debug.utils.js";
10
11
  import { eidReader } from "../EIDReader/eidReader.js";
11
12
  import NativeProgressBar from "./NativeProgressBar.js";
@@ -43,6 +44,13 @@ const EIDScanner = ({
43
44
  // Animation for pulse indicator
44
45
  const pulseAnim = useRef(new Animated.Value(1)).current;
45
46
 
47
+ // Strip MRZ filler (`<`) and trim; returns '' for empty/filler-only input so
48
+ // the UI can hide fields the chip didn't actually provide.
49
+ const cleanField = useCallback(value => {
50
+ if (!value) return '';
51
+ return value.replace(/</g, ' ').replace(/\s+/g, ' ').trim();
52
+ }, []);
53
+
46
54
  // Format date from YYMMDD to DD/MM/YYYY (matching Flutter SDK)
47
55
  const formatDate = useCallback(dateStr => {
48
56
  if (!dateStr) return '';
@@ -102,13 +110,20 @@ const EIDScanner = ({
102
110
  }, [i18n.language]);
103
111
  const [voiceGuidanceMessage, setVoiceGuidanceMessage] = useState();
104
112
  const [attemptNumber, setAttemptNumber] = useState(0);
105
- const getFieldsFromMRZ = useCallback(mrz => {
113
+ const getFieldsFromMRZ = useCallback((mrz, name) => {
114
+ // Prefer the resolved name (DG11 printed name / transliteration) over the
115
+ // raw DG1 split. Some chips — notably Turkish PACE passports — return a DG1
116
+ // name field with no `<<` separator, so getPrimaryIdentifier() holds the
117
+ // FULL name and getSecondaryIdentifier() is empty ("name as surname"). The
118
+ // resolved name corrects that; fall back to the raw split when absent.
119
+ const firstName = name?.displayFirst || mrz.getSecondaryIdentifier();
120
+ const lastName = name?.displayLast || mrz.getPrimaryIdentifier();
106
121
  return {
107
- documentCode: mrz.getDocumentCode(),
122
+ documentCode: mrzUtils.normalizeDocumentCode(mrz.getDocumentCode()),
108
123
  personalNumber: mrz.getPersonalNumber() || mrz.getOptionalData1(),
109
124
  documentNumber: mrz.getDocumentNumber(),
110
- firstName: mrz.getSecondaryIdentifier(),
111
- lastName: mrz.getPrimaryIdentifier(),
125
+ firstName,
126
+ lastName,
112
127
  birthDate: mrz.getDateOfBirth(),
113
128
  expirationDate: mrz.getDateOfExpiry(),
114
129
  issuingState: mrz.getIssuingState(),
@@ -190,7 +205,7 @@ const EIDScanner = ({
190
205
  appContext.setLastDocumentRead?.(documentReadResult);
191
206
  appContext.onDocumentRead?.(documentReadResult);
192
207
  if (appContext.skipNfcResultScreen && onScanSuccess) {
193
- onScanSuccess(getFieldsFromMRZ(passportData.mrz), passportData.faceImage, passportData.mimeType);
208
+ onScanSuccess(getFieldsFromMRZ(passportData.mrz, builtName), passportData.faceImage, passportData.mimeType);
194
209
  } else {
195
210
  setDocumentFaceImage(passportData.faceImage);
196
211
  setDocumentMRZInfo(passportData.mrz);
@@ -227,7 +242,37 @@ const EIDScanner = ({
227
242
  // Track EID scan failure with detailed metadata
228
243
  await trackEIDScanFailed(docType, errorType, errorMessage, scanDuration, currentAttempt, DeviceInfo.getModel(), DeviceInfo.getSystemVersion()).catch(() => {});
229
244
  console.debug('NFC scan error:', error);
230
- // Ignore error - let user retry
245
+
246
+ // Surface an accurate message instead of failing silently. eidReader
247
+ // attaches `nfcCategory` + `nfcCode`.
248
+ // - User-correctable failures (wrong doc data, card moved/too slow, NFC
249
+ // off) get a specific, actionable message and NO code — there's a clear
250
+ // action to take, a support code would just be noise.
251
+ // - Everything else is an unexpected/system error: show a generic message
252
+ // plus the reportable code (e.g. NFC-READ-6A82) so the user can quote it.
253
+ // - user_cancelled stays silent (the user dismissed the native NFC sheet).
254
+ const tagged = error;
255
+ const category = tagged?.nfcCategory ?? errorType;
256
+
257
+ // category -> actionable message key for the user-mistake cases.
258
+ const ACTIONABLE = {
259
+ auth_failed: 'eidScannerScreen.nfcAuthFailed',
260
+ tag_lost: 'eidScannerScreen.nfcTagLost',
261
+ timeout: 'eidScannerScreen.nfcTimeout',
262
+ not_enabled: 'eidScannerScreen.nfcNotEnabled'
263
+ };
264
+ if (category !== 'user_cancelled') {
265
+ const actionableKey = ACTIONABLE[category];
266
+ if (actionableKey) {
267
+ // Clear user action available — meaningful message, no support code.
268
+ Alert.alert(t('eidScannerScreen.error'), t(actionableKey));
269
+ } else {
270
+ // Unexpected/system error — generic message + reportable support code.
271
+ const errorCode = tagged?.nfcCode ?? 'NFC-READ';
272
+ const message = `${t('eidScannerScreen.nfcGenericError')}\n\n${t('eidScannerScreen.errorCode')}: ${errorCode}`;
273
+ Alert.alert(t('eidScannerScreen.error'), message);
274
+ }
275
+ }
231
276
  } finally {
232
277
  setIsScanning(false);
233
278
  setProgress(0);
@@ -387,36 +432,51 @@ const EIDScanner = ({
387
432
  })
388
433
  })
389
434
  })
390
- }), /*#__PURE__*/_jsxs(View, {
435
+ }), /*#__PURE__*/_jsx(View, {
391
436
  style: styles.idCardFields,
392
- children: [/*#__PURE__*/_jsx(IDCardField, {
393
- label: t('eidScannerScreen.surname'),
394
- value: documentName?.displayLast ?? documentMRZInfo.getPrimaryIdentifier()
395
- }), /*#__PURE__*/_jsx(IDCardField, {
396
- label: t('eidScannerScreen.name'),
397
- value: documentName?.displayFirst ?? documentMRZInfo.getSecondaryIdentifier()
398
- }), /*#__PURE__*/_jsx(IDCardField, {
399
- label: t('eidScannerScreen.documentNumber'),
400
- value: documentMRZInfo.getDocumentNumber()
401
- }), /*#__PURE__*/_jsx(IDCardField, {
402
- label: t('eidScannerScreen.birthDate'),
403
- value: formatDate(documentMRZInfo.getDateOfBirth())
404
- }), /*#__PURE__*/_jsx(IDCardField, {
405
- label: t('eidScannerScreen.expirationDate'),
406
- value: formatDate(documentMRZInfo.getDateOfExpiry())
407
- }), /*#__PURE__*/_jsx(IDCardField, {
408
- label: t('eidScannerScreen.gender'),
409
- value: formatGender(documentMRZInfo.getGender().getStrCode())
410
- }), /*#__PURE__*/_jsx(IDCardField, {
411
- label: t('eidScannerScreen.nationality'),
412
- value: formatNationality(documentMRZInfo.getNationality())
413
- }), /*#__PURE__*/_jsx(IDCardField, {
414
- label: t('eidScannerScreen.issuingState'),
415
- value: formatNationality(documentMRZInfo.getIssuingState())
416
- }), !!(documentMRZInfo.getPersonalNumber() || documentMRZInfo.getOptionalData1()) && /*#__PURE__*/_jsx(IDCardField, {
417
- label: t('eidScannerScreen.personalNumber'),
418
- value: documentMRZInfo.getPersonalNumber() || documentMRZInfo.getOptionalData1() || ''
419
- })]
437
+ children: (() => {
438
+ const surname = documentName?.displayLast ?? cleanField(documentMRZInfo.getPrimaryIdentifier());
439
+ const givenNames = documentName?.displayFirst ?? cleanField(documentMRZInfo.getSecondaryIdentifier());
440
+ const docNumber = cleanField(documentMRZInfo.getDocumentNumber());
441
+ const birthDate = formatDate(documentMRZInfo.getDateOfBirth());
442
+ const expiry = formatDate(documentMRZInfo.getDateOfExpiry());
443
+ const gender = formatGender(documentMRZInfo.getGender().getStrCode());
444
+ const nationality = formatNationality(documentMRZInfo.getNationality());
445
+ const issuingState = formatNationality(documentMRZInfo.getIssuingState());
446
+ const personalNumber = cleanField(documentMRZInfo.getPersonalNumber() || documentMRZInfo.getOptionalData1());
447
+ const fields = [{
448
+ label: t('eidScannerScreen.surname'),
449
+ value: surname
450
+ }, {
451
+ label: t('eidScannerScreen.name'),
452
+ value: givenNames
453
+ }, {
454
+ label: t('eidScannerScreen.documentNumber'),
455
+ value: docNumber
456
+ }, {
457
+ label: t('eidScannerScreen.birthDate'),
458
+ value: birthDate
459
+ }, {
460
+ label: t('eidScannerScreen.expirationDate'),
461
+ value: expiry
462
+ }, {
463
+ label: t('eidScannerScreen.gender'),
464
+ value: gender
465
+ }, {
466
+ label: t('eidScannerScreen.nationality'),
467
+ value: nationality
468
+ }, {
469
+ label: t('eidScannerScreen.issuingState'),
470
+ value: issuingState
471
+ }, {
472
+ label: t('eidScannerScreen.personalNumber'),
473
+ value: personalNumber
474
+ }];
475
+ return fields.filter(f => f.value && f.value.trim().length > 0).map(f => /*#__PURE__*/_jsx(IDCardField, {
476
+ label: f.label,
477
+ value: f.value
478
+ }, f.label));
479
+ })()
420
480
  })]
421
481
  })]
422
482
  }), hasNfc && isEnabled && isScanned && /*#__PURE__*/_jsx(View, {
@@ -426,7 +486,7 @@ const EIDScanner = ({
426
486
  disabled: !documentFaceImage || !documentMRZInfo,
427
487
  onPress: () => {
428
488
  if (onScanSuccess && documentMRZInfo && documentFaceImage && documentFaceImageMimeType) {
429
- onScanSuccess(getFieldsFromMRZ(documentMRZInfo), documentFaceImage, documentFaceImageMimeType);
489
+ onScanSuccess(getFieldsFromMRZ(documentMRZInfo, documentName), documentFaceImage, documentFaceImageMimeType);
430
490
  }
431
491
  },
432
492
  children: t('eidScannerScreen.approveAndContinue')
@@ -476,7 +536,7 @@ const EIDScanner = ({
476
536
  disabled: !documentFaceImage || !documentMRZInfo,
477
537
  onPress: () => {
478
538
  if (onScanSuccess && documentMRZInfo && documentFaceImage && documentFaceImageMimeType) {
479
- onScanSuccess(getFieldsFromMRZ(documentMRZInfo), documentFaceImage, documentFaceImageMimeType);
539
+ onScanSuccess(getFieldsFromMRZ(documentMRZInfo, documentName), documentFaceImage, documentFaceImageMimeType);
480
540
  }
481
541
  },
482
542
  children: t('eidScannerScreen.approveAndContinue')
@@ -17,7 +17,15 @@ export const REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS = 3;
17
17
  export const SIGNATURE_REGEX = /s[gi]g?n[au]?t[u]?r|imz[a]s?/i;
18
18
  export const SIGNATURE_TEXT_REGEX = /signature|imza|İmza/i;
19
19
  export const MRZ_BLOCK_PATTERN = /[A-Z0-9<]{8,}.*</i;
20
- export const PASSPORT_MRZ_PATTERN = /P<[A-Z]{3}/;
20
+ // Matches the line-1 start of ANY ICAO TD3 passport MRZ, so the camera routes
21
+ // every passport variant into the passport flow (not the ID-card back-side
22
+ // flow). Per ICAO 9303 Part 4: `P` + a type char (filler `<` or a subtype
23
+ // letter — `P<`, `PP` German, `PD`/`PS`/`PO` diplomatic/service/official) +
24
+ // the issuing state (3 letters, OR the German 1-letter `D` padded with filler,
25
+ // e.g. `P<D<<` / `PPD<<`). Previously only `P<[A-Z]{3}` matched, so German `PP`
26
+ // and 1-letter-issuer passports fell through to the ID-card flow and were
27
+ // wrongly asked for a back side.
28
+ export const PASSPORT_MRZ_PATTERN = /P[A-Z<](?:[A-Z]{3}|[A-Z]<<)[A-Z<]*<</;
21
29
 
22
30
  // BDDK Madde 7 - Security Feature Validation
23
31
  // When NFC chip reading unavailable, must verify at least 4 visual security features
@@ -3,6 +3,9 @@
3
3
  import { InputStream } from "./java/inputStream.js";
4
4
  import { Buffer } from 'buffer';
5
5
  export class CardFileInputStream extends InputStream {
6
+ /** Max re-issues of a single block read before failing — mitigates transient
7
+ * iOS NFC transceive drops during large-file reads (e.g. DG2). */
8
+ static MAX_BLOCK_READ_RETRIES = 5;
6
9
  constructor(maxBlockSize, fs) {
7
10
  super();
8
11
  this.fs = fs;
@@ -49,8 +52,28 @@ export class CardFileInputStream extends InputStream {
49
52
  newOffsetBufferInFile = this.offsetBufferInFile + this.bufferLength;
50
53
  let newOffsetInBuffer = 0;
51
54
  let newBufferLength = 0;
52
- while (newBufferLength === 0) {
53
- newBufferLength = await this.fillBufferFromFile(this.path, newOffsetBufferInFile, le);
55
+ // Retry a failed block read. The iOS NFC stack (react-native-nfc-manager)
56
+ // throws transient "READ BINARY" transceive errors during the many
57
+ // block reads of a large file like DG2 (face image); a single drop
58
+ // previously aborted the whole read with "Could not read file". Re-issue
59
+ // the SAME block (offset unchanged) a few times before giving up — this
60
+ // is idempotent (no secure-messaging state advances on a failed read).
61
+ let attempt = 0;
62
+ for (;;) {
63
+ try {
64
+ newBufferLength = 0;
65
+ while (newBufferLength === 0) {
66
+ newBufferLength = await this.fillBufferFromFile(this.path, newOffsetBufferInFile, le);
67
+ }
68
+ break;
69
+ } catch (blockError) {
70
+ if (++attempt >= CardFileInputStream.MAX_BLOCK_READ_RETRIES) {
71
+ throw blockError;
72
+ }
73
+ // Brief backoff: an immediate re-issue after a transient NFC drop
74
+ // usually fails again; a short pause lets the RF field re-stabilise.
75
+ await new Promise(r => setTimeout(r, 80 * attempt));
76
+ }
54
77
  }
55
78
  this.offsetBufferInFile = newOffsetBufferInFile;
56
79
  this.offsetInBuffer = newOffsetInBuffer;
@@ -6,9 +6,11 @@ import { DefaultFileInfo } from "./defaultFileInfo.js";
6
6
  import { ISO7816_SW } from "./smartcards/iso7816.js";
7
7
  import { SecureMessagingWrapper } from "./secureMessagingWrapper.js";
8
8
  import { TripleDesSecureMessagingWrapper } from "./tripleDesSecureMessagingWrapper.js";
9
+ import { AESSecureMessagingWrapper } from "./aesSecureMessagingWrapper.js";
9
10
  import { TLVInputStream } from "./tlv/tlvInputStream.js";
10
11
  import { CVCAFile } from "./lds/cvcaFile.js";
11
12
  import { Buffer } from 'buffer';
13
+ import { Platform } from 'react-native';
12
14
  export class DefaultFileSystem {
13
15
  static NO_SFI = -1;
14
16
  static READ_AHEAD_LENGTH = 8;
@@ -19,7 +21,27 @@ export class DefaultFileSystem {
19
21
  this.isSelected = false;
20
22
  this.isSFIEnabled = isSFIEnabled;
21
23
  this.fidToSFI = fidToSFI;
22
- this.maxReadBinaryLength = EID_CONSTANTS.EXTENDED_MAX_TRANSCEIVE_LENGTH;
24
+ // iOS CoreNFC (via react-native-nfc-manager's sendCommandAPDUIOS) does not
25
+ // accept the EXTENDED-length APDU byte form this code emits when ne > 256
26
+ // (a 7-byte "00 XX XX" Le). Such a READ BINARY is rejected outright, so on
27
+ // iOS EVERY DG2 read failed ("Exception during READ BINARY") even though
28
+ // PACE — which only used short Le=256 — succeeded. Start at a short-form
29
+ // length on iOS (ne <= 256) so every READ BINARY stays in the short APDU
30
+ // form. Android's transceive() handles extended APDUs, so keep extended
31
+ // there for speed. JMRTD/NFCPassportReader likewise read in small blocks.
32
+ this.maxReadBinaryLength = Platform.OS === 'ios' ? EID_CONSTANTS.DEFAULT_MAX_BLOCKSIZE // 223 → short-form Le, SM-safe
33
+ : EID_CONSTANTS.EXTENDED_MAX_TRANSCEIVE_LENGTH;
34
+ }
35
+
36
+ /** Deep-copy a secure-messaging wrapper PRESERVING its cipher type (AES vs
37
+ * 3DES), so the SSC can be rewound for a retry without changing the MAC/enc
38
+ * algorithm. Non-SM wrappers are returned as-is. */
39
+ cloneWrapper(wrapper) {
40
+ if (!(wrapper instanceof SecureMessagingWrapper)) {
41
+ return wrapper;
42
+ }
43
+ const args = [wrapper.getEncryptionKey(), wrapper.getMACKey(), wrapper.getMaxTransceiveLength(), wrapper.getShouldCheckMAC(), wrapper.getSendSequenceCounter()];
44
+ return wrapper.getType() === 'AES' ? new AESSecureMessagingWrapper(...args) : new TripleDesSecureMessagingWrapper(...args);
23
45
  }
24
46
  setWrapper(wrapper) {
25
47
  this.oldWrapper = this.wrapper;
@@ -89,13 +111,36 @@ export class DefaultFileSystem {
89
111
  const result = buffer.slice(offset, offset + responseLength);
90
112
  return result;
91
113
  } catch (error) {
92
- const sw = error.getSW();
93
- if ((sw & ISO7816_SW.WRONG_LENGTH) === ISO7816_SW.WRONG_LENGTH && this.maxReadBinaryLength > EID_CONSTANTS.DEFAULT_MAX_BLOCKSIZE) {
94
- this.wrapper = this.oldWrapper;
114
+ // Only APDU-level errors expose getSW(); a transient native NFC transceive
115
+ // error does NOT, so calling it unguarded threw a TypeError and masked the
116
+ // real failure. Guard it before testing for the WRONG_LENGTH downgrade.
117
+ const sw = typeof error.getSW === 'function' ? error.getSW() : undefined;
118
+ const isWrongLength = sw != null && (sw & ISO7816_SW.WRONG_LENGTH) === ISO7816_SW.WRONG_LENGTH;
119
+
120
+ // Downgrade the read size and signal a retry (empty result) when EITHER:
121
+ // - the chip returned 6Cxx WRONG_LENGTH (the standard JMRTD path), OR
122
+ // - the read failed transiently with NO status word while we were still
123
+ // at extended length. iOS CoreNFC (via react-native-nfc-manager) drops
124
+ // the connection on too-large extended-length READ BINARY on some chips
125
+ // (e.g. German passport DG2) instead of returning 6Cxx, so the chip
126
+ // never gets to ask for a smaller length. Dropping to the conservative
127
+ // 223-byte block makes the next read succeed. Reference readers
128
+ // (JMRTD, NFCPassportReader) read large files in small blocks (~0xA0).
129
+ // Restore the pre-send secure-messaging wrapper. A failed wrapped READ
130
+ // BINARY still advanced the send-sequence-counter, so a retry MUST reset
131
+ // it or every subsequent read fails the MAC. (This is why the read
132
+ // previously stalled partway through DG2: each retry reused a desynced
133
+ // SSC.) JMRTD does the same reset on its downgrade path.
134
+ this.wrapper = this.oldWrapper;
135
+ if ((isWrongLength || sw == null) && this.maxReadBinaryLength > EID_CONSTANTS.DEFAULT_MAX_BLOCKSIZE) {
95
136
  this.maxReadBinaryLength = EID_CONSTANTS.DEFAULT_MAX_BLOCKSIZE;
96
137
  return new Uint8Array(0);
97
138
  }
98
- throw new Error(`Read binary failed on file ${fileInfo == null ? this.selectedFID.toString(16) : fileInfo}`);
139
+
140
+ // Preserve the original error (a transient transceive failure at an
141
+ // already-small block size) so the retry layer above can recognise and
142
+ // re-issue it, instead of flattening every cause into one opaque string.
143
+ throw error instanceof Error ? error : new Error(`Read binary failed on file ${fileInfo == null ? this.selectedFID.toString(16) : fileInfo}`);
99
144
  }
100
145
  }
101
146
  async getFileInfo() {
@@ -139,7 +184,12 @@ export class DefaultFileSystem {
139
184
  await this.service.sendSelectFile(this.wrapper, fid);
140
185
  }
141
186
  sendReadBinaryNoSFI(offset, le, isTLVEncodedOffsetNeeded) {
142
- this.oldWrapper = this.wrapper instanceof SecureMessagingWrapper ? new TripleDesSecureMessagingWrapper(this.wrapper.getEncryptionKey(), this.wrapper.getMACKey(), this.wrapper.getMaxTransceiveLength(), this.wrapper.getShouldCheckMAC(), this.wrapper.getSendSequenceCounter()) : this.wrapper;
187
+ // Snapshot the wrapper BEFORE the send so a failed read can rewind the SSC.
188
+ // The copy MUST preserve the actual wrapper type — PACE sessions are AES
189
+ // (the German passport uses AES-CMAC). Previously this always built a 3DES
190
+ // wrapper, so an AES session's retry used a 3DES wrapper with AES keys and
191
+ // every subsequent read failed the MAC — the cause of the DG2 stall.
192
+ this.oldWrapper = this.cloneWrapper(this.wrapper);
143
193
  return this.service.sendReadBinary(this.wrapper, DefaultFileSystem.NO_SFI, offset, le, false, isTLVEncodedOffsetNeeded);
144
194
  }
145
195
  sendReadBinary(sfi, offset, le, isTLVEncodedOffsetNeeded) {
@@ -4,6 +4,7 @@ import { BACKey } from "./bacKey.js";
4
4
  import { PACEKeySpec } from "./paceKeySpec.js";
5
5
  import { NFCManagerCardService } from "./nfcManagerCardService.js";
6
6
  import { EIDService } from "./eidService.js";
7
+ import { isPACEUnsupportedError } from "./protocol/paceErrors.js";
7
8
  import { DG1File } from "./lds/icao/dg1File.js";
8
9
  import { DG2File } from "./lds/icao/dg2File.js";
9
10
  import { DG11File } from "./lds/icao/dg11File.js";
@@ -38,6 +39,49 @@ const categorizeNfcError = msg => {
38
39
  return 'reading_error';
39
40
  };
40
41
 
42
+ /** Short, stable code per category for the user-facing reportable error code. */
43
+ const NFC_CATEGORY_CODE = {
44
+ user_cancelled: 'CANCEL',
45
+ tag_lost: 'TAGLOST',
46
+ timeout: 'TIMEOUT',
47
+ unsupported: 'UNSUPPORTED',
48
+ not_enabled: 'DISABLED',
49
+ auth_failed: 'AUTH',
50
+ reading_error: 'READ'
51
+ };
52
+
53
+ /** Short, stable token per read step, so the error code names WHERE it failed.
54
+ * A generic NFC-READ is not actionable; NFC-READ-DG2-6982 tells support the
55
+ * read failed reading the face image (DG2) with APDU status 6982. */
56
+ const NFC_STEP_CODE = {
57
+ open: 'OPEN',
58
+ selectApplet: 'SELAPP',
59
+ selectMF: 'SELMF',
60
+ readCardAccess: 'CARDACCESS',
61
+ paceAuth: 'PACE',
62
+ bacAuth: 'BAC',
63
+ readDG1: 'DG1',
64
+ readDG2: 'DG2',
65
+ readDG11: 'DG11'
66
+ };
67
+
68
+ /**
69
+ * Build a stable, reportable error code from a categorised NFC failure that
70
+ * names the failing STEP, e.g. `NFC-READ-DG2-6982` (reading the face image
71
+ * failed, APDU status 6982) or `NFC-AUTH-PACE-6300` (PACE auth rejected). Falls
72
+ * back to `NFC-<category>` when neither step nor status word is known.
73
+ * Non-PII by construction: only the bucket + step name + the chip's 4-hex
74
+ * status word. A user can quote it to pinpoint exactly where the read failed.
75
+ */
76
+ const buildNfcErrorCode = (category, msg, step, stepSw) => {
77
+ const part = NFC_CATEGORY_CODE[category] ?? 'READ';
78
+ const stepTok = step ? NFC_STEP_CODE[step] : undefined;
79
+ // Prefer the status word recorded against the failing step; fall back to one
80
+ // parsed from the error message.
81
+ const sw = stepSw ?? extractStatusWord(msg);
82
+ return [`NFC-${part}`, stepTok, sw].filter(Boolean).join('-');
83
+ };
84
+
41
85
  // --- Minimal PNG encoder (pure JS, no native deps) ---
42
86
  // Uses deflate stored (uncompressed) blocks for O(n) encoding speed.
43
87
  // A naive DCT-based JPEG encoder would be O(n²) and freeze the JS thread
@@ -169,6 +213,13 @@ function pixelsToPng(pixels, width, height, channels) {
169
213
  * If the image is JPEG 2000, decode and convert to PNG so React Native can display it.
170
214
  * Returns { base64, mimeType } with the converted image, or the original if not JP2.
171
215
  */
216
+ // Largest JP2 face image we will attempt to decode in pure JS on the UI thread.
217
+ // The `jpeg2000` decoder's parse() is SYNCHRONOUS and roughly quadratic, so a
218
+ // large image (some passports store 20–30 KB+ JPEG2000 portraits) blocks the JS
219
+ // thread for many seconds or indefinitely — the German-passport "stuck at face
220
+ // conversion" hang. A JS timeout cannot interrupt a synchronous call, so we must
221
+ // gate BEFORE decoding. ~12 KB decodes fast; beyond that we keep the raw JP2.
222
+ const MAX_JP2_DECODE_BYTES = 12 * 1024;
172
223
  function convertJP2IfNeeded(imageBuffer, mimeType) {
173
224
  if (mimeType !== 'image/jp2') {
174
225
  return {
@@ -176,6 +227,18 @@ function convertJP2IfNeeded(imageBuffer, mimeType) {
176
227
  mimeType
177
228
  };
178
229
  }
230
+
231
+ // Too large to decode synchronously without freezing the UI — keep the raw
232
+ // JP2. The face image is still captured (the verification backend / face-match
233
+ // accept JPEG2000); only the on-screen preview can't render it. This lets the
234
+ // NFC read COMPLETE instead of hanging.
235
+ if (imageBuffer.length > MAX_JP2_DECODE_BYTES) {
236
+ debugLog('EID', `[EID] JP2 face image is ${imageBuffer.length} bytes (> ${MAX_JP2_DECODE_BYTES}); skipping in-JS decode to avoid blocking, keeping raw JP2`);
237
+ return {
238
+ base64: imageBuffer.toString('base64'),
239
+ mimeType
240
+ };
241
+ }
179
242
  debugLog('EID', '[EID] Face image is JP2, attempting conversion…');
180
243
  try {
181
244
  // Lazy-load jpeg2000 to avoid crashing if it fails to import
@@ -411,7 +474,30 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
411
474
  }
412
475
  const done = stepTimer();
413
476
  try {
414
- await passportService.doPACE(paceKey, paceInfo.oid, paceInfo.parameterId, progressCallback);
477
+ // PACE runs many APDU round-trips; on iOS the CoreNFC tag connection
478
+ // can drop mid-handshake (empty/lost-tag error). A restart can't
479
+ // resume PACE (the on-chip state is gone), so on a CONNECTION-DROP
480
+ // (not a crypto/capability failure) restart the session and retry
481
+ // the WHOLE PACE handshake from scratch a few times. A real failure
482
+ // (wrong key, unsupported variant) is rethrown immediately.
483
+ const MAX_PACE_ATTEMPTS = 3;
484
+ for (let paceAttempt = 1;; paceAttempt++) {
485
+ try {
486
+ await passportService.doPACE(paceKey, paceInfo.oid, paceInfo.parameterId, progressCallback);
487
+ break;
488
+ } catch (attemptErr) {
489
+ const lost = nfcManagerCardService.isTagLost(attemptErr);
490
+ if (!lost || paceAttempt >= MAX_PACE_ATTEMPTS) {
491
+ throw attemptErr;
492
+ }
493
+ debugLog('EID', `[EID] PACE attempt ${paceAttempt} lost the tag; restarting session and retrying`);
494
+ await nfcManagerCardService.restartSession();
495
+ await new Promise(r => setTimeout(r, 200 * paceAttempt));
496
+ // Re-select MF + re-read EF.CardAccess so the next PACE starts
497
+ // from the same clean state as the first attempt.
498
+ await passportService.sendSelectMF();
499
+ }
500
+ }
415
501
  hasPACESucceeded = true;
416
502
  done('paceAuth', true);
417
503
  debugLog('EID', '[EID] PACE succeeded');
@@ -424,10 +510,14 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
424
510
  debugLog('EID', '[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC');
425
511
  }
426
512
  } catch (paceError) {
427
- // PACE protocol exchange failed — fall back to BAC
428
- diagnostics.nfcPaceFellBackToBac('pace_failed');
513
+ // PACE could not complete — fall back to BAC. Distinguish a CAPABILITY
514
+ // gap (this build can't run the variant the chip advertises) from a
515
+ // RUNTIME failure (wrong key, tag lost, APDU error) so diagnostics show
516
+ // exactly why, e.g. pace_unsupported_curve vs pace_failed.
517
+ const reason = isPACEUnsupportedError(paceError) ? `pace_unsupported_${paceError.reason}` : 'pace_failed';
518
+ diagnostics.nfcPaceFellBackToBac(reason);
429
519
  const msg = paceError instanceof Error ? paceError.message : String(paceError);
430
- debugLog('EID', `[EID] PACE protocol failed (${msg}), falling back to BAC`);
520
+ debugLog('EID', `[EID] PACE could not complete (${reason}: ${msg}), falling back to BAC`);
431
521
  if (paceError instanceof Error && paceError.stack) {
432
522
  debugLog('EID', '[EID] PACE error stack:', paceError.stack);
433
523
  }
@@ -478,6 +568,13 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
478
568
  progressCallback(progress);
479
569
  }
480
570
 
571
+ // Authentication is complete — enable iOS CoreNFC session-restart recovery
572
+ // for the data-group reads. It must stay OFF during PACE/BAC (the auth state
573
+ // lives on the chip; a mid-handshake restart destroys it). Reads are
574
+ // idempotent and their secure-messaging state lives in our JS wrapper, so a
575
+ // session restart between blocks is safe here.
576
+ nfcManagerCardService.setSessionRestartEnabled(true);
577
+
481
578
  // Read DG1 (MRZ Info)
482
579
  const dg1Done = stepTimer();
483
580
  let mrzInfo;
@@ -512,20 +609,23 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
512
609
 
513
610
  // Read DG2 (Face Image)
514
611
  const dg2Done = stepTimer();
515
- const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
516
- await dg2InputStream.init();
517
- InputStream.onRead((totalBytesRead, fileLength) => {
518
- progress = 30 + totalBytesRead / fileLength * 70;
519
- if (progressCallback) {
520
- progressCallback(progress);
521
- }
522
- });
523
- const dg2File = new DG2File([], dg2InputStream);
524
612
  let imageAsBase64 = '';
525
613
  let mimeType = '';
526
614
  const allFaceImageInfos = [];
527
615
  let faceInfos;
528
616
  try {
617
+ // init() selects EF.DG2 and reads its header over secure messaging; keep
618
+ // it INSIDE the try so a failure here is still recorded as the readDG2
619
+ // step (it was previously outside, so an early DG2 failure went unlabelled).
620
+ const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
621
+ await dg2InputStream.init();
622
+ InputStream.onRead((totalBytesRead, fileLength) => {
623
+ progress = 30 + totalBytesRead / fileLength * 70;
624
+ if (progressCallback) {
625
+ progressCallback(progress);
626
+ }
627
+ });
628
+ const dg2File = new DG2File([], dg2InputStream);
529
629
  faceInfos = await dg2File.getFaceInfos();
530
630
  dg2Done('readDG2', true);
531
631
  } catch (e) {
@@ -560,8 +660,18 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
560
660
  };
561
661
  } catch (error) {
562
662
  const msg = error instanceof Error ? error.message : String(error);
563
- diagnostics.nfcResult(false, categorizeNfcError(msg));
663
+ const category = categorizeNfcError(msg);
664
+ diagnostics.nfcResult(false, category);
564
665
  debugLog('EID', 'Error reading passport data', error);
666
+ // Rethrow so the caller can surface an accurate, actionable message. Swallowing
667
+ // here returned `undefined`, which the UI mis-reported as "invalid MRZ fields"
668
+ // (e.g. a German passport whose BAC/PACE auth or chip read actually failed).
669
+ // The category is attached for the UI to map to a specific message.
670
+ const rethrown = error instanceof Error ? error : new Error(msg);
671
+ const tagged = rethrown;
672
+ tagged.nfcCategory = category;
673
+ tagged.nfcCode = buildNfcErrorCode(category, msg, diagnostics.getFailureStep(), diagnostics.getFailureStatusWord());
674
+ throw tagged;
565
675
  } finally {
566
676
  await passportService.close();
567
677
  }
@@ -65,13 +65,30 @@ export class DG11File extends DataGroup {
65
65
  * Strips trailing filler `<` within each segment.
66
66
  */
67
67
  parseName(nameString) {
68
- const delimIndex = nameString.indexOf('<<');
69
- if (delimIndex < 0) {
70
- this.lastName = nameString.replace(/<+$/, '').trim();
71
- this.firstName = '';
68
+ const fillerToSpace = s => s.replace(/<+$/, '') // drop trailing filler
69
+ .replace(/</g, ' ') // internal filler = word separator
70
+ .replace(/\s+/g, ' ').trim();
71
+ const dblIndex = nameString.indexOf('<<');
72
+ if (dblIndex >= 0) {
73
+ // Standard ICAO layout: PRIMARY<<SECONDARY.
74
+ this.lastName = fillerToSpace(nameString.substring(0, dblIndex));
75
+ this.firstName = fillerToSpace(nameString.substring(dblIndex + 2));
72
76
  return;
73
77
  }
74
- this.lastName = nameString.substring(0, delimIndex).replace(/<+$/, '').trim();
75
- this.firstName = nameString.substring(delimIndex + 2).replace(/<$/, ' ').replace(/<+$/, '').trim();
78
+
79
+ // No `<<` separator. Some chips (e.g. certain Turkish PACE passports) store
80
+ // DG11 0x5F0E as SURNAME<GIVEN1<GIVEN2 with single fillers. Split on the
81
+ // FIRST single filler: surname = first token, given names = the rest. With
82
+ // no filler at all the field is a single token we cannot reliably split into
83
+ // surname/given, so leave firstName empty and let the caller decide (the MRZ
84
+ // split is then more trustworthy than a guess).
85
+ const singleIndex = nameString.replace(/<+$/, '').indexOf('<');
86
+ if (singleIndex >= 0) {
87
+ this.lastName = fillerToSpace(nameString.substring(0, singleIndex));
88
+ this.firstName = fillerToSpace(nameString.substring(singleIndex + 1));
89
+ return;
90
+ }
91
+ this.lastName = fillerToSpace(nameString);
92
+ this.firstName = '';
76
93
  }
77
94
  }