@myazahq/kyc-sdk-react-native 2.2.0 → 2.4.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/package.json +1 -2
- package/src/components/BrandBar.tsx +137 -0
- package/src/components/DialCodePicker.tsx +11 -17
- package/src/components/DocumentReviewSide.tsx +34 -14
- package/src/components/Icon.tsx +5 -0
- package/src/components/KycSheet.tsx +161 -180
- package/src/components/MediaSourceSheet.tsx +7 -37
- package/src/components/MyazaDateField.tsx +6 -20
- package/src/components/MyazaInput.tsx +6 -1
- package/src/components/MyazaSelect.tsx +20 -39
- package/src/components/PoweredBy.tsx +20 -15
- package/src/components/ProgressBar.tsx +91 -0
- package/src/components/SandboxBanner.tsx +92 -0
- package/src/components/StepHeader.tsx +15 -2
- package/src/components/StepIndicator.tsx +145 -31
- package/src/components/fonts.ts +23 -0
- package/src/components/glass/ChromeGlass.tsx +65 -0
- package/src/components/glass/FloatingSheet.tsx +190 -0
- package/src/components/glass/GlassSheet.tsx +38 -0
- package/src/components/glass/GlassSurface.tsx +31 -3
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
- package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
- package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
- package/src/config/questionnaire.ts +45 -4
- package/src/config/workflowMerge.ts +1 -0
- package/src/emrtd/crypto.ts +1 -1
- package/src/emrtd/dg1.ts +55 -0
- package/src/emrtd/ec-curves.ts +142 -0
- package/src/emrtd/ec.ts +196 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/mrzKey.ts +21 -1
- package/src/emrtd/open.ts +169 -0
- package/src/emrtd/pace-params.ts +169 -0
- package/src/emrtd/pace.ts +295 -0
- package/src/emrtd/secureMessaging.ts +32 -14
- package/src/emrtd/session.ts +124 -20
- package/src/emrtd/suites.ts +99 -0
- package/src/index.ts +1 -0
- package/src/lib/step-log.ts +43 -0
- package/src/lib/step-window.ts +96 -0
- package/src/liveness/useLiveness.ts +1 -1
- package/src/screens/IdTypeStep.tsx +15 -2
- package/src/screens/NfcStep.tsx +12 -0
- package/src/screens/QuestionnaireField.tsx +30 -0
- package/src/screens/QuestionnaireStep.tsx +3 -1
- package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
- package/src/services/deviceMetadata.ts +9 -1
- package/src/store/derive.ts +7 -0
- package/src/store/kycStore.ts +25 -1
- package/src/types/config.ts +22 -0
- package/src/types/workflow.ts +10 -0
package/src/emrtd/session.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { buildBacChallenge, completeBac } from './bac';
|
|
|
3
3
|
import { primitivesFromNative, type EmrtdPrimitives, type MrzKeyFields } from './crypto';
|
|
4
4
|
import { EF, readFile, type Transceive } from './files';
|
|
5
5
|
import { readOptionalFile } from './optionalRead';
|
|
6
|
+
import { PaceError, PREFER_PACE, tryPace, type PaceOutcome } from './open';
|
|
6
7
|
import { SecureMessagingSession } from './secureMessaging';
|
|
7
8
|
import type { NfcReadStage } from './stages';
|
|
8
9
|
|
|
@@ -44,8 +45,15 @@ export interface EmrtdReadResult {
|
|
|
44
45
|
sod?: string;
|
|
45
46
|
/** DG2 — the portrait. Best-effort; the largest file and the likeliest to drop. */
|
|
46
47
|
dg2?: string;
|
|
47
|
-
/** How the chip was unlocked. */
|
|
48
|
-
chipAuth: 'bac';
|
|
48
|
+
/** How the chip was unlocked. Reported to the server on the submission. */
|
|
49
|
+
chipAuth: 'bac' | 'pace';
|
|
50
|
+
/**
|
|
51
|
+
* Why the session is on that protocol, and the negotiated variant when PACE
|
|
52
|
+
* ran. Diagnostics only: it never changes the read, and it is what
|
|
53
|
+
* distinguishes "the chip does not speak PACE" from "our PACE failed".
|
|
54
|
+
*/
|
|
55
|
+
paceOutcome?: PaceOutcome;
|
|
56
|
+
paceDetail?: string;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** The raw transport, before secure messaging wraps it. */
|
|
@@ -137,23 +145,8 @@ export async function readChip(
|
|
|
137
145
|
const p = primitivesFromNative(native);
|
|
138
146
|
|
|
139
147
|
onStage?.('authenticating');
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// settling the link, so the first exchange dies with the chip right there —
|
|
143
|
-
// and an immediate second attempt succeeds with the phone untouched. The
|
|
144
|
-
// Flutter SDK documents and fixes this exact failure the same way
|
|
145
|
-
// (nfc_reader_emrtd.dart); a full session teardown cannot fix it, because
|
|
146
|
-
// Android never re-dispatches a tag that stayed in the field, so the outer
|
|
147
|
-
// retry loop just waited for a re-tap nobody knew to perform.
|
|
148
|
-
let sm: Awaited<ReturnType<typeof openSession>> | null = null;
|
|
149
|
-
for (let attempt = 0; sm === null; attempt += 1) {
|
|
150
|
-
try {
|
|
151
|
-
await selectApplication(native);
|
|
152
|
-
sm = await openSession(p, native, mrz);
|
|
153
|
-
} catch (err) {
|
|
154
|
-
if (attempt >= 1) throw err;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
148
|
+
const access = await establishSession(p, native, mrz);
|
|
149
|
+
const sm = access.sm;
|
|
157
150
|
|
|
158
151
|
const transceive: Transceive = async (command) => {
|
|
159
152
|
const { data, statusWord } = await native.transceive(toBase64(command));
|
|
@@ -195,6 +188,117 @@ export async function readChip(
|
|
|
195
188
|
dg1: toBase64(dg1),
|
|
196
189
|
...(sod ? { sod: toBase64(sod) } : {}),
|
|
197
190
|
...(dg2 ? { dg2: toBase64(dg2) } : {}),
|
|
198
|
-
chipAuth:
|
|
191
|
+
chipAuth: access.chipAuth,
|
|
192
|
+
paceOutcome: access.outcome,
|
|
193
|
+
...(access.detail ? { paceDetail: access.detail } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Open a secured session, trying both access protocols as needed.
|
|
199
|
+
*
|
|
200
|
+
* The ordering lives in open.ts (PREFER_PACE) along with why it is what it is.
|
|
201
|
+
* Whichever protocol goes first, the other still runs as the fallback, so a
|
|
202
|
+
* document that read before still reads.
|
|
203
|
+
*/
|
|
204
|
+
async function establishSession(
|
|
205
|
+
p: EmrtdPrimitives,
|
|
206
|
+
native: EmrtdTransport,
|
|
207
|
+
mrz: MrzKeyFields,
|
|
208
|
+
): Promise<{
|
|
209
|
+
sm: SecureMessagingSession;
|
|
210
|
+
chipAuth: 'bac' | 'pace';
|
|
211
|
+
outcome: PaceOutcome;
|
|
212
|
+
detail?: string;
|
|
213
|
+
}> {
|
|
214
|
+
// SELECT + BAC are retried ONCE in place, on the same live connection.
|
|
215
|
+
// On Android the tag is routinely dispatched while the platform is still
|
|
216
|
+
// settling the link, so the first exchange dies with the chip right there —
|
|
217
|
+
// and an immediate second attempt succeeds with the phone untouched. The
|
|
218
|
+
// Flutter SDK documents and fixes this exact failure the same way
|
|
219
|
+
// (nfc_reader_emrtd.dart); a full session teardown cannot fix it, because
|
|
220
|
+
// Android never re-dispatches a tag that stayed in the field, so the outer
|
|
221
|
+
// retry loop just waited for a re-tap nobody knew to perform.
|
|
222
|
+
const bac = async (): Promise<SecureMessagingSession> => {
|
|
223
|
+
let sm: SecureMessagingSession | null = null;
|
|
224
|
+
for (let attempt = 0; sm === null; attempt += 1) {
|
|
225
|
+
try {
|
|
226
|
+
await selectApplication(native);
|
|
227
|
+
sm = await openSession(p, native, mrz);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (attempt >= 1) throw err;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return sm;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// PACE leaves the chip mid-protocol when it fails, so BAC after a failed
|
|
236
|
+
// PACE runs against a chip in an unknown state. Both orders below therefore
|
|
237
|
+
// re-SELECT the application first, which bac() already does.
|
|
238
|
+
const pace = async (): Promise<
|
|
239
|
+
{ sm: SecureMessagingSession; detail: string } | { outcome: PaceOutcome; detail?: string }
|
|
240
|
+
> => {
|
|
241
|
+
try {
|
|
242
|
+
return await tryPace(p, native, mrz);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
// A chip that fails PACE may still answer BAC, so this is never fatal
|
|
245
|
+
// by itself — only the reason is kept.
|
|
246
|
+
const detail =
|
|
247
|
+
err instanceof PaceError ? `${err.code}: ${err.message}` : String(err ?? '');
|
|
248
|
+
return { outcome: 'failed', detail };
|
|
249
|
+
}
|
|
199
250
|
};
|
|
251
|
+
|
|
252
|
+
if (PREFER_PACE) {
|
|
253
|
+
const attempted = await pace();
|
|
254
|
+
if ('sm' in attempted) {
|
|
255
|
+
// After PACE the application must be selected through the secure channel.
|
|
256
|
+
await selectApplicationSecure(attempted.sm, native);
|
|
257
|
+
return { sm: attempted.sm, chipAuth: 'pace', outcome: 'used', detail: attempted.detail };
|
|
258
|
+
}
|
|
259
|
+
return { sm: await bac(), chipAuth: 'bac', outcome: attempted.outcome, detail: attempted.detail };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
return { sm: await bac(), chipAuth: 'bac', outcome: 'notAttempted' };
|
|
264
|
+
} catch (bacFailure) {
|
|
265
|
+
// BAC was refused. A chip that has retired it may still open with PACE, and
|
|
266
|
+
// trying costs one exchange against a document that has otherwise failed.
|
|
267
|
+
const attempted = await pace();
|
|
268
|
+
if ('sm' in attempted) {
|
|
269
|
+
await selectApplicationSecure(attempted.sm, native);
|
|
270
|
+
return { sm: attempted.sm, chipAuth: 'pace', outcome: 'used', detail: attempted.detail };
|
|
271
|
+
}
|
|
272
|
+
// Both refused. The BAC failure is the one the user is told about: its
|
|
273
|
+
// message already says the document details did not match.
|
|
274
|
+
throw bacFailure;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* SELECT the eMRTD application through an established PACE channel.
|
|
280
|
+
*
|
|
281
|
+
* PACE authenticates at the Master File, so the application still has to be
|
|
282
|
+
* selected afterwards — and now every command is wrapped, so it goes through
|
|
283
|
+
* secure messaging rather than the raw transport.
|
|
284
|
+
*/
|
|
285
|
+
async function selectApplicationSecure(
|
|
286
|
+
sm: SecureMessagingSession,
|
|
287
|
+
transport: EmrtdTransport,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const wrapped = sm.protect({ cla: 0x00, ins: 0xa4, p1: 0x04, p2: 0x0c, data: AID });
|
|
290
|
+
const { data, statusWord } = await transport.transceive(toBase64(wrapped));
|
|
291
|
+
const body = fromBase64(data);
|
|
292
|
+
const framed = new Uint8Array(body.length + 2);
|
|
293
|
+
framed.set(body);
|
|
294
|
+
framed[body.length] = (statusWord >> 8) & 0xff;
|
|
295
|
+
framed[body.length + 1] = statusWord & 0xff;
|
|
296
|
+
|
|
297
|
+
const unwrapped = sm.unprotect(framed);
|
|
298
|
+
if (unwrapped.statusWord !== SW_OK) {
|
|
299
|
+
throw new EmrtdSessionError(
|
|
300
|
+
'The secured session did not hold when selecting the passport application.',
|
|
301
|
+
'select_failed',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
200
304
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { concat, padToBlock } from './bytes';
|
|
2
|
+
import {
|
|
3
|
+
adjustParity,
|
|
4
|
+
decrypt3Des,
|
|
5
|
+
deriveDigestSha1,
|
|
6
|
+
encrypt3Des,
|
|
7
|
+
macWithPadding,
|
|
8
|
+
retailMac,
|
|
9
|
+
type EmrtdPrimitives,
|
|
10
|
+
} from './crypto';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Cipher suites (ICAO 9303 Part 11 §9.7 and §9.8).
|
|
14
|
+
//
|
|
15
|
+
// BAC always runs two-key 3DES with the ISO 9797-1 retail MAC. PACE negotiates:
|
|
16
|
+
// it may use that same suite, or AES at 128, 192 or 256 bits with CMAC.
|
|
17
|
+
//
|
|
18
|
+
// They differ in more than the cipher, which is why this is one abstraction
|
|
19
|
+
// rather than a flag:
|
|
20
|
+
//
|
|
21
|
+
// • AES has a 16-byte block, so the padding unit AND the send-sequence
|
|
22
|
+
// counter are 16 bytes too.
|
|
23
|
+
// • AES derives a fresh IV per message by encrypting the counter, where 3DES
|
|
24
|
+
// uses a zero IV throughout.
|
|
25
|
+
// • AES-192/256 key derivation moves from SHA-1 to SHA-256, because SHA-1
|
|
26
|
+
// does not produce enough output.
|
|
27
|
+
// • The PACE authentication token pads for the retail MAC but must NOT be
|
|
28
|
+
// pre-padded for CMAC, which pads internally and takes a different branch
|
|
29
|
+
// for exact block multiples.
|
|
30
|
+
//
|
|
31
|
+
// Each of those, gotten wrong, produces a session that handshakes cleanly and
|
|
32
|
+
// then fails on the first real command. So they are expressed here once.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export interface CipherSuite {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** Cipher block size — also the padding unit and the SSC width. */
|
|
38
|
+
readonly blockSize: number;
|
|
39
|
+
/** Derived session-key length in bytes. */
|
|
40
|
+
readonly keyLength: number;
|
|
41
|
+
/** ICAO §9.7.1 key derivation: hash the secret with a 4-byte counter. */
|
|
42
|
+
deriveKey(p: EmrtdPrimitives, secret: Uint8Array, counter: number): Uint8Array;
|
|
43
|
+
/** `ssc` is present only for AES, which derives its IV from it. */
|
|
44
|
+
encrypt(p: EmrtdPrimitives, key: Uint8Array, padded: Uint8Array, ssc?: Uint8Array): Uint8Array;
|
|
45
|
+
decrypt(p: EmrtdPrimitives, key: Uint8Array, data: Uint8Array, ssc?: Uint8Array): Uint8Array;
|
|
46
|
+
/** MAC over data the caller has already padded. */
|
|
47
|
+
mac(p: EmrtdPrimitives, key: Uint8Array, data: Uint8Array): Uint8Array;
|
|
48
|
+
/** The PACE authentication token over an UNPADDED encoding. */
|
|
49
|
+
token(p: EmrtdPrimitives, key: Uint8Array, data: Uint8Array): Uint8Array;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Two-key 3DES with the retail MAC: what BAC always uses. */
|
|
53
|
+
export const DES_EDE2_SUITE: CipherSuite = {
|
|
54
|
+
name: '3DES',
|
|
55
|
+
blockSize: 8,
|
|
56
|
+
keyLength: 16,
|
|
57
|
+
deriveKey: (p, secret, counter) =>
|
|
58
|
+
adjustParity(deriveDigestSha1(p, secret, counter).subarray(0, 16)),
|
|
59
|
+
encrypt: (p, key, padded) => encrypt3Des(p, key, padded),
|
|
60
|
+
decrypt: (p, key, data) => decrypt3Des(p, key, data),
|
|
61
|
+
mac: (p, key, data) => retailMac(p, key, data),
|
|
62
|
+
token: (p, key, data) => macWithPadding(p, key, data),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** AES-CBC with CMAC, at 128, 192 or 256 bits. Only PACE reaches this. */
|
|
66
|
+
export function aesSuite(keyLength: 16 | 24 | 32): CipherSuite {
|
|
67
|
+
return {
|
|
68
|
+
name: `AES-${keyLength * 8}`,
|
|
69
|
+
blockSize: 16,
|
|
70
|
+
keyLength,
|
|
71
|
+
deriveKey: (p, secret, counter) =>
|
|
72
|
+
// AES-128 keeps SHA-1; the longer keys need SHA-256 for enough output.
|
|
73
|
+
// No parity adjustment: that is a DES-only convention, and applying it to
|
|
74
|
+
// an AES key silently produces a different key.
|
|
75
|
+
keyLength === 16
|
|
76
|
+
? deriveDigestSha1(p, secret, counter).subarray(0, 16)
|
|
77
|
+
: p.sha256(concat(secret, new Uint8Array([0, 0, 0, counter]))).subarray(0, keyLength),
|
|
78
|
+
encrypt: (p, key, padded, ssc) => p.aesCbc(key, padded, aesIv(p, key, ssc), true),
|
|
79
|
+
decrypt: (p, key, data, ssc) => p.aesCbc(key, data, aesIv(p, key, ssc), false),
|
|
80
|
+
// ICAO truncates the 16-byte CMAC to its leading 8 bytes.
|
|
81
|
+
mac: (p, key, data) => p.aesCmac(key, data).subarray(0, 8),
|
|
82
|
+
token: (p, key, data) => p.aesCmac(key, data).subarray(0, 8),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The per-message IV: AES encrypts the send-sequence counter with the session
|
|
88
|
+
* key, so two identical commands never produce identical ciphertext. A zero IV
|
|
89
|
+
* here (the 3DES convention) would leak that they were the same.
|
|
90
|
+
*/
|
|
91
|
+
function aesIv(p: EmrtdPrimitives, key: Uint8Array, ssc?: Uint8Array): Uint8Array {
|
|
92
|
+
if (!ssc) return new Uint8Array(16);
|
|
93
|
+
return p.aesCbc(key, ssc, new Uint8Array(16), true);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pad for this suite's block size (ISO 9797-1 method 2). */
|
|
97
|
+
export function padForSuite(suite: CipherSuite, data: Uint8Array): Uint8Array {
|
|
98
|
+
return padToBlock(data, suite.blockSize);
|
|
99
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Session step log — records each SDK step the user reaches, with a
|
|
2
|
+
// timestamp, so the server can reconstruct the journey on the verification
|
|
3
|
+
// timeline ("consent opened → ID type chosen → document captured → …").
|
|
4
|
+
// Rides the verify submission as metadata.device.stepLog — the same free-form
|
|
5
|
+
// channel the Device Intelligence fingerprint uses: no extra network calls,
|
|
6
|
+
// no new endpoint, and old SDKs simply never send it. `sentAt` is stamped at
|
|
7
|
+
// collect time so the server can correct client-clock skew against its own
|
|
8
|
+
// receipt time. Step names only — never PII. Mirrors the web SDK's
|
|
9
|
+
// lib/step-log.ts; keep the two in lockstep.
|
|
10
|
+
|
|
11
|
+
export interface StepLogEntry {
|
|
12
|
+
step: string;
|
|
13
|
+
at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StepLog {
|
|
17
|
+
steps: StepLogEntry[];
|
|
18
|
+
sentAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_ENTRIES = 40;
|
|
22
|
+
|
|
23
|
+
let entries: StepLogEntry[] = [];
|
|
24
|
+
|
|
25
|
+
/** Fresh slate per session (called from the store's reset — each modal open). */
|
|
26
|
+
export function resetStepLog(): void {
|
|
27
|
+
entries = [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Records a step visit. Consecutive duplicates are collapsed; back-and-forth
|
|
31
|
+
* navigation is kept — repeat visits are honest journey data. */
|
|
32
|
+
export function recordStep(step: string): void {
|
|
33
|
+
if (entries.length >= MAX_ENTRIES) return;
|
|
34
|
+
if (entries[entries.length - 1]?.step === step) return;
|
|
35
|
+
entries.push({ step, at: new Date().toISOString() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Snapshot attached to the verify submission. Null when nothing was recorded
|
|
39
|
+
* so the field is simply absent. */
|
|
40
|
+
export function getStepLog(): StepLog | null {
|
|
41
|
+
if (entries.length === 0) return null;
|
|
42
|
+
return { steps: [...entries], sentAt: new Date().toISOString() };
|
|
43
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which step circles to draw, and when to start collapsing them.
|
|
3
|
+
*
|
|
4
|
+
* A KYB flow can reach FOURTEEN steps (consent → email → phone →
|
|
5
|
+
* business-details → key-people → documents → applicant-role → country-select →
|
|
6
|
+
* id-type → document-capture → nfc → liveness → questionnaire → submitted), and
|
|
7
|
+
* an individual flow eleven. The circles are a fixed size, so they cannot shrink
|
|
8
|
+
* to fit: on a 360dp Android with 16dp padding, ten steps leave 14dp TOTAL for
|
|
9
|
+
* nine connectors, and twelve steps overflow the row outright.
|
|
10
|
+
*
|
|
11
|
+
* COLLAPSING IS A LAST RESORT. The row fits as many real circles as the measured
|
|
12
|
+
* width allows and only windows once they would stop looking like a connected
|
|
13
|
+
* chain — which is what the minimum connector length below decides. A fixed cap
|
|
14
|
+
* would collapse a 9-step flow on a large phone that had room for all of it.
|
|
15
|
+
*
|
|
16
|
+
* When it does window, two rules keep the elision honest:
|
|
17
|
+
*
|
|
18
|
+
* • The LAST step is always shown. Without it the ellipsis hides how much is
|
|
19
|
+
* left, which is the one thing a progress indicator exists to answer.
|
|
20
|
+
* • The FIRST step is always shown, so the row still reads as a whole journey
|
|
21
|
+
* rather than a fragment floating in the middle.
|
|
22
|
+
*
|
|
23
|
+
* 1 ··· 5 6 7 ··· 10
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A rendered slot: a step index, or a collapsed run of them. */
|
|
27
|
+
export type StepSlot = number | 'ellipsis';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shortest connector that still reads as a line joining two circles rather than
|
|
31
|
+
* a stray dash. Below this the "chain" metaphor is gone and the row just looks
|
|
32
|
+
* cramped — which is the state the 10-step screenshot was already in.
|
|
33
|
+
*/
|
|
34
|
+
export const MIN_CONNECTOR = 10;
|
|
35
|
+
|
|
36
|
+
/** Horizontal margin a connector carries on each side (matches the component). */
|
|
37
|
+
const CONNECTOR_MARGIN = 6;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Never collapse below this many circles. At 5 the pattern still reads as
|
|
41
|
+
* first · gap · current · gap · last; below it the row says nothing useful.
|
|
42
|
+
*/
|
|
43
|
+
const MIN_CIRCLES = 5;
|
|
44
|
+
|
|
45
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many circles fit across `width` before they stop looking like a chain.
|
|
49
|
+
*
|
|
50
|
+
* n·size + (n−1)·(margins + minConnector) ≤ width
|
|
51
|
+
*
|
|
52
|
+
* Returns 0 when the width is not known yet, which the caller reads as "render
|
|
53
|
+
* them all" — an un-measured first frame should not flash a collapsed row.
|
|
54
|
+
*/
|
|
55
|
+
export function fitStepCircles(width: number, circleSize: number): number {
|
|
56
|
+
if (!Number.isFinite(width) || width <= 0) return 0;
|
|
57
|
+
const gap = CONNECTOR_MARGIN + MIN_CONNECTOR;
|
|
58
|
+
return Math.max(1, Math.floor((width + gap) / (circleSize + gap)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param total how many steps the flow has
|
|
63
|
+
* @param active 0-based index of the current step
|
|
64
|
+
* @param maxCircles how many circles fit (from `fitStepCircles`); 0 = unknown,
|
|
65
|
+
* render everything
|
|
66
|
+
*/
|
|
67
|
+
export function windowedSteps(total: number, active: number, maxCircles = 0): StepSlot[] {
|
|
68
|
+
if (total <= 0) return [];
|
|
69
|
+
const all = Array.from({ length: total }, (_, i) => i);
|
|
70
|
+
if (maxCircles <= 0 || total <= maxCircles) return all;
|
|
71
|
+
|
|
72
|
+
// A collapsed row also pays for up to TWO ellipses, each about as wide as a
|
|
73
|
+
// circle once its padding and connectors are counted. Reserving only one
|
|
74
|
+
// between them under-counted, and the row overflowed — which is invisible
|
|
75
|
+
// until you notice every `flex: 1` connector has collapsed to zero width.
|
|
76
|
+
const budget = Math.max(MIN_CIRCLES, maxCircles - 2);
|
|
77
|
+
if (total <= budget) return all;
|
|
78
|
+
|
|
79
|
+
const first = 0;
|
|
80
|
+
const last = total - 1;
|
|
81
|
+
const current = clamp(active, first, last);
|
|
82
|
+
|
|
83
|
+
// First and last are drawn unconditionally; the rest of the budget is a
|
|
84
|
+
// window centred on the current step.
|
|
85
|
+
const windowSize = Math.max(1, budget - 2);
|
|
86
|
+
const half = Math.floor((windowSize - 1) / 2);
|
|
87
|
+
const start = clamp(current - half, first + 1, Math.max(first + 1, last - windowSize));
|
|
88
|
+
const end = Math.min(start + windowSize - 1, last - 1);
|
|
89
|
+
|
|
90
|
+
const slots: StepSlot[] = [first];
|
|
91
|
+
if (start > first + 1) slots.push('ellipsis');
|
|
92
|
+
for (let i = start; i <= end; i += 1) slots.push(i);
|
|
93
|
+
if (end < last - 1) slots.push('ellipsis');
|
|
94
|
+
slots.push(last);
|
|
95
|
+
return slots;
|
|
96
|
+
}
|
|
@@ -562,7 +562,7 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
562
562
|
setState((s) => ({
|
|
563
563
|
...s,
|
|
564
564
|
phase: 'complete',
|
|
565
|
-
instruction: '
|
|
565
|
+
instruction: 'Capture complete',
|
|
566
566
|
activeChallenge: null,
|
|
567
567
|
positionGuidance: null,
|
|
568
568
|
}));
|
|
@@ -99,6 +99,19 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
99
99
|
return (table[country.toUpperCase()] ?? []).filter((t) => allowed(t.key));
|
|
100
100
|
}, [country, config.idTypes, config.countries, serverConfig]);
|
|
101
101
|
|
|
102
|
+
// Document Intelligence off ⇒ number-only IDs only (there is no document
|
|
103
|
+
// capture step), so drop every document-scanned ID from the picker. This is
|
|
104
|
+
// what makes the disabled step actually disappear from the live flow rather
|
|
105
|
+
// than still offering passports and licences that then have nowhere to be
|
|
106
|
+
// captured. Mirrors the web SDK's IdTypeStep.
|
|
107
|
+
const visible = useMemo<IdTypeDefinition[]>(
|
|
108
|
+
() =>
|
|
109
|
+
config.enableDocumentCapture === false
|
|
110
|
+
? available.filter((t) => !t.requiresDocumentCapture)
|
|
111
|
+
: available,
|
|
112
|
+
[available, config.enableDocumentCapture],
|
|
113
|
+
);
|
|
114
|
+
|
|
102
115
|
if (serverConfig.status === 'loading') {
|
|
103
116
|
return (
|
|
104
117
|
<View style={{ paddingVertical: spacing.xl, alignItems: 'center' }}>
|
|
@@ -107,7 +120,7 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
107
120
|
);
|
|
108
121
|
}
|
|
109
122
|
|
|
110
|
-
if (serverConfig.status === 'ready' &&
|
|
123
|
+
if (serverConfig.status === 'ready' && visible.length === 0) {
|
|
111
124
|
return (
|
|
112
125
|
<View
|
|
113
126
|
style={{
|
|
@@ -127,7 +140,7 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
127
140
|
|
|
128
141
|
return (
|
|
129
142
|
<View>
|
|
130
|
-
{
|
|
143
|
+
{visible.map((t) => {
|
|
131
144
|
const selected = selectedIdType === t.key;
|
|
132
145
|
return (
|
|
133
146
|
<Pressable
|
package/src/screens/NfcStep.tsx
CHANGED
|
@@ -151,6 +151,18 @@ export function NfcStep(): React.ReactElement {
|
|
|
151
151
|
},
|
|
152
152
|
);
|
|
153
153
|
if (!liveRef.current || seq !== readSeqRef.current) return;
|
|
154
|
+
// Which access protocol opened the session, and why. PACE is new here and
|
|
155
|
+
// reads over BAC by default, so this line is what distinguishes "the chip
|
|
156
|
+
// never offered PACE" from "our PACE was attempted and failed" while it
|
|
157
|
+
// is being proven against real documents. Dev builds only — it is
|
|
158
|
+
// diagnostics, never user-facing.
|
|
159
|
+
if (__DEV__) {
|
|
160
|
+
console.log(
|
|
161
|
+
`[kyc.nfc] session opened over ${result.chipAuth}` +
|
|
162
|
+
(result.paceOutcome ? ` (pace: ${result.paceOutcome}` : '') +
|
|
163
|
+
(result.paceDetail ? ` — ${result.paceDetail})` : result.paceOutcome ? ')' : ''),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
154
166
|
store.getState().setChipData(result);
|
|
155
167
|
setResult(result);
|
|
156
168
|
setPhase('done');
|
|
@@ -33,22 +33,35 @@ export function QuestionnaireFieldView({
|
|
|
33
33
|
field,
|
|
34
34
|
value,
|
|
35
35
|
currencyValue,
|
|
36
|
+
detailValue,
|
|
36
37
|
error,
|
|
37
38
|
onChange,
|
|
38
39
|
onCurrencyChange,
|
|
40
|
+
onDetailChange,
|
|
39
41
|
}: {
|
|
40
42
|
field: FieldDef;
|
|
41
43
|
value: QuestionnaireAnswerValue | undefined;
|
|
42
44
|
/** money only: the `<key>_currency` companion answer. */
|
|
43
45
|
currencyValue?: string;
|
|
46
|
+
/** choice fields only: the `<key>_other` companion answer. */
|
|
47
|
+
detailValue?: string;
|
|
44
48
|
error?: string;
|
|
45
49
|
onChange: (value: QuestionnaireAnswerValue | undefined) => void;
|
|
46
50
|
onCurrencyChange: (currency: string) => void;
|
|
51
|
+
onDetailChange?: (detail: string | undefined) => void;
|
|
47
52
|
}): React.ReactElement {
|
|
48
53
|
const { colors } = useTheme();
|
|
49
54
|
|
|
50
55
|
const isPlainInput = field.type === 'text' || field.type === 'number';
|
|
51
56
|
|
|
57
|
+
// The chosen option that is not an answer on its own ("Other"). Covers both
|
|
58
|
+
// select (a single value) and multiselect (a list).
|
|
59
|
+
const detailOption = (field.options ?? []).find(
|
|
60
|
+
(o) =>
|
|
61
|
+
o.requiresDetail &&
|
|
62
|
+
(Array.isArray(value) ? value.includes(o.value) : value === o.value),
|
|
63
|
+
);
|
|
64
|
+
|
|
52
65
|
return (
|
|
53
66
|
<View style={{ marginBottom: spacing.lg }}>
|
|
54
67
|
<FieldLabel
|
|
@@ -143,6 +156,23 @@ export function QuestionnaireFieldView({
|
|
|
143
156
|
})
|
|
144
157
|
: null}
|
|
145
158
|
|
|
159
|
+
{/* Free text behind an "Other" choice. Always required once that option
|
|
160
|
+
is picked: an unexplained "Other" is the answer a compliance reviewer
|
|
161
|
+
most needs spelled out. */}
|
|
162
|
+
{detailOption ? (
|
|
163
|
+
<View style={{ marginTop: spacing.sm }}>
|
|
164
|
+
<MyazaInput
|
|
165
|
+
label={detailOption.detailLabel || 'Please specify'}
|
|
166
|
+
value={detailValue ?? ''}
|
|
167
|
+
maxLength={200}
|
|
168
|
+
placeholder={
|
|
169
|
+
detailOption.detailPlaceholder || `Tell us more about "${detailOption.label}"`
|
|
170
|
+
}
|
|
171
|
+
onChangeText={(text: string) => onDetailChange?.(text || undefined)}
|
|
172
|
+
/>
|
|
173
|
+
</View>
|
|
174
|
+
) : null}
|
|
175
|
+
|
|
146
176
|
{/* Inputs draw their own error; the rest need one underneath. */}
|
|
147
177
|
{error && !isPlainInput && field.type !== 'money' ? (
|
|
148
178
|
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
|
|
@@ -4,7 +4,7 @@ import { View } from 'react-native';
|
|
|
4
4
|
import { spacing } from '../config/theme';
|
|
5
5
|
import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
6
6
|
import { MyazaButton } from '../components/MyazaButton';
|
|
7
|
-
import { currencyKeyFor, validateQuestionnaire } from '../config/questionnaire';
|
|
7
|
+
import { currencyKeyFor, otherKeyFor, validateQuestionnaire } from '../config/questionnaire';
|
|
8
8
|
import { QuestionnaireFieldView } from './QuestionnaireField';
|
|
9
9
|
import type { QuestionnaireAnswerValue } from '../types/workflow';
|
|
10
10
|
|
|
@@ -63,9 +63,11 @@ export function QuestionnaireStep(): React.ReactElement {
|
|
|
63
63
|
field={field}
|
|
64
64
|
value={answers[field.key]}
|
|
65
65
|
currencyValue={answers[currencyKeyFor(field)] as string | undefined}
|
|
66
|
+
detailValue={answers[otherKeyFor(field)] as string | undefined}
|
|
66
67
|
error={errors[field.key] || undefined}
|
|
67
68
|
onChange={(value) => setAnswer(field.key, value)}
|
|
68
69
|
onCurrencyChange={(currency) => setAnswer(currencyKeyFor(field), currency)}
|
|
70
|
+
onDetailChange={(detail) => setAnswer(otherKeyFor(field), detail)}
|
|
69
71
|
/>
|
|
70
72
|
))}
|
|
71
73
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
2
|
import { Image, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { spacing } from '../../config/theme';
|
|
@@ -8,7 +8,7 @@ import { MyazaButton } from '../../components/MyazaButton';
|
|
|
8
8
|
import { Icon } from '../../components/Icon';
|
|
9
9
|
import { useChipPortrait } from './useChipPortrait';
|
|
10
10
|
import { NfcScannedSummary } from './NfcScannedSummary';
|
|
11
|
-
import type
|
|
11
|
+
import { parseDg1, type EmrtdReadResult } from '../../emrtd';
|
|
12
12
|
import type { MrzScan } from '../../mrz/parse';
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -41,7 +41,12 @@ export function NfcSuccessPanel({
|
|
|
41
41
|
// component rejects) must degrade to the generic mark, not an empty circle.
|
|
42
42
|
const [portraitBroken, setPortraitBroken] = useState(false);
|
|
43
43
|
const portrait = portraitBroken ? null : portraitUri;
|
|
44
|
-
|
|
44
|
+
// Once the chip has been read, IT is the source — see emrtd/dg1.ts. The
|
|
45
|
+
// camera scan stays as the fallback for a chip whose DG1 we could not parse,
|
|
46
|
+
// so a read that otherwise worked still shows something.
|
|
47
|
+
const chip = useMemo(() => parseDg1(result.dg1), [result.dg1]);
|
|
48
|
+
const holder = chip ?? mrz;
|
|
49
|
+
const name = [holder?.firstName, holder?.lastName].filter(Boolean).join(' ');
|
|
45
50
|
|
|
46
51
|
return (
|
|
47
52
|
<View style={{ alignItems: 'center' }}>
|
|
@@ -132,12 +137,14 @@ export function NfcSuccessPanel({
|
|
|
132
137
|
) : null}
|
|
133
138
|
|
|
134
139
|
{/* What was read, shown back — the same card the pre-read screen uses, so
|
|
135
|
-
the user checks the same two fields against the printed page.
|
|
136
|
-
|
|
140
|
+
the user checks the same two fields against the printed page. Before
|
|
141
|
+
the read that card can only show the camera's guess; here it shows the
|
|
142
|
+
chip's own values, which is both correct and a stronger check. */}
|
|
143
|
+
{holder ? (
|
|
137
144
|
<>
|
|
138
145
|
<View style={{ height: spacing.lg }} />
|
|
139
146
|
<View style={{ alignSelf: 'stretch' }}>
|
|
140
|
-
<NfcScannedSummary scan={
|
|
147
|
+
<NfcScannedSummary scan={holder} />
|
|
141
148
|
</View>
|
|
142
149
|
</>
|
|
143
150
|
) : null}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// `utils/device-metadata.ts` and the Flutter SDK's `device_metadata_service.dart`.
|
|
5
5
|
|
|
6
6
|
import { OS } from '../utils/platform';
|
|
7
|
+
import { getStepLog } from '../lib/step-log';
|
|
7
8
|
|
|
8
9
|
export const SDK_TYPE = 'react-native' as const;
|
|
9
10
|
|
|
@@ -14,7 +15,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
|
|
|
14
15
|
* Single source of truth for the SDK version — also used by `services/api.ts`
|
|
15
16
|
* for the `X-SDK-Version` header. Keep in sync with `package.json`.
|
|
16
17
|
*/
|
|
17
|
-
export const SDK_VERSION = '2.
|
|
18
|
+
export const SDK_VERSION = '2.4.0';
|
|
18
19
|
|
|
19
20
|
export interface ReactNativeDeviceMetadata {
|
|
20
21
|
sdkType: 'react-native';
|
|
@@ -181,5 +182,12 @@ export function collectDeviceMetadata(): ReactNativeDeviceMetadata {
|
|
|
181
182
|
meta.locale = locales[0]?.languageTag;
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Step journey recorded during the session — powers the dashboard's
|
|
186
|
+
// verification timeline. See lib/step-log.
|
|
187
|
+
const stepLog = getStepLog();
|
|
188
|
+
if (stepLog) {
|
|
189
|
+
(meta as ReactNativeDeviceMetadata & { stepLog?: unknown }).stepLog = stepLog;
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
return meta;
|
|
185
193
|
}
|
package/src/store/derive.ts
CHANGED
|
@@ -29,6 +29,13 @@ import type { KYCStep, SupportedCountry } from '../types/config';
|
|
|
29
29
|
import type { KycState } from './state';
|
|
30
30
|
|
|
31
31
|
export function livenessEnabled(state: KycState): boolean {
|
|
32
|
+
// Presence Intelligence off ⇒ no selfie step at all. This is the builder's
|
|
33
|
+
// "Presence Intelligence step" switch (`enableSelfie`), and it outranks the
|
|
34
|
+
// liveness-gesture question below: with no selfie there is nothing to run
|
|
35
|
+
// gestures against. Checked first for that reason, and because omitting it
|
|
36
|
+
// was why turning the step off in the workflow changed nothing here while it
|
|
37
|
+
// worked on web.
|
|
38
|
+
if (state.config.enableSelfie === false) return false;
|
|
32
39
|
// Consumer baseline: liveness is on unless explicitly disabled.
|
|
33
40
|
if (state.config.enableLiveness === false) return false;
|
|
34
41
|
const idType = state.selectedIdType;
|