@trustchex/react-native-sdk 1.487.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 (66) hide show
  1. package/lib/module/Shared/Components/EIDScanner.js +95 -36
  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 +22 -5
  16. package/lib/module/Shared/Libs/mrzTransliteration.js +14 -1
  17. package/lib/module/Translation/Resources/en.js +12 -0
  18. package/lib/module/Translation/Resources/tr.js +12 -0
  19. package/lib/module/version.js +1 -1
  20. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  21. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts +3 -0
  23. package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts.map +1 -1
  24. package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts +4 -0
  25. package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts.map +1 -1
  26. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts +9 -1
  29. package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts.map +1 -1
  30. package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts +56 -0
  31. package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts.map +1 -0
  32. package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts +26 -0
  33. package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts.map +1 -0
  34. package/lib/typescript/src/Shared/EIDReader/protocol/paceProtocol.d.ts.map +1 -1
  35. package/lib/typescript/src/Shared/EIDReader/protocol/readBinaryAPDUSender.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Libs/country-display.utils.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -1
  38. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +9 -1
  39. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  41. package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts.map +1 -1
  42. package/lib/typescript/src/Translation/Resources/en.d.ts +12 -0
  43. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  44. package/lib/typescript/src/Translation/Resources/tr.d.ts +12 -0
  45. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  46. package/lib/typescript/src/version.d.ts +1 -1
  47. package/package.json +1 -1
  48. package/src/Shared/Components/EIDScanner.tsx +148 -68
  49. package/src/Shared/Components/IdentityDocumentCamera.constants.ts +9 -1
  50. package/src/Shared/EIDReader/cardFileInputStream.ts +30 -6
  51. package/src/Shared/EIDReader/defaultFileSystem.ts +75 -17
  52. package/src/Shared/EIDReader/eidReader.ts +154 -21
  53. package/src/Shared/EIDReader/lds/icao/dg11File.ts +28 -13
  54. package/src/Shared/EIDReader/nfcManagerCardService.ts +91 -13
  55. package/src/Shared/EIDReader/protocol/paceDH.ts +183 -0
  56. package/src/Shared/EIDReader/protocol/paceErrors.ts +41 -0
  57. package/src/Shared/EIDReader/protocol/paceProtocol.ts +72 -6
  58. package/src/Shared/EIDReader/protocol/readBinaryAPDUSender.ts +7 -4
  59. package/src/Shared/Libs/country-display.utils.ts +12 -1
  60. package/src/Shared/Libs/diagnosticReport.ts +32 -1
  61. package/src/Shared/Libs/diagnostics.ts +38 -14
  62. package/src/Shared/Libs/mrz.utils.ts +27 -6
  63. package/src/Shared/Libs/mrzTransliteration.ts +16 -3
  64. package/src/Translation/Resources/en.ts +19 -0
  65. package/src/Translation/Resources/tr.ts +19 -0
  66. package/src/version.ts +1 -1
@@ -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
@@ -4,6 +4,10 @@ import type { FileSystemStructured } from './smartcards/fileSystemStructured';
4
4
  import { Buffer } from 'buffer';
5
5
 
