@trustchex/react-native-sdk 1.487.0 → 1.489.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.
- package/ios/ImageDecoder/ImageDecoderModule.m +12 -0
- package/ios/ImageDecoder/ImageDecoderModule.swift +43 -0
- package/lib/module/Shared/Components/EIDScanner.js +100 -37
- package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +9 -1
- package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +22 -2
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +1 -1
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +7 -7
- package/lib/module/Shared/EIDReader/cardFileInputStream.js +25 -2
- package/lib/module/Shared/EIDReader/defaultFileSystem.js +56 -6
- package/lib/module/Shared/EIDReader/eidReader.js +138 -16
- package/lib/module/Shared/EIDReader/lds/icao/dg11File.js +23 -6
- package/lib/module/Shared/EIDReader/nfcManagerCardService.js +79 -9
- package/lib/module/Shared/EIDReader/protocol/paceDH.js +101 -0
- package/lib/module/Shared/EIDReader/protocol/paceErrors.js +34 -0
- package/lib/module/Shared/EIDReader/protocol/paceProtocol.js +53 -4
- package/lib/module/Shared/EIDReader/protocol/readBinaryAPDUSender.js +7 -3
- package/lib/module/Shared/Libs/country-display.utils.js +13 -1
- package/lib/module/Shared/Libs/diagnosticReport.js +24 -1
- package/lib/module/Shared/Libs/diagnostics.js +30 -8
- package/lib/module/Shared/Libs/jp2Decode.js +43 -0
- package/lib/module/Shared/Libs/mrz.utils.js +22 -5
- package/lib/module/Shared/Libs/mrzTransliteration.js +14 -1
- package/lib/module/Translation/Resources/en.js +12 -0
- package/lib/module/Translation/Resources/tr.js +12 -0
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +11 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts +3 -0
- package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts +4 -0
- package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts +9 -1
- package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts +56 -0
- package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts.map +1 -0
- package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts +26 -0
- package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts.map +1 -0
- package/lib/typescript/src/Shared/EIDReader/protocol/paceProtocol.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/protocol/readBinaryAPDUSender.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/country-display.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +9 -1
- package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts +13 -0
- package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/en.d.ts +12 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +12 -0
- package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/Shared/Components/EIDScanner.tsx +153 -69
- package/src/Shared/Components/IdentityDocumentCamera.constants.ts +9 -1
- package/src/Shared/Components/IdentityDocumentCamera.flows.ts +22 -2
- package/src/Shared/Components/IdentityDocumentCamera.tsx +1 -2
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +6 -8
- package/src/Shared/EIDReader/cardFileInputStream.ts +30 -6
- package/src/Shared/EIDReader/defaultFileSystem.ts +75 -17
- package/src/Shared/EIDReader/eidReader.ts +172 -24
- package/src/Shared/EIDReader/lds/icao/dg11File.ts +28 -13
- package/src/Shared/EIDReader/nfcManagerCardService.ts +91 -13
- package/src/Shared/EIDReader/protocol/paceDH.ts +183 -0
- package/src/Shared/EIDReader/protocol/paceErrors.ts +41 -0
- package/src/Shared/EIDReader/protocol/paceProtocol.ts +72 -6
- package/src/Shared/EIDReader/protocol/readBinaryAPDUSender.ts +7 -4
- package/src/Shared/Libs/country-display.utils.ts +12 -1
- package/src/Shared/Libs/diagnosticReport.ts +32 -1
- package/src/Shared/Libs/diagnostics.ts +38 -14
- package/src/Shared/Libs/jp2Decode.ts +48 -0
- package/src/Shared/Libs/mrz.utils.ts +27 -6
- package/src/Shared/Libs/mrzTransliteration.ts +16 -3
- package/src/Translation/Resources/en.ts +19 -0
- package/src/Translation/Resources/tr.ts +19 -0
- package/src/version.ts +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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';
|
|
@@ -11,6 +12,7 @@ import { MRZInfo } from './lds/icao/mrzInfo';
|
|
|
11
12
|
import { InputStream } from './java/inputStream';
|
|
12
13
|
import { debugLog } from '../Libs/debug.utils';
|
|
13
14
|
import { diagnostics } from '../Libs/diagnostics';
|
|
15
|
+
import { decodeJp2ToJpeg } from '../Libs/jp2Decode';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Extract an APDU status word (hex, e.g. "6982") from an error message if the
|
|
@@ -38,6 +40,54 @@ const categorizeNfcError = (msg: string): string => {
|
|
|
38
40
|
return 'reading_error';
|
|
39
41
|
};
|
|
40
42
|
|
|
43
|
+
/** Short, stable code per category for the user-facing reportable error code. */
|
|
44
|
+
const NFC_CATEGORY_CODE: Record<string, string> = {
|
|
45
|
+
user_cancelled: 'CANCEL',
|
|
46
|
+
tag_lost: 'TAGLOST',
|
|
47
|
+
timeout: 'TIMEOUT',
|
|
48
|
+
unsupported: 'UNSUPPORTED',
|
|
49
|
+
not_enabled: 'DISABLED',
|
|
50
|
+
auth_failed: 'AUTH',
|
|
51
|
+
reading_error: 'READ',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Short, stable token per read step, so the error code names WHERE it failed.
|
|
55
|
+
* A generic NFC-READ is not actionable; NFC-READ-DG2-6982 tells support the
|
|
56
|
+
* read failed reading the face image (DG2) with APDU status 6982. */
|
|
57
|
+
const NFC_STEP_CODE: Record<string, string> = {
|
|
58
|
+
open: 'OPEN',
|
|
59
|
+
selectApplet: 'SELAPP',
|
|
60
|
+
selectMF: 'SELMF',
|
|
61
|
+
readCardAccess: 'CARDACCESS',
|
|
62
|
+
paceAuth: 'PACE',
|
|
63
|
+
bacAuth: 'BAC',
|
|
64
|
+
readDG1: 'DG1',
|
|
65
|
+
readDG2: 'DG2',
|
|
66
|
+
readDG11: 'DG11',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a stable, reportable error code from a categorised NFC failure that
|
|
71
|
+
* names the failing STEP, e.g. `NFC-READ-DG2-6982` (reading the face image
|
|
72
|
+
* failed, APDU status 6982) or `NFC-AUTH-PACE-6300` (PACE auth rejected). Falls
|
|
73
|
+
* back to `NFC-<category>` when neither step nor status word is known.
|
|
74
|
+
* Non-PII by construction: only the bucket + step name + the chip's 4-hex
|
|
75
|
+
* status word. A user can quote it to pinpoint exactly where the read failed.
|
|
76
|
+
*/
|
|
77
|
+
const buildNfcErrorCode = (
|
|
78
|
+
category: string,
|
|
79
|
+
msg: string,
|
|
80
|
+
step?: string,
|
|
81
|
+
stepSw?: string
|
|
82
|
+
): string => {
|
|
83
|
+
const part = NFC_CATEGORY_CODE[category] ?? 'READ';
|
|
84
|
+
const stepTok = step ? NFC_STEP_CODE[step] : undefined;
|
|
85
|
+
// Prefer the status word recorded against the failing step; fall back to one
|
|
86
|
+
// parsed from the error message.
|
|
87
|
+
const sw = stepSw ?? extractStatusWord(msg);
|
|
88
|
+
return [`NFC-${part}`, stepTok, sw].filter(Boolean).join('-');
|
|
89
|
+
};
|
|
90
|
+
|
|
41
91
|
// --- Minimal PNG encoder (pure JS, no native deps) ---
|
|
42
92
|
// Uses deflate stored (uncompressed) blocks for O(n) encoding speed.
|
|
43
93
|
// A naive DCT-based JPEG encoder would be O(n²) and freeze the JS thread
|
|
@@ -182,14 +232,48 @@ function pixelsToPng(
|
|
|
182
232
|
* If the image is JPEG 2000, decode and convert to PNG so React Native can display it.
|
|
183
233
|
* Returns { base64, mimeType } with the converted image, or the original if not JP2.
|
|
184
234
|
*/
|
|
185
|
-
|
|
235
|
+
// Largest JP2 face image we will attempt to decode in pure JS on the UI thread.
|
|
236
|
+
// The `jpeg2000` decoder's parse() is SYNCHRONOUS and roughly quadratic, so a
|
|
237
|
+
// large image (some passports store 20–30 KB+ JPEG2000 portraits) blocks the JS
|
|
238
|
+
// thread for many seconds or indefinitely — the German-passport "stuck at face
|
|
239
|
+
// conversion" hang. A JS timeout cannot interrupt a synchronous call, so we must
|
|
240
|
+
// gate BEFORE decoding. ~12 KB decodes fast; beyond that we keep the raw JP2.
|
|
241
|
+
const MAX_JP2_DECODE_BYTES = 12 * 1024;
|
|
242
|
+
|
|
243
|
+
async function convertJP2IfNeeded(
|
|
186
244
|
imageBuffer: Buffer,
|
|
187
245
|
mimeType: string
|
|
188
|
-
): { base64: string; mimeType: string } {
|
|
246
|
+
): Promise<{ base64: string; mimeType: string }> {
|
|
189
247
|
if (mimeType !== 'image/jp2') {
|
|
190
248
|
return { base64: imageBuffer.toString('base64'), mimeType };
|
|
191
249
|
}
|
|
192
250
|
|
|
251
|
+
// Prefer the native decoder (iOS ImageIO). It decodes JPEG2000 of any size
|
|
252
|
+
// without blocking the JS thread, so it both fixes the iOS preview (RN's
|
|
253
|
+
// <Image> can't render a raw image/jp2 data URI) and avoids the slow pure-JS
|
|
254
|
+
// decoder entirely. Returns null on Android / when unavailable → fall through.
|
|
255
|
+
const native = await decodeJp2ToJpeg(imageBuffer);
|
|
256
|
+
if (native) {
|
|
257
|
+
debugLog(
|
|
258
|
+
'EID',
|
|
259
|
+
`[EID] JP2 decoded natively → ${native.mimeType} (${native.base64.length} base64 chars)`
|
|
260
|
+
);
|
|
261
|
+
return native;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Too large to decode synchronously without freezing the UI — keep the raw
|
|
265
|
+
// JP2. The face image is still captured (the verification backend / face-match
|
|
266
|
+
// accept JPEG2000); only the on-screen preview can't render it. This lets the
|
|
267
|
+
// NFC read COMPLETE instead of hanging. (On Android the raw JP2 still renders
|
|
268
|
+
// in <Image>; on iOS the native path above normally handles it.)
|
|
269
|
+
if (imageBuffer.length > MAX_JP2_DECODE_BYTES) {
|
|
270
|
+
debugLog(
|
|
271
|
+
'EID',
|
|
272
|
+
`[EID] JP2 face image is ${imageBuffer.length} bytes (> ${MAX_JP2_DECODE_BYTES}); skipping in-JS decode to avoid blocking, keeping raw JP2`
|
|
273
|
+
);
|
|
274
|
+
return { base64: imageBuffer.toString('base64'), mimeType };
|
|
275
|
+
}
|
|
276
|
+
|
|
193
277
|
debugLog('EID', '[EID] Face image is JP2, attempting conversion…');
|
|
194
278
|
try {
|
|
195
279
|
// Lazy-load jpeg2000 to avoid crashing if it fails to import
|
|
@@ -349,7 +433,12 @@ const eidReader = async (
|
|
|
349
433
|
const stepTimer = () => {
|
|
350
434
|
const t0 = Date.now();
|
|
351
435
|
return (step: string, ok: boolean, error?: unknown) => {
|
|
352
|
-
const msg =
|
|
436
|
+
const msg =
|
|
437
|
+
error instanceof Error
|
|
438
|
+
? error.message
|
|
439
|
+
: error
|
|
440
|
+
? String(error)
|
|
441
|
+
: undefined;
|
|
353
442
|
// Store only the APDU status word and a COARSE category — never the raw
|
|
354
443
|
// error text, which could in theory embed field/PII values. The SW + step
|
|
355
444
|
// name + category carry all the actionable signal.
|
|
@@ -472,12 +561,38 @@ const eidReader = async (
|
|
|
472
561
|
|
|
473
562
|
const done = stepTimer();
|
|
474
563
|
try {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
)
|
|
564
|
+
// PACE runs many APDU round-trips; on iOS the CoreNFC tag connection
|
|
565
|
+
// can drop mid-handshake (empty/lost-tag error). A restart can't
|
|
566
|
+
// resume PACE (the on-chip state is gone), so on a CONNECTION-DROP
|
|
567
|
+
// (not a crypto/capability failure) restart the session and retry
|
|
568
|
+
// the WHOLE PACE handshake from scratch a few times. A real failure
|
|
569
|
+
// (wrong key, unsupported variant) is rethrown immediately.
|
|
570
|
+
const MAX_PACE_ATTEMPTS = 3;
|
|
571
|
+
for (let paceAttempt = 1; ; paceAttempt++) {
|
|
572
|
+
try {
|
|
573
|
+
await passportService.doPACE(
|
|
574
|
+
paceKey,
|
|
575
|
+
paceInfo.oid,
|
|
576
|
+
paceInfo.parameterId,
|
|
577
|
+
progressCallback
|
|
578
|
+
);
|
|
579
|
+
break;
|
|
580
|
+
} catch (attemptErr) {
|
|
581
|
+
const lost = nfcManagerCardService.isTagLost(attemptErr);
|
|
582
|
+
if (!lost || paceAttempt >= MAX_PACE_ATTEMPTS) {
|
|
583
|
+
throw attemptErr;
|
|
584
|
+
}
|
|
585
|
+
debugLog(
|
|
586
|
+
'EID',
|
|
587
|
+
`[EID] PACE attempt ${paceAttempt} lost the tag; restarting session and retrying`
|
|
588
|
+
);
|
|
589
|
+
await nfcManagerCardService.restartSession();
|
|
590
|
+
await new Promise((r) => setTimeout(r, 200 * paceAttempt));
|
|
591
|
+
// Re-select MF + re-read EF.CardAccess so the next PACE starts
|
|
592
|
+
// from the same clean state as the first attempt.
|
|
593
|
+
await passportService.sendSelectMF();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
481
596
|
hasPACESucceeded = true;
|
|
482
597
|
done('paceAuth', true);
|
|
483
598
|
debugLog('EID', '[EID] PACE succeeded');
|
|
@@ -493,13 +608,19 @@ const eidReader = async (
|
|
|
493
608
|
);
|
|
494
609
|
}
|
|
495
610
|
} catch (paceError) {
|
|
496
|
-
// PACE
|
|
497
|
-
|
|
611
|
+
// PACE could not complete — fall back to BAC. Distinguish a CAPABILITY
|
|
612
|
+
// gap (this build can't run the variant the chip advertises) from a
|
|
613
|
+
// RUNTIME failure (wrong key, tag lost, APDU error) so diagnostics show
|
|
614
|
+
// exactly why, e.g. pace_unsupported_curve vs pace_failed.
|
|
615
|
+
const reason = isPACEUnsupportedError(paceError)
|
|
616
|
+
? `pace_unsupported_${paceError.reason}`
|
|
617
|
+
: 'pace_failed';
|
|
618
|
+
diagnostics.nfcPaceFellBackToBac(reason);
|
|
498
619
|
const msg =
|
|
499
620
|
paceError instanceof Error ? paceError.message : String(paceError);
|
|
500
621
|
debugLog(
|
|
501
622
|
'EID',
|
|
502
|
-
`[EID] PACE
|
|
623
|
+
`[EID] PACE could not complete (${reason}: ${msg}), falling back to BAC`
|
|
503
624
|
);
|
|
504
625
|
if (paceError instanceof Error && paceError.stack) {
|
|
505
626
|
debugLog('EID', '[EID] PACE error stack:', paceError.stack);
|
|
@@ -555,6 +676,13 @@ const eidReader = async (
|
|
|
555
676
|
progressCallback(progress);
|
|
556
677
|
}
|
|
557
678
|
|
|
679
|
+
// Authentication is complete — enable iOS CoreNFC session-restart recovery
|
|
680
|
+
// for the data-group reads. It must stay OFF during PACE/BAC (the auth state
|
|
681
|
+
// lives on the chip; a mid-handshake restart destroys it). Reads are
|
|
682
|
+
// idempotent and their secure-messaging state lives in our JS wrapper, so a
|
|
683
|
+
// session restart between blocks is safe here.
|
|
684
|
+
nfcManagerCardService.setSessionRestartEnabled(true);
|
|
685
|
+
|
|
558
686
|
// Read DG1 (MRZ Info)
|
|
559
687
|
const dg1Done = stepTimer();
|
|
560
688
|
let mrzInfo: MRZInfo;
|
|
@@ -592,21 +720,23 @@ const eidReader = async (
|
|
|
592
720
|
|
|
593
721
|
// Read DG2 (Face Image)
|
|
594
722
|
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
723
|
let imageAsBase64 = '';
|
|
605
724
|
let mimeType = '';
|
|
606
|
-
|
|
607
725
|
const allFaceImageInfos: FaceImageInfo[] = [];
|
|
608
726
|
let faceInfos;
|
|
609
727
|
try {
|
|
728
|
+
// init() selects EF.DG2 and reads its header over secure messaging; keep
|
|
729
|
+
// it INSIDE the try so a failure here is still recorded as the readDG2
|
|
730
|
+
// step (it was previously outside, so an early DG2 failure went unlabelled).
|
|
731
|
+
const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
|
|
732
|
+
await dg2InputStream.init();
|
|
733
|
+
InputStream.onRead((totalBytesRead, fileLength) => {
|
|
734
|
+
progress = 30 + (totalBytesRead / fileLength) * 70;
|
|
735
|
+
if (progressCallback) {
|
|
736
|
+
progressCallback(progress);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
const dg2File = new DG2File([], dg2InputStream);
|
|
610
740
|
faceInfos = await dg2File.getFaceInfos();
|
|
611
741
|
dg2Done('readDG2', true);
|
|
612
742
|
} catch (e) {
|
|
@@ -624,7 +754,7 @@ const eidReader = async (
|
|
|
624
754
|
const buffer = Buffer.alloc(imageLength);
|
|
625
755
|
await imageInputStream.readBytesWithOffset(buffer, 0, imageLength);
|
|
626
756
|
const rawMimeType = faceImageInfo.getMimeType();
|
|
627
|
-
const converted = convertJP2IfNeeded(buffer, rawMimeType);
|
|
757
|
+
const converted = await convertJP2IfNeeded(buffer, rawMimeType);
|
|
628
758
|
imageAsBase64 = converted.base64;
|
|
629
759
|
mimeType = converted.mimeType;
|
|
630
760
|
}
|
|
@@ -644,8 +774,26 @@ const eidReader = async (
|
|
|
644
774
|
};
|
|
645
775
|
} catch (error) {
|
|
646
776
|
const msg = error instanceof Error ? error.message : String(error);
|
|
647
|
-
|
|
777
|
+
const category = categorizeNfcError(msg);
|
|
778
|
+
diagnostics.nfcResult(false, category);
|
|
648
779
|
debugLog('EID', 'Error reading passport data', error);
|
|
780
|
+
// Rethrow so the caller can surface an accurate, actionable message. Swallowing
|
|
781
|
+
// here returned `undefined`, which the UI mis-reported as "invalid MRZ fields"
|
|
782
|
+
// (e.g. a German passport whose BAC/PACE auth or chip read actually failed).
|
|
783
|
+
// The category is attached for the UI to map to a specific message.
|
|
784
|
+
const rethrown = error instanceof Error ? error : new Error(msg);
|
|
785
|
+
const tagged = rethrown as Error & {
|
|
786
|
+
nfcCategory?: string;
|
|
787
|
+
nfcCode?: string;
|
|
788
|
+
};
|
|
789
|
+
tagged.nfcCategory = category;
|
|
790
|
+
tagged.nfcCode = buildNfcErrorCode(
|
|
791
|
+
category,
|
|
792
|
+
msg,
|
|
793
|
+
diagnostics.getFailureStep(),
|
|
794
|
+
diagnostics.getFailureStatusWord()
|
|
795
|
+
);
|
|
796
|
+
throw tagged;
|
|
649
797
|
} finally {
|
|
650
798
|
await passportService.close();
|
|
651
799
|
}
|
|
@@ -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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
68
|
+
const bytes = Array.from(commandAPDU.getBytes());
|
|
29
69
|
|
|
30
|
-
if (Platform.OS
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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(
|
|
49
|
-
|
|
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> {
|