@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.
- package/lib/module/Shared/Components/EIDScanner.js +97 -37
- package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +9 -1
- 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 +124 -14
- 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/mrz.utils.js +172 -25
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +29 -1
- 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/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/mrz.utils.d.ts +1 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.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 +149 -68
- package/src/Shared/Components/IdentityDocumentCamera.constants.ts +9 -1
- package/src/Shared/EIDReader/cardFileInputStream.ts +30 -6
- package/src/Shared/EIDReader/defaultFileSystem.ts +75 -17
- package/src/Shared/EIDReader/eidReader.ts +154 -21
- 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/mrz.utils.ts +193 -28
- package/src/Shared/Libs/mrzFrameAggregator.ts +38 -3
- 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
|
@@ -4,6 +4,7 @@ import type { SecureMessagingWrapper } from '../secureMessagingWrapper';
|
|
|
4
4
|
import { AESSecureMessagingWrapper } from '../aesSecureMessagingWrapper';
|
|
5
5
|
import { TripleDesSecureMessagingWrapper } from '../tripleDesSecureMessagingWrapper';
|
|
6
6
|
import { PACEInfo } from '../paceInfo';
|
|
7
|
+
import { PACEUnsupportedError } from './paceErrors';
|
|
7
8
|
import { PACEResult } from './paceResult';
|
|
8
9
|
import { PACEKeySpec } from '../paceKeySpec';
|
|
9
10
|
import { EID_CONSTANTS } from '../constants/eidConstants';
|
|
@@ -26,6 +27,60 @@ import { ec as EC } from 'elliptic';
|
|
|
26
27
|
const _hash = require('hash.js') as any;
|
|
27
28
|
const PresetCurve = _elliptic.curves.PresetCurve;
|
|
28
29
|
|
|
30
|
+
// brainpoolP192r1 (RFC 5639 §3.1) — PACE standardized domain parameter ID 9.
|
|
31
|
+
if (!_elliptic.curves.brainpoolP192r1) {
|
|
32
|
+
_elliptic.curves.brainpoolP192r1 = new PresetCurve({
|
|
33
|
+
type: 'short',
|
|
34
|
+
prime: null,
|
|
35
|
+
p: 'c302f41d932a36cda7a3463093d18db78fce476de1a86297',
|
|
36
|
+
a: '6a91174076b1e0e19c39c031fe8685c1cae040e5c69a28ef',
|
|
37
|
+
b: '469a28ef7c28cca3dc721d044f4496bcca7ef4146fbf25c9',
|
|
38
|
+
n: 'c302f41d932a36cda7a3462f9e9e916b5be8f1029ac4acc1',
|
|
39
|
+
hash: _hash.sha256,
|
|
40
|
+
gRed: false,
|
|
41
|
+
g: [
|
|
42
|
+
'c0a0647eaab6a48753b033c56cb0f0900a2f5c4853375fd6',
|
|
43
|
+
'14b690866abd5bb88b5f4828c1490002e6773fa2fa299b8f',
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// brainpoolP224r1 (RFC 5639 §3.2) — PACE standardized domain parameter ID 11.
|
|
49
|
+
if (!_elliptic.curves.brainpoolP224r1) {
|
|
50
|
+
_elliptic.curves.brainpoolP224r1 = new PresetCurve({
|
|
51
|
+
type: 'short',
|
|
52
|
+
prime: null,
|
|
53
|
+
p: 'd7c134aa264366862a18302575d1d787b09f075797da89f57ec8c0ff',
|
|
54
|
+
a: '68a5e62ca9ce6c1c299803a6c1530b514e182ad8b0042a59cad29f43',
|
|
55
|
+
b: '2580f63ccfe44138870713b1a92369e33e2135d266dbb372386c400b',
|
|
56
|
+
n: 'd7c134aa264366862a18302575d0fb98d116bc4b6ddebca3a5a7939f',
|
|
57
|
+
hash: _hash.sha256,
|
|
58
|
+
gRed: false,
|
|
59
|
+
g: [
|
|
60
|
+
'0d9029ad2c7e5cf4340823b2a87dc68c9e4ce3174c1e6efdee12c07d',
|
|
61
|
+
'58aa56f772c0726f24c6b89e4ecdac24354b9e99caa3f6d3761402cd',
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// brainpoolP320r1 (RFC 5639 §3.4) — PACE standardized domain parameter ID 14.
|
|
67
|
+
if (!_elliptic.curves.brainpoolP320r1) {
|
|
68
|
+
_elliptic.curves.brainpoolP320r1 = new PresetCurve({
|
|
69
|
+
type: 'short',
|
|
70
|
+
prime: null,
|
|
71
|
+
p: 'd35e472036bc4fb7e13c785ed201e065f98fcfa6f6f40def4f92b9ec7893ec28fcd412b1f1b32e27',
|
|
72
|
+
a: '3ee30b568fbab0f883ccebd46d3f3bb8a2a73513f5eb79da66190eb085ffa9f492f375a97d860eb4',
|
|
73
|
+
b: '520883949dfdbc42d3ad198640688a6fe13f41349554b49acc31dccd884539816f5eb4ac8fb1f1a6',
|
|
74
|
+
n: 'd35e472036bc4fb7e13c785ed201e065f98fcfa5b68f12a32d482ec7ee8658e98691555b44c59311',
|
|
75
|
+
hash: _hash.sha384,
|
|
76
|
+
gRed: false,
|
|
77
|
+
g: [
|
|
78
|
+
'43bd7e9afb53d8b85289bcc48ee5bfe6f20137d10a087eb6e7871e2a10a599c710af8d0d39e20611',
|
|
79
|
+
'14fdd05545ec1cc8ab4093247f77275e0743ffed117182eaa9c77877aaac6ac7d35245d1692e8ee1',
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
29
84
|
if (!_elliptic.curves.brainpoolP256r1) {
|
|
30
85
|
_elliptic.curves.brainpoolP256r1 = new PresetCurve({
|
|
31
86
|
type: 'short',
|
|
@@ -130,12 +185,19 @@ export class PACEProtocol {
|
|
|
130
185
|
const keyLength = PACEInfo.toKeyLength(oid);
|
|
131
186
|
|
|
132
187
|
if (agreementAlg !== 'ECDH') {
|
|
133
|
-
|
|
134
|
-
|
|
188
|
+
// DH-based PACE is rare in modern documents; surface it as a typed
|
|
189
|
+
// capability gap so the caller falls back to BAC with a precise reason.
|
|
190
|
+
throw new PACEUnsupportedError(
|
|
191
|
+
'agreement',
|
|
192
|
+
`key agreement ${agreementAlg} (only ECDH implemented)`
|
|
135
193
|
);
|
|
136
194
|
}
|
|
137
195
|
if (mappingType !== 'GM' && mappingType !== 'IM') {
|
|
138
|
-
|
|
196
|
+
// CAM (Chip Authentication Mapping) is not implemented.
|
|
197
|
+
throw new PACEUnsupportedError(
|
|
198
|
+
'mapping',
|
|
199
|
+
`mapping type ${mappingType} (only GM/IM implemented)`
|
|
200
|
+
);
|
|
139
201
|
}
|
|
140
202
|
|
|
141
203
|
// Derive static PACE key K_pi from the access key
|
|
@@ -408,8 +470,9 @@ export class PACEProtocol {
|
|
|
408
470
|
ephemeralParams: { generatorX: string; generatorY: string; curve: string };
|
|
409
471
|
}> {
|
|
410
472
|
if (cipherAlg !== 'AES') {
|
|
411
|
-
throw new
|
|
412
|
-
|
|
473
|
+
throw new PACEUnsupportedError(
|
|
474
|
+
'cipher',
|
|
475
|
+
`IM mapping requires AES, found ${cipherAlg}`
|
|
413
476
|
);
|
|
414
477
|
}
|
|
415
478
|
|
|
@@ -940,7 +1003,10 @@ export class PACEProtocol {
|
|
|
940
1003
|
case 18:
|
|
941
1004
|
return 'p521'; // NIST P-521
|
|
942
1005
|
default:
|
|
943
|
-
throw new
|
|
1006
|
+
throw new PACEUnsupportedError(
|
|
1007
|
+
'curve',
|
|
1008
|
+
`domain parameter ID ${parameterId}`
|
|
1009
|
+
);
|
|
944
1010
|
}
|
|
945
1011
|
}
|
|
946
1012
|
}
|
|
@@ -158,11 +158,14 @@ export class ReadBinaryAPDUSender implements APDULevelReadBinaryCapable {
|
|
|
158
158
|
commandAPDU
|
|
159
159
|
);
|
|
160
160
|
} catch (error) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
// A transient transceive failure (iOS RF drop during a large DG2 read)
|
|
162
|
+
// used to be swallowed here, leaving responseAPDU undefined — which then
|
|
163
|
+
// crashed downstream as a misleading "Could not read file" that the retry
|
|
164
|
+
// layer couldn't recognise. Rethrow so the block-read retry/backoff in
|
|
165
|
+
// CardFileInputStream can re-issue the SAME block (idempotent), and the
|
|
166
|
+
// file system can downgrade the read size if needed.
|
|
165
167
|
console.debug('Exception during READ BINARY', error);
|
|
168
|
+
throw error;
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
const responseData = ReadBinaryAPDUSender.getResponseData(
|
|
@@ -5,6 +5,13 @@ const COUNTRY_NAME_OVERRIDES: Record<string, string> = {
|
|
|
5
5
|
TUR: 'Türkiye',
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
// ICAO/MRZ issuing-state codes that are not ISO-3166 alpha-3 and must be mapped
|
|
9
|
+
// before lookup. "D" is Germany's historical 1-letter MRZ code (ICAO 9303
|
|
10
|
+
// Part 3 §C), surfaced filler-padded as "D<<" by the chip getters.
|
|
11
|
+
const MRZ_CODE_ALIASES: Record<string, string> = {
|
|
12
|
+
D: 'DEU',
|
|
13
|
+
};
|
|
14
|
+
|
|
8
15
|
const findCountryByAlpha3 = (alpha3Code: string) => {
|
|
9
16
|
return [...UnicodeCountry.VALUES, ...ISOCountry.VALUES].find(
|
|
10
17
|
(country) => country.toAlpha3Code() === alpha3Code
|
|
@@ -17,7 +24,11 @@ export const getLocalizedCountryName = (
|
|
|
17
24
|
): string => {
|
|
18
25
|
if (!alpha3Code) return '';
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
// Strip MRZ filler (the chip pads short codes, e.g. German "D" → "D<<") and
|
|
28
|
+
// map non-ISO MRZ codes to their alpha-3 before lookup.
|
|
29
|
+
const stripped = alpha3Code.replace(/</g, '').trim().toUpperCase();
|
|
30
|
+
if (!stripped) return '';
|
|
31
|
+
const normalizedCode = MRZ_CODE_ALIASES[stripped] ?? stripped;
|
|
21
32
|
const overrideName = COUNTRY_NAME_OVERRIDES[normalizedCode];
|
|
22
33
|
|
|
23
34
|
if (overrideName) {
|
|
@@ -31,6 +31,30 @@ const icaoCheckDigit = (str: string): string => {
|
|
|
31
31
|
return String(sum);
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Reportable NFC error code, e.g. `NFC-AUTH-6982`. MUST match the code shown to
|
|
36
|
+
* the user by EIDScanner (built in eidReader.buildNfcErrorCode) so a user who
|
|
37
|
+
* quotes "NFC-AUTH-6982" can be matched verbatim against their diagnostics.json.
|
|
38
|
+
* Local copy of the category→prefix map (eidReader pulls in native NFC deps).
|
|
39
|
+
*/
|
|
40
|
+
const NFC_CATEGORY_CODE: Record<string, string> = {
|
|
41
|
+
user_cancelled: 'CANCEL',
|
|
42
|
+
tag_lost: 'TAGLOST',
|
|
43
|
+
timeout: 'TIMEOUT',
|
|
44
|
+
unsupported: 'UNSUPPORTED',
|
|
45
|
+
not_enabled: 'DISABLED',
|
|
46
|
+
auth_failed: 'AUTH',
|
|
47
|
+
reading_error: 'READ',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const nfcErrorCode = (
|
|
51
|
+
category: string | undefined,
|
|
52
|
+
statusWord: string | undefined
|
|
53
|
+
): string => {
|
|
54
|
+
const part = NFC_CATEGORY_CODE[category ?? ''] ?? 'READ';
|
|
55
|
+
return statusWord ? `NFC-${part}-${statusWord}` : `NFC-${part}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
34
58
|
export interface ScanDataForReport {
|
|
35
59
|
documentType?: string;
|
|
36
60
|
dataSource?: string;
|
|
@@ -166,9 +190,16 @@ export const buildDiagnosticReport = (params: {
|
|
|
166
190
|
|
|
167
191
|
// --- compact email body (always under MAX_BODY_BYTES) ---
|
|
168
192
|
const nfc = diag.nfc;
|
|
193
|
+
// Status word of the failing step (if any), used to build the reportable code.
|
|
194
|
+
const failingSw = nfc.failureStep
|
|
195
|
+
? nfc.steps.find((s) => s.step === nfc.failureStep)?.sw
|
|
196
|
+
: undefined;
|
|
197
|
+
const nfcCode = nfcErrorCode(nfc.errorCategory, failingSw);
|
|
169
198
|
const nfcLine = nfc.attempted
|
|
170
199
|
? `${nfc.authProtocol}${nfc.paceFellBackToBac ? '→BAC' : ''} · ${
|
|
171
|
-
nfc.succeeded
|
|
200
|
+
nfc.succeeded
|
|
201
|
+
? 'success'
|
|
202
|
+
: `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'}) [${nfcCode}]`
|
|
172
203
|
}`
|
|
173
204
|
: 'not attempted';
|
|
174
205
|
const checksLine = checkDigits
|
|
@@ -40,7 +40,9 @@ export interface NfcDiagnostics {
|
|
|
40
40
|
/** PACE object identifier string when PACE was attempted. */
|
|
41
41
|
paceOid?: string;
|
|
42
42
|
/** Why PACE wasn't used / fell back: 'no_card_access' | 'no_pace_info' |
|
|
43
|
-
* 'pace_failed'
|
|
43
|
+
* 'pace_failed' | 'pace_unsupported_<agreement|mapping|curve|cipher>'.
|
|
44
|
+
* The pace_unsupported_* reasons mark a CAPABILITY gap (this build can't run
|
|
45
|
+
* the variant the chip advertises) vs pace_failed (a runtime failure). */
|
|
44
46
|
paceFallbackReason?: string;
|
|
45
47
|
steps: NfcStep[];
|
|
46
48
|
/** Name of the first failing step, if any. */
|
|
@@ -175,10 +177,8 @@ export class DiagnosticsCollector {
|
|
|
175
177
|
setMrz(m: Partial<MrzDiagnostics>): void {
|
|
176
178
|
const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
|
|
177
179
|
const maxMrzBandHeightPx =
|
|
178
|
-
Math.max(
|
|
179
|
-
|
|
180
|
-
m.maxMrzBandHeightPx ?? 0
|
|
181
|
-
) || undefined;
|
|
180
|
+
Math.max(this.mrz.maxMrzBandHeightPx ?? 0, m.maxMrzBandHeightPx ?? 0) ||
|
|
181
|
+
undefined;
|
|
182
182
|
this.mrz = { ...this.mrz, ...m, reachedStable, maxMrzBandHeightPx };
|
|
183
183
|
}
|
|
184
184
|
|
|
@@ -186,7 +186,16 @@ export class DiagnosticsCollector {
|
|
|
186
186
|
|
|
187
187
|
nfcAttemptStarted(): void {
|
|
188
188
|
// A fresh attempt: bump retry count if a previous attempt left steps.
|
|
189
|
-
if (this.nfc.attempted)
|
|
189
|
+
if (this.nfc.attempted) {
|
|
190
|
+
this.nfc.retryCount += 1;
|
|
191
|
+
// A retry supersedes the prior attempt: that attempt's failure is stale,
|
|
192
|
+
// so clear it. The failure markers then reflect only the CURRENT attempt —
|
|
193
|
+
// a within-attempt failure (e.g. PACE failing before a BAC fallback in the
|
|
194
|
+
// same read) is retained, while a failure that a later retry recovered is
|
|
195
|
+
// not reported as the outcome.
|
|
196
|
+
this.nfc.failureStep = undefined;
|
|
197
|
+
this.nfc.errorCategory = undefined;
|
|
198
|
+
}
|
|
190
199
|
this.nfc.attempted = true;
|
|
191
200
|
}
|
|
192
201
|
|
|
@@ -214,15 +223,28 @@ export class DiagnosticsCollector {
|
|
|
214
223
|
}
|
|
215
224
|
}
|
|
216
225
|
|
|
226
|
+
/** The first NFC step that failed in the current attempt (e.g. 'paceAuth',
|
|
227
|
+
* 'readDG2'), or undefined. Lets the caller build a step-specific error code
|
|
228
|
+
* without taking a full snapshot. */
|
|
229
|
+
getFailureStep(): string | undefined {
|
|
230
|
+
return this.nfc.failureStep;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** APDU status word recorded for the failing step, if any (e.g. '6982'). */
|
|
234
|
+
getFailureStatusWord(): string | undefined {
|
|
235
|
+
const fs = this.nfc.failureStep;
|
|
236
|
+
if (!fs) return undefined;
|
|
237
|
+
return this.nfc.steps.find((s) => s.step === fs && !s.ok)?.sw;
|
|
238
|
+
}
|
|
239
|
+
|
|
217
240
|
nfcResult(succeeded: boolean, errorCategory?: string): void {
|
|
218
241
|
this.nfc.succeeded = succeeded;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
} else if (errorCategory) {
|
|
242
|
+
// Note: a failure recovered by a RETRY is already cleared in
|
|
243
|
+
// nfcAttemptStarted (the retry supersedes it). We deliberately do NOT clear
|
|
244
|
+
// here on success, so a within-attempt failure that a fallback recovered
|
|
245
|
+
// (e.g. PACE failing before BAC succeeds in the same read) stays visible in
|
|
246
|
+
// diagnostics — that is a meaningful signal, not the overall outcome.
|
|
247
|
+
if (!succeeded && errorCategory) {
|
|
226
248
|
this.nfc.errorCategory = errorCategory;
|
|
227
249
|
}
|
|
228
250
|
}
|
|
@@ -238,7 +260,9 @@ export class DiagnosticsCollector {
|
|
|
238
260
|
generatedAt: isoNow,
|
|
239
261
|
documentType: this.documentType,
|
|
240
262
|
dataSource: this.dataSource,
|
|
241
|
-
totalDurationMs: this.startedAtMs
|
|
263
|
+
totalDurationMs: this.startedAtMs
|
|
264
|
+
? Math.max(0, nowMs - this.startedAtMs)
|
|
265
|
+
: 0,
|
|
242
266
|
camera: { ...this.camera },
|
|
243
267
|
mrz: { ...this.mrz },
|
|
244
268
|
nfc: { ...this.nfc, steps: this.nfc.steps.map((s) => ({ ...s })) },
|
|
@@ -14,6 +14,39 @@ const RECOVERY_BUDGET_MS = 80;
|
|
|
14
14
|
let recoveryDeadline = 0;
|
|
15
15
|
const recoveryExpired = (): boolean => Date.now() > recoveryDeadline;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Reduce an ICAO document code to the single canonical class char the rest of the
|
|
19
|
+
* app keys off of: `'P'` for any passport (`P`, `P<`, `PP`, `PD`, `PS`, `PO`, …)
|
|
20
|
+
* and `'I'` for any identity card (`I`, `ID`, `IR`, `AC`, `C`, …). The full
|
|
21
|
+
* subtype is informative but every downstream check is `=== 'I'` / `=== 'P'`, so
|
|
22
|
+
* carrying `PP`/`ID` here only mis-classifies the document (German passport `PP`
|
|
23
|
+
* → UNKNOWN). The chip/NFC path keeps the raw code; this normalizes the OCR path.
|
|
24
|
+
*/
|
|
25
|
+
const normalizeDocumentCode = (
|
|
26
|
+
code: string | null | undefined
|
|
27
|
+
): string | null => {
|
|
28
|
+
if (!code) return null;
|
|
29
|
+
const c = code.toUpperCase();
|
|
30
|
+
if (c[0] === 'P') return 'P';
|
|
31
|
+
// TD1/TD2 identity documents: I, ID, IR, AC, AR, C, etc. — all reduce to 'I'.
|
|
32
|
+
return 'I';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Strip MRZ filler characters from a human-readable text field. In the MRZ a
|
|
37
|
+
* single `<` separates words and `<<` separates components; the `mrz` package
|
|
38
|
+
* usually trims trailing fillers but can leave embedded `<`/`<<` in name and
|
|
39
|
+
* optional-data fields, which then surfaced raw in the UI (e.g.
|
|
40
|
+
* "ANNA<<MARIA"). Convert any filler run to a single space and trim, so the
|
|
41
|
+
* displayed value reads "ANNA MARIA". Returns null for empty/filler-only input.
|
|
42
|
+
* Only for text fields — never call on dates, sex, codes, or check digits.
|
|
43
|
+
*/
|
|
44
|
+
const cleanMRZTextField = (value: string | null | undefined): string | null => {
|
|
45
|
+
if (!value) return null;
|
|
46
|
+
const cleaned = value.replace(/</g, ' ').replace(/\s+/g, ' ').trim();
|
|
47
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
17
50
|
/**
|
|
18
51
|
* Pads a TD1 line to the fixed 30-char width.
|
|
19
52
|
*
|
|
@@ -303,12 +336,15 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
|
|
|
303
336
|
const pad = (s: string, len: number): string =>
|
|
304
337
|
s.length >= len ? s.slice(0, len) : s.padEnd(len, '<');
|
|
305
338
|
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
339
|
+
// Repair the document-subtype character (line 1, position 2). For TD1/TD2 IDs
|
|
340
|
+
// this is usually "<" and OCR may read it as "K"; collapse that misread back to
|
|
341
|
+
// "<". But for PASSPORTS position 2 is a real subtype LETTER (German ordinary
|
|
342
|
+
// passports are "PP", and "PC"/"PD"/"PR"/… exist) — it must NOT be rewritten,
|
|
343
|
+
// or the whole line shifts right and validation fails. So only normalise a "K"
|
|
344
|
+
// misread (→ "<"); leave any other letter/filler exactly as read.
|
|
309
345
|
const fixDocStart = (s: string): string =>
|
|
310
346
|
s.replace(/^([ACIPV])([A-Z0-9<])/, (_m, code, next) =>
|
|
311
|
-
next === '
|
|
347
|
+
next === 'K' ? code + '<' : code + next
|
|
312
348
|
);
|
|
313
349
|
|
|
314
350
|
const candidates: string[][] = [];
|
|
@@ -317,26 +353,133 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
|
|
|
317
353
|
// so title/front-of-card text mixed into the blob can't create a false anchor.
|
|
318
354
|
const docLine = ocrLines.find((l) => /^[ACIPV][A-Z0-9<]{3,}<</.test(l));
|
|
319
355
|
|
|
356
|
+
// Passport/TD-x line-2 signature: docNumber(9) + check + nationality(3) +
|
|
357
|
+
// birth(6)+check+sex+expiry(6)+check… Allow letters in the nationality and
|
|
358
|
+
// date spans (OCR reads 0↔O, 1↔I, etc.); the check-digit recovery downstream
|
|
359
|
+
// resolves them. A too-strict [A-Z]{3}/[0-9]{6} here was dropping the whole
|
|
360
|
+
// TD3/TD2 candidate on any noisy frame, leaving only a (wrong) 3-line TD1.
|
|
361
|
+
const td23Line2 =
|
|
362
|
+
/[A-Z0-9<]{9}[0-9<][A-Z<]{3}[A-Z0-9<]{6}[0-9<][MF<][A-Z0-9<]{6}[0-9<]/;
|
|
363
|
+
|
|
364
|
+
// LINE-AWARE line-2 finder. Real ML Kit OCR (a) prepends non-MRZ header text
|
|
365
|
+
// and (b) SPLITS a long MRZ line at its trailing filler run into two OCR lines
|
|
366
|
+
// (e.g. "…1204159<<<<<" + "<<<<<08"). The flat-blob approach loses line
|
|
367
|
+
// boundaries — a greedy line-1 match overruns into line 2, and the line-2
|
|
368
|
+
// regex can match a doc-number-shaped run inside line 1's name field. So work
|
|
369
|
+
// from `ocrLines`: find the doc-number record line, and if it's short (split),
|
|
370
|
+
// stitch following fragment lines onto it up to the format width. The check
|
|
371
|
+
// digits (incl. the trailing composite) then survive instead of being padded
|
|
372
|
+
// away. Returns null if no doc-number line exists at/after the line-1 index.
|
|
373
|
+
// Local 7-3-1 check digit (icaoCheckDigit is defined later in the module).
|
|
374
|
+
const cd731 = (str: string): string => {
|
|
375
|
+
const w = [7, 3, 1];
|
|
376
|
+
let sum = 0;
|
|
377
|
+
for (let i = 0; i < str.length; i++) {
|
|
378
|
+
const c = str[i];
|
|
379
|
+
const v =
|
|
380
|
+
c === '<'
|
|
381
|
+
? 0
|
|
382
|
+
: c >= '0' && c <= '9'
|
|
383
|
+
? c.charCodeAt(0) - 48
|
|
384
|
+
: c.charCodeAt(0) - 55;
|
|
385
|
+
sum = (sum + w[i % 3] * v) % 10;
|
|
386
|
+
}
|
|
387
|
+
return String(sum);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const findLine2 = (l1Index: number, width: number): string | null => {
|
|
391
|
+
for (let i = l1Index + 1; i < ocrLines.length; i++) {
|
|
392
|
+
const m = td23Line2.exec(ocrLines[i]);
|
|
393
|
+
if (!m || m.index !== 0) continue;
|
|
394
|
+
// The front 28 chars — docNumber(9)+check + nationality(3) +
|
|
395
|
+
// birth(6)+check + sex + expiry(6)+check — are positionally stable in OCR.
|
|
396
|
+
// The optional-data field and the two trailing check digits are what an
|
|
397
|
+
// OCR line-split garbles (dropped fillers shift the composite digit). For
|
|
398
|
+
// TD3 (width 44) REBUILD the line canonically: stable front + empty
|
|
399
|
+
// optional + recomputed optional-check + recomputed composite. This is
|
|
400
|
+
// robust to the split-filler ambiguity that single-frame stitching can't
|
|
401
|
+
// resolve. (Passports with a NON-empty optional/personal-number field are
|
|
402
|
+
// rarer; multi-frame voting still covers those via the stitch fallback.)
|
|
403
|
+
const front = m[0]; // exactly 28 chars by the regex
|
|
404
|
+
if (width === 44 && front.length >= 28) {
|
|
405
|
+
let head = front.slice(0, 28);
|
|
406
|
+
// Self-correct the document number against its OWN check digit before we
|
|
407
|
+
// recompute the composite — otherwise a recomputed composite would be
|
|
408
|
+
// self-consistent with an OCR look-alike error (e.g. O→0) and MASK it
|
|
409
|
+
// from the downstream ambiguous-char recovery. Try the digit/letter
|
|
410
|
+
// look-alike swaps in the 9-char doc-number field until its check digit
|
|
411
|
+
// (position 9) matches; only then rebuild the optional + composite.
|
|
412
|
+
const docNum = head.slice(0, 9);
|
|
413
|
+
const docCheck = head[9];
|
|
414
|
+
if (cd731(docNum) !== docCheck) {
|
|
415
|
+
const swaps: Record<string, string> = {
|
|
416
|
+
O: '0',
|
|
417
|
+
Q: '0',
|
|
418
|
+
D: '0',
|
|
419
|
+
I: '1',
|
|
420
|
+
L: '1',
|
|
421
|
+
S: '5',
|
|
422
|
+
B: '8',
|
|
423
|
+
Z: '2',
|
|
424
|
+
G: '6',
|
|
425
|
+
};
|
|
426
|
+
const fixed = docNum.replace(/[OQDILSBZG]/g, (c) => swaps[c] ?? c);
|
|
427
|
+
if (cd731(fixed) === docCheck) {
|
|
428
|
+
head = fixed + head.slice(9);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const optional = '<'.repeat(14);
|
|
432
|
+
const l2NoComposite = head + optional + cd731(optional);
|
|
433
|
+
const composite =
|
|
434
|
+
l2NoComposite.slice(0, 10) +
|
|
435
|
+
l2NoComposite.slice(13, 20) +
|
|
436
|
+
l2NoComposite.slice(21, 43);
|
|
437
|
+
return l2NoComposite + cd731(composite);
|
|
438
|
+
}
|
|
439
|
+
// Non-TD3 or unexpected: stitch fragments up to width as a fallback.
|
|
440
|
+
let joined = ocrLines[i];
|
|
441
|
+
let j = i + 1;
|
|
442
|
+
while (joined.length < width && j < ocrLines.length) {
|
|
443
|
+
joined += ocrLines[j];
|
|
444
|
+
j++;
|
|
445
|
+
}
|
|
446
|
+
return joined.length >= width ? joined.slice(0, width) : joined;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
};
|
|
450
|
+
|
|
320
451
|
// ---- TD3 (passport): line1 "P<XXX…", line2 starts with a 9-char doc number ----
|
|
321
|
-
// Line 2 signature: docNumber(9) + check + nationality(3) + birth(6)+check+sex+expiry(6)+check…
|
|
322
452
|
{
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
453
|
+
// Position 2 of line 1 is the "<" filler, frequently misread under glare as a
|
|
454
|
+
// filler-letter (K/C/E/G) — accept those for ALL issuers, not just TUR.
|
|
455
|
+
// Line-1 structure (ICAO 9303): doc code "P" + 1-char subtype (often "<",
|
|
456
|
+
// but a LETTER for some issuers — e.g. German ordinary passports are "PP",
|
|
457
|
+
// and "PC"/"PD" exist) + issuing state (1–3 letters then "<" filler; Germany
|
|
458
|
+
// is "D" → "PPD<<"). Find line 1 among the OCR lines (NOT the flat blob, whose
|
|
459
|
+
// greedy match overruns line boundaries).
|
|
460
|
+
const l1Index = ocrLines.findIndex((l) =>
|
|
461
|
+
/^P[A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l)
|
|
326
462
|
);
|
|
327
|
-
|
|
328
|
-
|
|
463
|
+
const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
|
|
464
|
+
const l2 = l1Index >= 0 ? findLine2(l1Index, 44) : null;
|
|
465
|
+
if (l1Raw && l2) {
|
|
466
|
+
candidates.push([pad(fixDocStart(l1Raw), 44), pad(l2, 44)]);
|
|
329
467
|
}
|
|
330
468
|
}
|
|
331
469
|
|
|
332
470
|
// ---- TD2: line1 "X<XXX…", line2 starts with docNumber ----
|
|
333
471
|
{
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
472
|
+
// TD2/ID doc codes are A/C/I (NOT P — that's a passport/TD3; including it
|
|
473
|
+
// here made passports get built as a 36-char TD2 and win selection). Position
|
|
474
|
+
// 2 is the SUBTYPE — any A–Z or "<" ("IR","IP","AR","AC"…). Line-aware (see
|
|
475
|
+
// TD3): find line 1 in ocrLines, stitch a split line 2.
|
|
476
|
+
const l1Index = ocrLines.findIndex((l) =>
|
|
477
|
+
/^[ACI][A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l)
|
|
337
478
|
);
|
|
338
|
-
|
|
339
|
-
|
|
479
|
+
const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
|
|
480
|
+
const l2 = l1Index >= 0 ? findLine2(l1Index, 36) : null;
|
|
481
|
+
if (l1Raw && l2) {
|
|
482
|
+
candidates.push([pad(fixDocStart(l1Raw), 36), pad(l2, 36)]);
|
|
340
483
|
}
|
|
341
484
|
}
|
|
342
485
|
|
|
@@ -355,9 +498,22 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
|
|
|
355
498
|
);
|
|
356
499
|
const l2m =
|
|
357
500
|
l2Line ??
|
|
358
|
-
flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z]{2,3}/)?.[0];
|
|
359
|
-
|
|
360
|
-
|
|
501
|
+
flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z<]{2,3}/)?.[0];
|
|
502
|
+
|
|
503
|
+
// Don't emit a 3-line TD1 candidate for passport-shaped input. A passport
|
|
504
|
+
// (TD3) line 1 starts with "P" + a subtype char (a filler "<" OR a real
|
|
505
|
+
// letter — German ordinary passports are "PP", and "PC"/"PD"/… exist), or
|
|
506
|
+
// the band is two long ~44-char lines. Matching ANY "P"-prefixed L1 is safe:
|
|
507
|
+
// no valid TD1 (doc codes A/C/I only) ever starts with "P". Without this
|
|
508
|
+
// guard a noisy passport whose TD3 line-2 regex just missed would fall
|
|
509
|
+
// through to here and be forced into a 3×30 TD1 layout — the "always 3
|
|
510
|
+
// lines, invalid" bug.
|
|
511
|
+
const looksLikePassport =
|
|
512
|
+
(!!l1Source && /^P[A-Z<]/.test(l1Source)) ||
|
|
513
|
+
(ocrLines.length === 2 &&
|
|
514
|
+
ocrLines.every((l) => Math.abs(l.length - 44) <= 4));
|
|
515
|
+
|
|
516
|
+
if (l1Source && !looksLikePassport) {
|
|
361
517
|
let l1 = pad(fixDocStart(l1Source), 30);
|
|
362
518
|
// The issuing-state field (TD1 line 1, positions 2–5) is structurally
|
|
363
519
|
// fillers/letters only — never a personal name — so a "K" there flanked by
|
|
@@ -409,9 +565,11 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
|
|
|
409
565
|
l
|
|
410
566
|
// digit look-alikes inside the name → letters
|
|
411
567
|
.replace(/[015862]/g, (d) => digitToLetter[d] ?? d)
|
|
412
|
-
//
|
|
413
|
-
// is
|
|
414
|
-
|
|
568
|
+
// ONLY collapse the TRAILING run of filler-letters (K/C/E/G) — that
|
|
569
|
+
// region is unambiguous "<" padding. Do NOT collapse internal runs
|
|
570
|
+
// flanked by fillers: a short given/sur-name made only of K/C/E/G
|
|
571
|
+
// (e.g. Turkish "ECE"/"EGE", "GEC") sits between "<<" separators and
|
|
572
|
+
// would be wrongly erased.
|
|
415
573
|
.replace(/[KCEG]+$/g, (m) => '<'.repeat(m.length))
|
|
416
574
|
);
|
|
417
575
|
};
|
|
@@ -678,7 +836,11 @@ const linesFromParseRanges = (
|
|
|
678
836
|
for (const range of detail.ranges ?? []) {
|
|
679
837
|
// `raw` (the actual characters at this span) exists at runtime but isn't in
|
|
680
838
|
// the `mrz` Range type.
|
|
681
|
-
const {
|
|
839
|
+
const {
|
|
840
|
+
line,
|
|
841
|
+
start,
|
|
842
|
+
raw = '',
|
|
843
|
+
} = range as typeof range & { raw?: string };
|
|
682
844
|
const buf = buffers[line];
|
|
683
845
|
if (!buf) continue;
|
|
684
846
|
for (let i = 0; i < raw.length && start + i < width; i++) {
|
|
@@ -1241,18 +1403,20 @@ const validateMRZ = (
|
|
|
1241
1403
|
|
|
1242
1404
|
// Map mrz package fields to our MRZFields type
|
|
1243
1405
|
const fields: MRZFields = {
|
|
1244
|
-
documentCode: result.fields.documentCode
|
|
1406
|
+
documentCode: normalizeDocumentCode(result.fields.documentCode),
|
|
1245
1407
|
issuingState: result.fields.issuingState || null,
|
|
1246
1408
|
documentNumber: result.fields.documentNumber || null,
|
|
1247
1409
|
nationality: result.fields.nationality || null,
|
|
1248
|
-
|
|
1249
|
-
|
|
1410
|
+
// Text fields can carry embedded MRZ filler (`<`/`<<`) — strip it so the
|
|
1411
|
+
// UI shows "ANNA MARIA", not "ANNA<<MARIA".
|
|
1412
|
+
lastName: cleanMRZTextField(result.fields.lastName),
|
|
1413
|
+
firstName: cleanMRZTextField(result.fields.firstName),
|
|
1250
1414
|
sex: result.fields.sex || null,
|
|
1251
1415
|
birthDate: result.fields.birthDate || null,
|
|
1252
1416
|
expirationDate: result.fields.expirationDate || null,
|
|
1253
|
-
personalNumber: result.fields.personalNumber
|
|
1254
|
-
optional1: result.fields.optional1
|
|
1255
|
-
optional2: result.fields.optional2
|
|
1417
|
+
personalNumber: cleanMRZTextField(result.fields.personalNumber),
|
|
1418
|
+
optional1: cleanMRZTextField(result.fields.optional1),
|
|
1419
|
+
optional2: cleanMRZTextField(result.fields.optional2),
|
|
1256
1420
|
};
|
|
1257
1421
|
|
|
1258
1422
|
return {
|
|
@@ -1376,4 +1540,5 @@ export default {
|
|
|
1376
1540
|
isValidOCRBPattern,
|
|
1377
1541
|
applyOCRBCorrections,
|
|
1378
1542
|
convertMRZDateToISODate,
|
|
1543
|
+
normalizeDocumentCode,
|
|
1379
1544
|
};
|
|
@@ -146,6 +146,33 @@ const linesForFrame = (text: string): string[] | null => {
|
|
|
146
146
|
const exact = exactFormatLines(rawLines);
|
|
147
147
|
if (exact) return exact;
|
|
148
148
|
|
|
149
|
+
// Near-format catch for 2-line bands (TD3 passport 2×44, TD2 2×36). A passport
|
|
150
|
+
// OCR'd a few chars off (2×43, 2×45) would otherwise fall into the TD1-biased
|
|
151
|
+
// reconstruction and be forced into a wrong 3×30 layout. When we have exactly
|
|
152
|
+
// two lines both close to a 2-line format's width, coerce them to that width
|
|
153
|
+
// and keep them as-is (2 lines) rather than reconstructing. Tie-break to the
|
|
154
|
+
// closest width. Never matches a 3-line TD1 (line count differs).
|
|
155
|
+
if (rawLines.length === 2) {
|
|
156
|
+
const twoLineFormats = Object.values(FORMAT_GEOMETRY).filter(
|
|
157
|
+
(g) => g.lines === 2
|
|
158
|
+
);
|
|
159
|
+
let bestGeom: { lines: number; width: number } | null = null;
|
|
160
|
+
let bestDelta = Infinity;
|
|
161
|
+
for (const g of twoLineFormats) {
|
|
162
|
+
const delta = Math.max(
|
|
163
|
+
...rawLines.map((l) => Math.abs(l.length - g.width))
|
|
164
|
+
);
|
|
165
|
+
if (delta <= 4 && delta < bestDelta) {
|
|
166
|
+
bestDelta = delta;
|
|
167
|
+
bestGeom = g;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (bestGeom) {
|
|
171
|
+
const w = bestGeom.width;
|
|
172
|
+
return rawLines.map((l) => (l.length >= w ? l.slice(0, w) : l.padEnd(w, '<')));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
149
176
|
const candidates = mrzUtils.reconstructMRZCandidates(text);
|
|
150
177
|
if (!candidates.length) {
|
|
151
178
|
// Fall back to the lightweight fixMRZ split for already-clean text.
|
|
@@ -165,9 +192,17 @@ const linesForFrame = (text: string): string[] | null => {
|
|
|
165
192
|
let best: string[] | null = null;
|
|
166
193
|
let bestScore = -1;
|
|
167
194
|
for (const cand of candidates) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
195
|
+
// Among formats with this line count, pick the one whose width best matches
|
|
196
|
+
// the candidate's lines. TD2 and TD3 are BOTH 2 lines (36 vs 44) — matching
|
|
197
|
+
// by line count alone wrongly picked TD2 for a 44-wide passport, then the
|
|
198
|
+
// ">6 off width" guard discarded it. Average line length disambiguates.
|
|
199
|
+
const avgLen =
|
|
200
|
+
cand.reduce((s, l) => s + l.length, 0) / Math.max(1, cand.length);
|
|
201
|
+
const geom = Object.values(FORMAT_GEOMETRY)
|
|
202
|
+
.filter((g) => g.lines === cand.length)
|
|
203
|
+
.sort(
|
|
204
|
+
(a, b) => Math.abs(a.width - avgLen) - Math.abs(b.width - avgLen)
|
|
205
|
+
)[0];
|
|
171
206
|
if (!geom) continue;
|
|
172
207
|
// Reject if any line is wildly off the expected width (likely not the band).
|
|
173
208
|
if (cand.some((l) => Math.abs(l.length - geom.width) > 6)) continue;
|