6
6
  export class CardFileInputStream extends InputStream {
7
+ /** Max re-issues of a single block read before failing — mitigates transient
8
+ * iOS NFC transceive drops during large-file reads (e.g. DG2). */
9
+ private static readonly MAX_BLOCK_READ_RETRIES = 5;
10
+
7
11
  private path!: FileInfo[];
8
12
  private buffer!: Buffer;
9
13
  private bufferLength!: number;
@@ -69,12 +73,32 @@ export class CardFileInputStream extends InputStream {
69
73
  newOffsetBufferInFile = this.offsetBufferInFile + this.bufferLength;
70
74
  let newOffsetInBuffer = 0;
71
75
  let newBufferLength = 0;
72
- while (newBufferLength === 0) {
73
- newBufferLength = await this.fillBufferFromFile(
74
- this.path,
75
- newOffsetBufferInFile,
76
- le
77
- );
76
+ // Retry a failed block read. The iOS NFC stack (react-native-nfc-manager)
77
+ // throws transient "READ BINARY" transceive errors during the many
78
+ // block reads of a large file like DG2 (face image); a single drop
79
+ // previously aborted the whole read with "Could not read file". Re-issue
80
+ // the SAME block (offset unchanged) a few times before giving up — this
81
+ // is idempotent (no secure-messaging state advances on a failed read).
82
+ let attempt = 0;
83
+ for (;;) {
84
+ try {
85
+ newBufferLength = 0;
86
+ while (newBufferLength === 0) {
87
+ newBufferLength = await this.fillBufferFromFile(
88
+ this.path,
89
+ newOffsetBufferInFile,
90
+ le
91
+ );
92
+ }
93
+ break;
94
+ } catch (blockError) {
95
+ if (++attempt >= CardFileInputStream.MAX_BLOCK_READ_RETRIES) {
96
+ throw blockError;
97
+ }
98
+ // Brief backoff: an immediate re-issue after a transient NFC drop
99
+ // usually fails again; a short pause lets the RF field re-stabilise.
100
+ await new Promise((r) => setTimeout(r, 80 * attempt));
101
+ }
78
102
  }
79
103
 
80
104
  this.offsetBufferInFile = newOffsetBufferInFile;
@@ -6,10 +6,12 @@ import { FileInfo } from './fileInfo';
6
6
  import { ISO7816_SW } from './smartcards/iso7816';
7
7
  import { SecureMessagingWrapper } from './secureMessagingWrapper';
8
8
  import { TripleDesSecureMessagingWrapper } from './tripleDesSecureMessagingWrapper';
9
+ import { AESSecureMessagingWrapper } from './aesSecureMessagingWrapper';
9
10
  import type { APDUWrapper } from './smartcards/apduWrapper';
10
11
  import { TLVInputStream } from './tlv/tlvInputStream';
11
12
  import { CVCAFile } from './lds/cvcaFile';
12
13
  import { Buffer } from 'buffer';
14
+ import { Platform } from 'react-native';
13
15
  import type { FileSystemStructured } from './smartcards/fileSystemStructured';
14
16
 
15
17
  export class DefaultFileSystem implements FileSystemStructured {
@@ -37,7 +39,37 @@ export class DefaultFileSystem implements FileSystemStructured {
37
39
  this.isSelected = false;
38
40
  this.isSFIEnabled = isSFIEnabled;
39
41
  this.fidToSFI = fidToSFI;
40
- this.maxReadBinaryLength = EID_CONSTANTS.EXTENDED_MAX_TRANSCEIVE_LENGTH;
42
+ // iOS CoreNFC (via react-native-nfc-manager's sendCommandAPDUIOS) does not
43
+ // accept the EXTENDED-length APDU byte form this code emits when ne > 256
44
+ // (a 7-byte "00 XX XX" Le). Such a READ BINARY is rejected outright, so on
45
+ // iOS EVERY DG2 read failed ("Exception during READ BINARY") even though
46
+ // PACE — which only used short Le=256 — succeeded. Start at a short-form
47
+ // length on iOS (ne <= 256) so every READ BINARY stays in the short APDU
48
+ // form. Android's transceive() handles extended APDUs, so keep extended
49
+ // there for speed. JMRTD/NFCPassportReader likewise read in small blocks.
50
+ this.maxReadBinaryLength =
51
+ Platform.OS === 'ios'
52
+ ? EID_CONSTANTS.DEFAULT_MAX_BLOCKSIZE // 223 → short-form Le, SM-safe
53
+ : EID_CONSTANTS.EXTENDED_MAX_TRANSCEIVE_LENGTH;
54
+ }
55
+
56
+ /** Deep-copy a secure-messaging wrapper PRESERVING its cipher type (AES vs
57
+ * 3DES), so the SSC can be rewound for a retry without changing the MAC/enc
58
+ * algorithm. Non-SM wrappers are returned as-is. */
59
+ private cloneWrapper(wrapper: APDUWrapper): APDUWrapper {
60
+ if (!(wrapper instanceof SecureMessagingWrapper)) {
61
+ return wrapper;
62
+ }
63
+ const args: [string, string, number, boolean, bigint] = [
64
+ wrapper.getEncryptionKey(),
65
+ wrapper.getMACKey(),
66
+ wrapper.getMaxTransceiveLength(),
67
+ wrapper.getShouldCheckMAC(),
68
+ wrapper.getSendSequenceCounter(),
69
+ ];
70
+ return wrapper.getType() === 'AES'
71
+ ? new AESSecureMessagingWrapper(...args)
72
+ : new TripleDesSecureMessagingWrapper(...args);
41
73
  }
42
74
 
43
75
  public setWrapper(wrapper: APDUWrapper): void {
@@ -131,19 +163,49 @@ export class DefaultFileSystem implements FileSystemStructured {
131
163
 
132
164
  return result;
133
165
  } catch (error) {
134
- const sw = (error as any).getSW();
166
+ // Only APDU-level errors expose getSW(); a transient native NFC transceive
167
+ // error does NOT, so calling it unguarded threw a TypeError and masked the
168
+ // real failure. Guard it before testing for the WRONG_LENGTH downgrade.
169
+ const sw =
170
+ typeof (error as { getSW?: () => number }).getSW === 'function'
171
+ ? (error as { getSW: () => number }).getSW()
172
+ : undefined;
173
+ const isWrongLength =
174
+ sw != null &&
175
+ (sw & ISO7816_SW.WRONG_LENGTH) === ISO7816_SW.WRONG_LENGTH;
176
+
177
+ // Downgrade the read size and signal a retry (empty result) when EITHER:
178
+ // - the chip returned 6Cxx WRONG_LENGTH (the standard JMRTD path), OR
179
+ // - the read failed transiently with NO status word while we were still
180
+ // at extended length. iOS CoreNFC (via react-native-nfc-manager) drops
181
+ // the connection on too-large extended-length READ BINARY on some chips
182
+ // (e.g. German passport DG2) instead of returning 6Cxx, so the chip
183
+ // never gets to ask for a smaller length. Dropping to the conservative
184
+ // 223-byte block makes the next read succeed. Reference readers
185
+ // (JMRTD, NFCPassportReader) read large files in small blocks (~0xA0).
186
+ // Restore the pre-send secure-messaging wrapper. A failed wrapped READ
187
+ // BINARY still advanced the send-sequence-counter, so a retry MUST reset
188
+ // it or every subsequent read fails the MAC. (This is why the read
189
+ // previously stalled partway through DG2: each retry reused a desynced
190
+ // SSC.) JMRTD does the same reset on its downgrade path.
191
+ this.wrapper = this.oldWrapper;
192
+
135
193
  if (
136
- (sw & ISO7816_SW.WRONG_LENGTH) === ISO7816_SW.WRONG_LENGTH &&
194
+ (isWrongLength || sw == null) &&
137
195
  this.maxReadBinaryLength > EID_CONSTANTS.DEFAULT_MAX_BLOCKSIZE
138
196
  ) {
139
- this.wrapper = this.oldWrapper;
140
197
  this.maxReadBinaryLength = EID_CONSTANTS.DEFAULT_MAX_BLOCKSIZE;
141
198
  return new Uint8Array(0);
142
199
  }
143
200
 
144
- throw new Error(
145
- `Read binary failed on file ${fileInfo == null ? this.selectedFID.toString(16) : fileInfo}`
146
- );
201
+ // Preserve the original error (a transient transceive failure at an
202
+ // already-small block size) so the retry layer above can recognise and
203
+ // re-issue it, instead of flattening every cause into one opaque string.
204
+ throw error instanceof Error
205
+ ? error
206
+ : new Error(
207
+ `Read binary failed on file ${fileInfo == null ? this.selectedFID.toString(16) : fileInfo}`
208
+ );
147
209
  }
148
210
  }
149
211
 
@@ -216,16 +278,12 @@ export class DefaultFileSystem implements FileSystemStructured {
216
278
  le: number,
217
279
  isTLVEncodedOffsetNeeded: boolean
218
280
  ) {
219
- this.oldWrapper =
220
- this.wrapper instanceof SecureMessagingWrapper
221
- ? new TripleDesSecureMessagingWrapper(
222
- this.wrapper.getEncryptionKey(),
223
- this.wrapper.getMACKey(),
224
- this.wrapper.getMaxTransceiveLength(),
225
- this.wrapper.getShouldCheckMAC(),
226
- this.wrapper.getSendSequenceCounter()
227
- )
228
- : this.wrapper;
281
+ // Snapshot the wrapper BEFORE the send so a failed read can rewind the SSC.
282
+ // The copy MUST preserve the actual wrapper type — PACE sessions are AES
283
+ // (the German passport uses AES-CMAC). Previously this always built a 3DES
284
+ // wrapper, so an AES session's retry used a 3DES wrapper with AES keys and
285
+ // every subsequent read failed the MAC — the cause of the DG2 stall.
286
+ this.oldWrapper = this.cloneWrapper(this.wrapper);
229
287
  return this.service.sendReadBinary(
230
288
  this.wrapper,
231
289
  DefaultFileSystem.NO_SFI,
@@ -2,6 +2,7 @@ import { BACKey } from './bacKey';
2
2
  import { PACEKeySpec } from './paceKeySpec';
3
3
  import { NFCManagerCardService } from './nfcManagerCardService';
4
4
  import { EIDService } from './eidService';
5
+ import { isPACEUnsupportedError } from './protocol/paceErrors';
5
6
  import { DG1File } from './lds/icao/dg1File';
6
7
  import { DG2File } from './lds/icao/dg2File';
7
8
  import { DG11File } from './lds/icao/dg11File';
@@ -38,6 +39,54 @@ const categorizeNfcError = (msg: string): string => {
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: Record<string, string> = {
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: Record<string, string> = {
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 = (
77
+ category: string,
78
+ msg: string,
79
+ step?: string,
80
+ stepSw?: string
81
+ ): string => {
82
+ const part = NFC_CATEGORY_CODE[category] ?? 'READ';
83
+ const stepTok = step ? NFC_STEP_CODE[step] : undefined;
84
+ // Prefer the status word recorded against the failing step; fall back to one
85
+ // parsed from the error message.
86
+ const sw = stepSw ?? extractStatusWord(msg);
87
+ return [`NFC-${part}`, stepTok, sw].filter(Boolean).join('-');
88
+ };
89
+
41
90
  // --- Minimal PNG encoder (pure JS, no native deps) ---
42
91
  // Uses deflate stored (uncompressed) blocks for O(n) encoding speed.
43
92
  // A naive DCT-based JPEG encoder would be O(n²) and freeze the JS thread
@@ -182,6 +231,14 @@ function pixelsToPng(
182
231
  * If the image is JPEG 2000, decode and convert to PNG so React Native can display it.
183
232
  * Returns { base64, mimeType } with the converted image, or the original if not JP2.
184
233
  */
234
+ // Largest JP2 face image we will attempt to decode in pure JS on the UI thread.
235
+ // The `jpeg2000` decoder's parse() is SYNCHRONOUS and roughly quadratic, so a
236
+ // large image (some passports store 20–30 KB+ JPEG2000 portraits) blocks the JS
237
+ // thread for many seconds or indefinitely — the German-passport "stuck at face
238
+ // conversion" hang. A JS timeout cannot interrupt a synchronous call, so we must
239
+ // gate BEFORE decoding. ~12 KB decodes fast; beyond that we keep the raw JP2.
240
+ const MAX_JP2_DECODE_BYTES = 12 * 1024;
241
+
185
242
  function convertJP2IfNeeded(
186
243
  imageBuffer: Buffer,
187
244
  mimeType: string
@@ -190,6 +247,18 @@ function convertJP2IfNeeded(
190
247
  return { base64: imageBuffer.toString('base64'), mimeType };
191
248
  }
192
249
 
250
+ // Too large to decode synchronously without freezing the UI — keep the raw
251
+ // JP2. The face image is still captured (the verification backend / face-match
252
+ // accept JPEG2000); only the on-screen preview can't render it. This lets the
253
+ // NFC read COMPLETE instead of hanging.
254
+ if (imageBuffer.length > MAX_JP2_DECODE_BYTES) {
255
+ debugLog(
256
+ 'EID',
257
+ `[EID] JP2 face image is ${imageBuffer.length} bytes (> ${MAX_JP2_DECODE_BYTES}); skipping in-JS decode to avoid blocking, keeping raw JP2`
258
+ );
259
+ return { base64: imageBuffer.toString('base64'), mimeType };
260
+ }
261
+
193
262
  debugLog('EID', '[EID] Face image is JP2, attempting conversion…');
194
263
  try {
195
264
  // Lazy-load jpeg2000 to avoid crashing if it fails to import
@@ -349,7 +418,12 @@ const eidReader = async (
349
418
  const stepTimer = () => {
350
419
  const t0 = Date.now();
351
420
  return (step: string, ok: boolean, error?: unknown) => {
352
- const msg = error instanceof Error ? error.message : error ? String(error) : undefined;
421
+ const msg =
422
+ error instanceof Error
423
+ ? error.message
424
+ : error
425
+ ? String(error)
426
+ : undefined;
353
427
  // Store only the APDU status word and a COARSE category — never the raw
354
428
  // error text, which could in theory embed field/PII values. The SW + step
355
429
  // name + category carry all the actionable signal.
@@ -472,12 +546,38 @@ const eidReader = async (
472
546
 
473
547
  const done = stepTimer();
474
548
  try {
475
- await passportService.doPACE(
476
- paceKey,
477
- paceInfo.oid,
478
- paceInfo.parameterId,
479
- progressCallback
480
- );
549
+ // PACE runs many APDU round-trips; on iOS the CoreNFC tag connection
550
+ // can drop mid-handshake (empty/lost-tag error). A restart can't
551
+ // resume PACE (the on-chip state is gone), so on a CONNECTION-DROP
552
+ // (not a crypto/capability failure) restart the session and retry
553
+ // the WHOLE PACE handshake from scratch a few times. A real failure
554
+ // (wrong key, unsupported variant) is rethrown immediately.
555
+ const MAX_PACE_ATTEMPTS = 3;
556
+ for (let paceAttempt = 1; ; paceAttempt++) {
557
+ try {
558
+ await passportService.doPACE(
559
+ paceKey,
560
+ paceInfo.oid,
561
+ paceInfo.parameterId,
562
+ progressCallback
563
+ );
564
+ break;
565
+ } catch (attemptErr) {
566
+ const lost = nfcManagerCardService.isTagLost(attemptErr);
567
+ if (!lost || paceAttempt >= MAX_PACE_ATTEMPTS) {
568
+ throw attemptErr;
569
+ }
570
+ debugLog(
571
+ 'EID',
572
+ `[EID] PACE attempt ${paceAttempt} lost the tag; restarting session and retrying`
573
+ );
574
+ await nfcManagerCardService.restartSession();
575
+ await new Promise((r) => setTimeout(r, 200 * paceAttempt));
576
+ // Re-select MF + re-read EF.CardAccess so the next PACE starts
577
+ // from the same clean state as the first attempt.
578
+ await passportService.sendSelectMF();
579
+ }
580
+ }
481
581
  hasPACESucceeded = true;
482
582
  done('paceAuth', true);
483
583
  debugLog('EID', '[EID] PACE succeeded');
@@ -493,13 +593,19 @@ const eidReader = async (
493
593
  );
494
594
  }
495
595
  } catch (paceError) {
496
- // PACE protocol exchange failed — fall back to BAC
497
- diagnostics.nfcPaceFellBackToBac('pace_failed');
596
+ // PACE could not complete — fall back to BAC. Distinguish a CAPABILITY
597
+ // gap (this build can't run the variant the chip advertises) from a
598
+ // RUNTIME failure (wrong key, tag lost, APDU error) so diagnostics show
599
+ // exactly why, e.g. pace_unsupported_curve vs pace_failed.
600
+ const reason = isPACEUnsupportedError(paceError)
601
+ ? `pace_unsupported_${paceError.reason}`
602
+ : 'pace_failed';
603
+ diagnostics.nfcPaceFellBackToBac(reason);
498
604
  const msg =
499
605
  paceError instanceof Error ? paceError.message : String(paceError);
500
606
  debugLog(
501
607
  'EID',
502
- `[EID] PACE protocol failed (${msg}), falling back to BAC`
608
+ `[EID] PACE could not complete (${reason}: ${msg}), falling back to BAC`
503
609
  );
504
610
  if (paceError instanceof Error && paceError.stack) {
505
611
  debugLog('EID', '[EID] PACE error stack:', paceError.stack);
@@ -555,6 +661,13 @@ const eidReader = async (
555
661
  progressCallback(progress);
556
662
  }
557
663
 
664
+ // Authentication is complete — enable iOS CoreNFC session-restart recovery
665
+ // for the data-group reads. It must stay OFF during PACE/BAC (the auth state
666
+ // lives on the chip; a mid-handshake restart destroys it). Reads are
667
+ // idempotent and their secure-messaging state lives in our JS wrapper, so a
668
+ // session restart between blocks is safe here.
669
+ nfcManagerCardService.setSessionRestartEnabled(true);
670
+
558
671
  // Read DG1 (MRZ Info)
559
672
  const dg1Done = stepTimer();
560
673
  let mrzInfo: MRZInfo;
@@ -592,21 +705,23 @@ const eidReader = async (
592
705
 
593
706
  // Read DG2 (Face Image)
594
707
  const dg2Done = stepTimer();
595
- const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
596
- await dg2InputStream.init();
597
- InputStream.onRead((totalBytesRead, fileLength) => {
598
- progress = 30 + (totalBytesRead / fileLength) * 70;
599
- if (progressCallback) {
600
- progressCallback(progress);
601
- }
602
- });
603
- const dg2File = new DG2File([], dg2InputStream);
604
708
  let imageAsBase64 = '';
605
709
  let mimeType = '';
606
-
607
710
  const allFaceImageInfos: FaceImageInfo[] = [];
608
711
  let faceInfos;
609
712
  try {
713
+ // init() selects EF.DG2 and reads its header over secure messaging; keep
714
+ // it INSIDE the try so a failure here is still recorded as the readDG2
715
+ // step (it was previously outside, so an early DG2 failure went unlabelled).
716
+ const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
717
+ await dg2InputStream.init();
718
+ InputStream.onRead((totalBytesRead, fileLength) => {
719
+ progress = 30 + (totalBytesRead / fileLength) * 70;
720
+ if (progressCallback) {
721
+ progressCallback(progress);
722
+ }
723
+ });
724
+ const dg2File = new DG2File([], dg2InputStream);
610
725
  faceInfos = await dg2File.getFaceInfos();
611
726
  dg2Done('readDG2', true);
612
727
  } catch (e) {
@@ -644,8 +759,26 @@ const eidReader = async (
644
759
  };
645
760
  } catch (error) {
646
761
  const msg = error instanceof Error ? error.message : String(error);
647
- diagnostics.nfcResult(false, categorizeNfcError(msg));
762
+ const category = categorizeNfcError(msg);
763
+ diagnostics.nfcResult(false, category);
648
764
  debugLog('EID', 'Error reading passport data', error);
765
+ // Rethrow so the caller can surface an accurate, actionable message. Swallowing
766
+ // here returned `undefined`, which the UI mis-reported as "invalid MRZ fields"
767
+ // (e.g. a German passport whose BAC/PACE auth or chip read actually failed).
768
+ // The category is attached for the UI to map to a specific message.
769
+ const rethrown = error instanceof Error ? error : new Error(msg);
770
+ const tagged = rethrown as Error & {
771
+ nfcCategory?: string;
772
+ nfcCode?: string;
773
+ };
774
+ tagged.nfcCategory = category;
775
+ tagged.nfcCode = buildNfcErrorCode(
776
+ category,
777
+ msg,
778
+ diagnostics.getFailureStep(),
779
+ diagnostics.getFailureStatusWord()
780
+ );
781
+ throw tagged;
649
782
  } finally {
650
783
  await passportService.close();
651
784
  }
@@ -75,20 +75,35 @@ export class DG11File extends DataGroup {
75
75
  * Strips trailing filler `<` within each segment.
76
76
  */
77
77
  private parseName(nameString: string): void {
78
- const delimIndex = nameString.indexOf('<<');
79
- if (delimIndex < 0) {
80
- this.lastName = nameString.replace(/<+$/, '').trim();
81
- this.firstName = '';
78
+ const fillerToSpace = (s: string): string =>
79
+ s
80
+ .replace(/<+$/, '') // drop trailing filler
81
+ .replace(/</g, ' ') // internal filler = word separator
82
+ .replace(/\s+/g, ' ')
83
+ .trim();
84
+
85
+ const dblIndex = nameString.indexOf('<<');
86
+ if (dblIndex >= 0) {
87
+ // Standard ICAO layout: PRIMARY<<SECONDARY.
88
+ this.lastName = fillerToSpace(nameString.substring(0, dblIndex));
89
+ this.firstName = fillerToSpace(nameString.substring(dblIndex + 2));
82
90
  return;
83
91
  }
84
- this.lastName = nameString
85
- .substring(0, delimIndex)
86
- .replace(/<+$/, '')
87
- .trim();
88
- this.firstName = nameString
89
- .substring(delimIndex + 2)
90
- .replace(/<$/, ' ')
91
- .replace(/<+$/, '')
92
- .trim();
92
+
93
+ // No `<<` separator. Some chips (e.g. certain Turkish PACE passports) store
94
+ // DG11 0x5F0E as SURNAME<GIVEN1<GIVEN2 with single fillers. Split on the
95
+ // FIRST single filler: surname = first token, given names = the rest. With
96
+ // no filler at all the field is a single token we cannot reliably split into
97
+ // surname/given, so leave firstName empty and let the caller decide (the MRZ
98
+ // split is then more trustworthy than a guess).
99
+ const singleIndex = nameString.replace(/<+$/, '').indexOf('<');
100
+ if (singleIndex >= 0) {
101
+ this.lastName = fillerToSpace(nameString.substring(0, singleIndex));
102
+ this.firstName = fillerToSpace(nameString.substring(singleIndex + 1));
103
+ return;
104
+ }
105
+
106
+ this.lastName = fillerToSpace(nameString);
107
+ this.firstName = '';
93
108
  }
94
109
  }
@@ -4,6 +4,36 @@ import { CommandAPDU } from './smartcards/commandAPDU';
4
4
  import { ResponseAPDU } from './smartcards/responseAPDU';
5
5
  import { Platform } from 'react-native';
6
6
 
7
+ // iOS CoreNFC "Tag connection lost" (NFCError 100) is a well-known issue whose
8
+ // likelihood scales with the number of APDU round-trips in a session — a large
9
+ // file like DG2 (face image) needs dozens, so the tag is regularly lost
10
+ // mid-read. The session itself can be re-established with
11
+ // restartTechnologyRequestIOS() WITHOUT losing our PACE/BAC secure-messaging
12
+ // state (keys + send-sequence-counter live in our JS wrapper), so a lost-tag
13
+ // transmit can recover by restarting the session and re-issuing the SAME APDU
14
+ // (idempotent — a failed read advances no SM state, and the caller rewinds the
15
+ // SSC). See react-native-nfc-manager#358.
16
+ const IOS_MAX_RECONNECTS = 3;
17
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
18
+
19
+ const isTagLostError = (e: unknown): boolean => {
20
+ const msg = (
21
+ e instanceof Error ? e.message : typeof e === 'string' ? e : ''
22
+ ).toLowerCase();
23
+ // The native bridge surfaces these for a dropped/timed-out CoreNFC session;
24
+ // an empty message is also treated as a lost tag (the observed DG2 failure).
25
+ return (
26
+ msg === '' ||
27
+ msg.includes('lost') ||
28
+ msg.includes('not connected') ||
29
+ msg.includes('tag was') ||
30
+ msg.includes('session') ||
31
+ msg.includes('invalidated') ||
32
+ msg.includes('timeout') ||
33
+ msg.includes('100')
34
+ );
35
+ };
36
+
7
37
  export class NFCManagerCardService extends CardService {
8
38
  private isOpened = false;
9
39
  public async open(): Promise<void> {
@@ -24,29 +54,77 @@ export class NFCManagerCardService extends CardService {
24
54
  return this.isOpened;
25
55
  }
26
56
 
57
+ // Session-restart recovery is SAFE only for idempotent data-group reads, NOT
58
+ // during PACE/BAC: the authentication state machine lives on the chip, so
59
+ // restarting the CoreNFC session mid-handshake destroys it ("Not even
60
+ // registered"). The reader turns this on AFTER authentication succeeds.
61
+ private sessionRestartEnabled = false;
62
+
63
+ public setSessionRestartEnabled(enabled: boolean): void {
64
+ this.sessionRestartEnabled = enabled;
65
+ }
66
+
27
67
  public async transmit(commandAPDU: CommandAPDU): Promise<ResponseAPDU> {
28
- let response: number[];
68
+ const bytes = Array.from(commandAPDU.getBytes());
29
69
 
30
- if (Platform.OS === 'ios') {
31
- const iosResponse = await NFCManager.sendCommandAPDUIOS(
32
- Array.from(commandAPDU.getBytes())
33
- );
34
- response = [...iosResponse.response, iosResponse.sw1, iosResponse.sw2];
35
- } else {
36
- response = await NFCManager.transceive(
37
- Array.from(commandAPDU.getBytes())
38
- );
70
+ if (Platform.OS !== 'ios') {
71
+ return new ResponseAPDU(await NFCManager.transceive(bytes));
72
+ }
73
+
74
+ // During auth (restart disabled) just send once — never restart mid-PACE.
75
+ if (!this.sessionRestartEnabled) {
76
+ const r = await NFCManager.sendCommandAPDUIOS(bytes);
77
+ return new ResponseAPDU([...r.response, r.sw1, r.sw2]);
39
78
  }
40
79
 
41
- return new ResponseAPDU(response);
80
+ // Post-auth data reads: on a lost-tag error restart the CoreNFC session and
81
+ // retry the same command (secure-messaging state is preserved by the caller).
82
+ let lastError: unknown;
83
+ for (let attempt = 0; attempt <= IOS_MAX_RECONNECTS; attempt++) {
84
+ try {
85
+ const r = await NFCManager.sendCommandAPDUIOS(bytes);
86
+ return new ResponseAPDU([...r.response, r.sw1, r.sw2]);
87
+ } catch (e) {
88
+ lastError = e;
89
+ if (attempt >= IOS_MAX_RECONNECTS || !isTagLostError(e)) {
90
+ throw e;
91
+ }
92
+ // Re-establish the session, then pause briefly so the RF field settles
93
+ // before re-issuing (immediate re-send after a drop usually fails again).
94
+ try {
95
+ await NFCManager.restartTechnologyRequestIOS();
96
+ } catch {
97
+ // If restart itself fails the session is unrecoverable for now —
98
+ // surface the original lost-tag error to the caller.
99
+ throw e;
100
+ }
101
+ await sleep(150 * (attempt + 1));
102
+ }
103
+ }
104
+ throw lastError;
42
105
  }
43
106
 
44
107
  public getATR(): Uint8Array {
45
108
  throw new Error('Method not implemented.');
46
109
  }
47
110
 
48
- public getIsConnectionLost(_error: Error): boolean {
49
- return !this.isOpened;
111
+ public getIsConnectionLost(error: Error): boolean {
112
+ // Lost if the session was closed, or the error looks like a dropped tag.
113
+ return !this.isOpened || isTagLostError(error);
114
+ }
115
+
116
+ /** True if the error looks like a dropped/timed-out CoreNFC tag connection
117
+ * (as opposed to a real APDU/crypto failure). */
118
+ public isTagLost(error: unknown): boolean {
119
+ return isTagLostError(error);
120
+ }
121
+
122
+ /** Re-establish the CoreNFC session (iOS) so a fresh handshake can be retried
123
+ * after a tag drop. No-op on Android. Best-effort. */
124
+ public async restartSession(): Promise<void> {
125
+ if (Platform.OS === 'ios') {
126
+ await NFCManager.restartTechnologyRequestIOS();
127
+ }
50
128
  }
51
129
 
52
130
  public async close(): Promise<void> {