@myazahq/kyc-sdk-react-native 2.4.0 → 2.5.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/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
- package/ios/HybridMyazaFaceDetector.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +1 -1
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +14 -7
- package/src/components/Icon.tsx +9 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +23 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +37 -4
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +26 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/stepHeaderMeta.tsx +3 -1
- package/src/config/business.ts +6 -6
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +14 -3
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +24 -4
- package/src/config/theme.ts +8 -2
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +5 -0
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +109 -0
- package/src/screens/BusinessDetailsStep.tsx +131 -145
- package/src/screens/BusinessDocumentSlot.tsx +2 -3
- package/src/screens/BusinessDocumentsStep.tsx +1 -1
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +86 -51
- package/src/screens/ConsentStep.tsx +31 -0
- package/src/screens/ContactDestinationField.tsx +1 -0
- package/src/screens/ContactVerificationStep.tsx +34 -3
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessStep.tsx +18 -0
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/ProofOfAddressStep.tsx +6 -3
- package/src/screens/SubmittedStep.tsx +66 -10
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/services/api-types.ts +158 -2
- package/src/services/api-verify-types.ts +53 -1
- package/src/services/api.ts +104 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/workflowGate.ts +1 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +15 -0
- package/src/store/kycStore.ts +241 -14
- package/src/store/serverConfig.ts +2 -0
- package/src/store/session.ts +208 -0
- package/src/store/state.ts +155 -2
- package/src/store/submit.ts +63 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +22 -1
- package/src/types/verification.ts +1 -1
- package/src/types/workflow.ts +15 -0
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { KeyPersonEntry } from './keyPeople';
|
|
2
|
+
import type { KeyPersonRole } from '../types/business';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The sectioned key-people model: UBOs / Shareholders / Directors &
|
|
6
|
+
* representatives as VIEWS over one shared list of people.
|
|
7
|
+
*
|
|
8
|
+
* A section is not a bucket. One human is a director AND a 30% owner, and the
|
|
9
|
+
* register files them that way, so an entry appears in every section whose
|
|
10
|
+
* definition it meets — membership is derived from the roles it holds and the
|
|
11
|
+
* stake it declares, never stored. Quick-add grants an existing person another
|
|
12
|
+
* hat instead of retyping them; classification by ownership happens here
|
|
13
|
+
* exactly as the server escalates it, so the screen never disagrees with what
|
|
14
|
+
* the submission will be read as.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type KeyPeopleSection = 'ubos' | 'shareholders' | 'representatives';
|
|
18
|
+
|
|
19
|
+
/** Strongest first — the headline role on one-role surfaces. Mirrors the
|
|
20
|
+
* server's precedence in `key-people/roles.ts`; keep the two in lockstep. */
|
|
21
|
+
const ROLE_PRECEDENCE: KeyPersonRole[] = [
|
|
22
|
+
'beneficial_owner',
|
|
23
|
+
'director',
|
|
24
|
+
'signatory',
|
|
25
|
+
'shareholder',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function primaryRole(roles: KeyPersonRole[]): KeyPersonRole {
|
|
29
|
+
for (const role of ROLE_PRECEDENCE) if (roles.includes(role)) return role;
|
|
30
|
+
return 'shareholder';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The roles an entry actually holds; falls back to the headline for rows
|
|
34
|
+
* minted before `roles` existed (restored sessions). */
|
|
35
|
+
export function rolesOf(entry: Pick<KeyPersonEntry, 'role' | 'roles'>): KeyPersonRole[] {
|
|
36
|
+
return entry.roles && entry.roles.length > 0 ? entry.roles : [entry.role];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The declared stake as a number, or null when blank/unparseable. */
|
|
40
|
+
export function stakeOf(entry: Pick<KeyPersonEntry, 'ownershipPct'>): number | null {
|
|
41
|
+
const raw = entry.ownershipPct.trim();
|
|
42
|
+
if (raw === '') return null;
|
|
43
|
+
const n = Number(raw);
|
|
44
|
+
return Number.isFinite(n) ? n : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which sections this entry belongs to.
|
|
49
|
+
*
|
|
50
|
+
* - UBOs: a natural person holding the beneficial-owner role OR a stake at or
|
|
51
|
+
* above the threshold — the same escalation the server performs, so a
|
|
52
|
+
* shareholder who types 60% moves up on screen exactly as they will in the
|
|
53
|
+
* submission. A company never qualifies (a beneficial owner is a natural
|
|
54
|
+
* person in every regime that defines one).
|
|
55
|
+
* - Shareholders: every corporate holder (whatever its stake — the never-a-UBO
|
|
56
|
+
* rule made visible), plus people with a declared holding or shareholder
|
|
57
|
+
* role below the threshold. A person the UBO section claimed is not ALSO a
|
|
58
|
+
* plain shareholder: same stake, one classification.
|
|
59
|
+
* - Representatives: anyone holding director or signatory.
|
|
60
|
+
*/
|
|
61
|
+
export function sectionsFor(
|
|
62
|
+
entry: KeyPersonEntry,
|
|
63
|
+
threshold: number,
|
|
64
|
+
): Set<KeyPeopleSection> {
|
|
65
|
+
const roles = rolesOf(entry);
|
|
66
|
+
const stake = stakeOf(entry);
|
|
67
|
+
const out = new Set<KeyPeopleSection>();
|
|
68
|
+
|
|
69
|
+
const isUbo =
|
|
70
|
+
!entry.isCorporate &&
|
|
71
|
+
(roles.includes('beneficial_owner') || (stake != null && stake >= threshold));
|
|
72
|
+
if (isUbo) out.add('ubos');
|
|
73
|
+
if (
|
|
74
|
+
entry.isCorporate ||
|
|
75
|
+
(!isUbo && (roles.includes('shareholder') || (stake != null && stake > 0)))
|
|
76
|
+
) {
|
|
77
|
+
out.add('shareholders');
|
|
78
|
+
}
|
|
79
|
+
if (roles.includes('director') || roles.includes('signatory')) {
|
|
80
|
+
out.add('representatives');
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Indices of the entries each section shows, in list order. */
|
|
86
|
+
export function sectionMembers(
|
|
87
|
+
rows: KeyPersonEntry[],
|
|
88
|
+
threshold: number,
|
|
89
|
+
): Record<KeyPeopleSection, number[]> {
|
|
90
|
+
const out: Record<KeyPeopleSection, number[]> = {
|
|
91
|
+
ubos: [],
|
|
92
|
+
shareholders: [],
|
|
93
|
+
representatives: [],
|
|
94
|
+
};
|
|
95
|
+
rows.forEach((row, index) => {
|
|
96
|
+
for (const section of sectionsFor(row, threshold)) out[section].push(index);
|
|
97
|
+
});
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The role a section's add-tile (and quick-add chip) grants. */
|
|
102
|
+
export const SECTION_ROLE: Record<KeyPeopleSection, KeyPersonRole> = {
|
|
103
|
+
ubos: 'beneficial_owner',
|
|
104
|
+
shareholders: 'shareholder',
|
|
105
|
+
representatives: 'director',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Entries offerable as quick-add chips for a section: already entered, named,
|
|
110
|
+
* not yet a member, and eligible (a company can never be quick-added as a
|
|
111
|
+
* UBO). One tap grants the section's role — the Didit chip, minus its
|
|
112
|
+
* duplicate-name bug.
|
|
113
|
+
*/
|
|
114
|
+
export function quickAddCandidates(
|
|
115
|
+
rows: KeyPersonEntry[],
|
|
116
|
+
section: KeyPeopleSection,
|
|
117
|
+
threshold: number,
|
|
118
|
+
): number[] {
|
|
119
|
+
const members = new Set(sectionMembers(rows, threshold)[section]);
|
|
120
|
+
return rows
|
|
121
|
+
.map((row, index) => ({ row, index }))
|
|
122
|
+
.filter(({ row, index }) => {
|
|
123
|
+
if (members.has(index)) return false;
|
|
124
|
+
if (row.name.trim().length < 2) return false;
|
|
125
|
+
if (section === 'ubos' && row.isCorporate) return false;
|
|
126
|
+
// Only offer a chip that would DO something. Membership is derived, so
|
|
127
|
+
// granting a role does not always produce it: a beneficial owner is not
|
|
128
|
+
// also a plain shareholder (same stake, one classification), so offering
|
|
129
|
+
// them under Shareholders gave a chip that could be tapped forever and
|
|
130
|
+
// never move anybody. An affordance that does nothing is worse than an
|
|
131
|
+
// absent one, because the applicant concludes the app is broken.
|
|
132
|
+
return sectionsFor(grantRole(row, section), threshold).has(section);
|
|
133
|
+
})
|
|
134
|
+
.map(({ index }) => index);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Grant an entry another hat (quick-add). The headline follows precedence. */
|
|
138
|
+
export function grantRole(entry: KeyPersonEntry, section: KeyPeopleSection): KeyPersonEntry {
|
|
139
|
+
const roles = rolesOf(entry);
|
|
140
|
+
const granted = SECTION_ROLE[section];
|
|
141
|
+
const next = roles.includes(granted) ? roles : [...roles, granted];
|
|
142
|
+
return { ...entry, roles: next, role: primaryRole(next) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Take an entry out of a section (the card's X). Removing the section's roles
|
|
147
|
+
* is enough when membership came from them; when it came from a declared
|
|
148
|
+
* stake (or nothing else keeps the row alive) the honest reading of "remove
|
|
149
|
+
* from UBOs" is "remove this person" — the caller deletes the row when this
|
|
150
|
+
* returns null.
|
|
151
|
+
*/
|
|
152
|
+
export function withoutSection(
|
|
153
|
+
entry: KeyPersonEntry,
|
|
154
|
+
section: KeyPeopleSection,
|
|
155
|
+
threshold: number,
|
|
156
|
+
): KeyPersonEntry | null {
|
|
157
|
+
const dropped: KeyPersonRole[] =
|
|
158
|
+
section === 'representatives' ? ['director', 'signatory'] : [SECTION_ROLE[section]];
|
|
159
|
+
const remaining = rolesOf(entry).filter((r) => !dropped.includes(r));
|
|
160
|
+
if (remaining.length === 0) return null;
|
|
161
|
+
const next = { ...entry, roles: remaining, role: primaryRole(remaining) };
|
|
162
|
+
// Still a member by stake? Then role removal did not remove them, and the
|
|
163
|
+
// tap meant more than that.
|
|
164
|
+
if (sectionsFor(next, threshold).has(section)) return null;
|
|
165
|
+
return next;
|
|
166
|
+
}
|
|
167
|
+
|
|
@@ -70,7 +70,7 @@ export function registrationNumberHint(
|
|
|
70
70
|
tip: NG_TIP,
|
|
71
71
|
isValidFormat: (value) => NG_PREFIX_RE.test(value.trim()),
|
|
72
72
|
formatError:
|
|
73
|
-
'Start with RC, BN, IT, LP or LLP followed by the number
|
|
73
|
+
'Start with RC, BN, IT, LP or LLP followed by the number, no spaces, e.g. RC0000000.',
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
package/src/config/stepOrder.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// capture → (nfc) → (liveness) → (poa) → (questionnaire) →
|
|
12
12
|
// submitted
|
|
13
13
|
// business: consent → (email) → (phone) → business-details →
|
|
14
|
-
// (
|
|
15
|
-
// (
|
|
14
|
+
// (documents) → (questionnaire) → (key-people) →
|
|
15
|
+
// (applicant-role → country-select → capture leg) → submitted
|
|
16
16
|
//
|
|
17
17
|
// Mirrors the web SDK's `lib/step-order.ts`, plus the `nfc` step, which is a
|
|
18
18
|
// real step here — the web SDK can't do ISO-DEP so it only previews the screen.
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import type { WorkflowBusinessConfig } from '../types/business';
|
|
22
22
|
import type { KYCStep } from '../types/config';
|
|
23
|
+
import { applyResubmitSteps, type ResubmitConfig } from '../lib/resubmit';
|
|
23
24
|
import { businessSectionSteps, hasApplicantVerification } from './businessSteps';
|
|
24
25
|
|
|
25
26
|
export interface StepOrderOptions {
|
|
@@ -34,6 +35,14 @@ export interface StepOrderOptions {
|
|
|
34
35
|
hasPhoneVerification: boolean;
|
|
35
36
|
hasPoa: boolean;
|
|
36
37
|
hasQuestionnaire: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* A reviewer sent this back to redo specific steps.
|
|
40
|
+
*
|
|
41
|
+
* Applied LAST, over the fully-built order, so it narrows whatever the flow
|
|
42
|
+
* would otherwise have been rather than having to know how that order was
|
|
43
|
+
* assembled — which differs between the individual and KYB branches below.
|
|
44
|
+
*/
|
|
45
|
+
resubmit?: ResubmitConfig | null;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
// Contact-verification OTP steps sit right after consent (both flows) — a cheap
|
|
@@ -56,10 +65,22 @@ function captureLeg(o: StepOrderOptions): KYCStep[] {
|
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export function buildStepOrder(o: StepOrderOptions): KYCStep[] {
|
|
68
|
+
return applyResubmitSteps(fullStepOrder(o), o.resubmit);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The flow as configured, before any reviewer narrowing. */
|
|
72
|
+
function fullStepOrder(o: StepOrderOptions): KYCStep[] {
|
|
59
73
|
// Business (KYB) flow — the application section, then (when the workflow
|
|
60
74
|
// requires applicant verification) the ordinary individual capture leg.
|
|
61
75
|
if (o.isBusiness) {
|
|
62
|
-
|
|
76
|
+
// The questionnaire sits INSIDE the business section (before key people) —
|
|
77
|
+
// its questions are about the company, so it stays with the company form
|
|
78
|
+
// rather than trailing the applicant's own capture leg.
|
|
79
|
+
const steps: KYCStep[] = [
|
|
80
|
+
'consent',
|
|
81
|
+
...contactSteps(o),
|
|
82
|
+
...businessSectionSteps(o.business, o.hasQuestionnaire),
|
|
83
|
+
];
|
|
63
84
|
if (hasApplicantVerification(o.business)) {
|
|
64
85
|
// The applicant may hold an ID issued anywhere the org can verify —
|
|
65
86
|
// more than one granted country (hasCountrySelect, derived from the
|
|
@@ -68,7 +89,6 @@ export function buildStepOrder(o: StepOrderOptions): KYCStep[] {
|
|
|
68
89
|
if (o.hasCountrySelect) steps.push('country-select');
|
|
69
90
|
steps.push('id-type', ...captureLeg(o));
|
|
70
91
|
}
|
|
71
|
-
if (o.hasQuestionnaire) steps.push('questionnaire');
|
|
72
92
|
steps.push('submitted');
|
|
73
93
|
return steps;
|
|
74
94
|
}
|
package/src/config/theme.ts
CHANGED
|
@@ -37,7 +37,13 @@ export const LIGHT_COLORS: MyazaColorScheme = {
|
|
|
37
37
|
backgroundSecondary: '#F6F5FE',
|
|
38
38
|
textDark: '#070330',
|
|
39
39
|
textSecondary: '#5A5775',
|
|
40
|
-
|
|
40
|
+
// React's muted-foreground, exactly. The mobile SDKs had invented a THIRD,
|
|
41
|
+
// dimmer text tier that web does not have, and it failed WCAG AA in both
|
|
42
|
+
// modes: 3.79:1 on white and 2.97:1 on the default dark, dropping to 2.45:1
|
|
43
|
+
// on the dark backgrounds orgs actually brand with. Nothing is gained by a
|
|
44
|
+
// tier nobody can read, so it takes the same value as the tier above it, the
|
|
45
|
+
// way web does with one token for both.
|
|
46
|
+
textMuted: '#5A5775',
|
|
41
47
|
border: '#D3CFFC',
|
|
42
48
|
primary: '#5645F5',
|
|
43
49
|
onPrimary: '#FFFFFF',
|
|
@@ -61,7 +67,7 @@ export const DARK_COLORS: MyazaColorScheme = {
|
|
|
61
67
|
backgroundSecondary: '#0F0C2E',
|
|
62
68
|
textDark: '#F6F5FE',
|
|
63
69
|
textSecondary: '#ACABBA',
|
|
64
|
-
textMuted: '#
|
|
70
|
+
textMuted: '#ACABBA',
|
|
65
71
|
border: '#302D53',
|
|
66
72
|
primary: '#7B6EF7',
|
|
67
73
|
onPrimary: '#FFFFFF',
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Is this a website address?
|
|
2
|
+
//
|
|
3
|
+
// Deliberately not a URL parser. `new URL(value)` rejects "company.com" for
|
|
4
|
+
// having no scheme, which is how almost everyone writes a website, and accepts
|
|
5
|
+
// "mailto:x@y" and "javascript:alert(1)" for having one. Both answers are the
|
|
6
|
+
// wrong way round for a field labelled "Website". Mirrors the web SDK's
|
|
7
|
+
// lib/website.ts — keep the two in lockstep.
|
|
8
|
+
|
|
9
|
+
/** Trim, drop a scheme, drop a trailing slash. What the checks below run on. */
|
|
10
|
+
function hostOf(value: string): string {
|
|
11
|
+
return value
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/^https?:\/\//i, '')
|
|
14
|
+
.replace(/\/.*$/, '')
|
|
15
|
+
.replace(/:\d+$/, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HOST = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i;
|
|
19
|
+
|
|
20
|
+
export function isValidWebsite(value: string): boolean {
|
|
21
|
+
const raw = value.trim();
|
|
22
|
+
if (raw === '') return true; // Empty is for the required-field check, not this.
|
|
23
|
+
|
|
24
|
+
// A scheme we do not serve is a mistake worth catching: "mailto:" in a
|
|
25
|
+
// website box is a different thing entirely, not a typo in this one.
|
|
26
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^https?:\/\//i.test(raw)) return false;
|
|
27
|
+
|
|
28
|
+
const host = hostOf(raw);
|
|
29
|
+
if (host.length === 0 || host.length > 253) return false;
|
|
30
|
+
if (!HOST.test(host)) return false;
|
|
31
|
+
|
|
32
|
+
// A TLD of at least two letters. Rules out "company." and "192.168.0.1",
|
|
33
|
+
// neither of which is a website somebody meant to type.
|
|
34
|
+
const tld = host.slice(host.lastIndexOf('.') + 1);
|
|
35
|
+
return /^[a-z]{2,}$/i.test(tld);
|
|
36
|
+
}
|
|
@@ -20,6 +20,7 @@ export const WORKFLOW_KEYS = [
|
|
|
20
20
|
'country',
|
|
21
21
|
'countries',
|
|
22
22
|
'idTypes',
|
|
23
|
+
'multiId',
|
|
23
24
|
'enableSelfie',
|
|
24
25
|
'enableDocumentCapture',
|
|
25
26
|
'allowDocumentUpload',
|
|
@@ -27,6 +28,7 @@ export const WORKFLOW_KEYS = [
|
|
|
27
28
|
'livenessMode',
|
|
28
29
|
'flashSequenceLength',
|
|
29
30
|
'deviceIntelligence',
|
|
31
|
+
'keyPeopleLinkRecovery',
|
|
30
32
|
'requireMobileDevice',
|
|
31
33
|
'voiceGuidance',
|
|
32
34
|
'showThemeToggle',
|
|
@@ -40,6 +42,8 @@ export const WORKFLOW_KEYS = [
|
|
|
40
42
|
'questionnaire',
|
|
41
43
|
'proofOfAddress',
|
|
42
44
|
'nfc',
|
|
45
|
+
// Set only on a session a reviewer sent back, never on a published flow.
|
|
46
|
+
'resubmit',
|
|
43
47
|
] as const;
|
|
44
48
|
|
|
45
49
|
export type WorkflowKey = (typeof WORKFLOW_KEYS)[number];
|
|
@@ -96,6 +100,7 @@ const APPLICANT_LEG_KEYS = [
|
|
|
96
100
|
'country',
|
|
97
101
|
'countries',
|
|
98
102
|
'idTypes',
|
|
103
|
+
'multiId',
|
|
99
104
|
'enableSelfie',
|
|
100
105
|
'enableDocumentCapture',
|
|
101
106
|
'allowDocumentUpload',
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { toBase64 } from './bytes';
|
|
2
|
+
import { EF, type Transceive } from './files';
|
|
3
|
+
import { readOptionalFile } from './optionalRead';
|
|
4
|
+
import type { SecureMessagingSession } from './secureMessaging';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// ACTIVE AUTHENTICATION — asking the chip to prove it is not a copy.
|
|
8
|
+
//
|
|
9
|
+
// Passive authentication (the SOD) proves the data was signed by the issuing
|
|
10
|
+
// state. It cannot prove this is the chip they signed it onto: copy a genuine
|
|
11
|
+
// passport's files onto a blank chip and every hash and signature still
|
|
12
|
+
// verifies, because none of them is bound to the silicon.
|
|
13
|
+
//
|
|
14
|
+
// So we ask the chip to SIGN something. Its private key never leaves it, and
|
|
15
|
+
// the matching public key sits in DG15 — which is itself hashed into the SOD,
|
|
16
|
+
// so the issuing state has vouched for it. A cloner can copy DG15; they cannot
|
|
17
|
+
// copy the key that answers for it.
|
|
18
|
+
//
|
|
19
|
+
// TWO THINGS MATTER HERE, and neither is visible from this file alone:
|
|
20
|
+
//
|
|
21
|
+
// • THE CHALLENGE IS THE SERVER'S. We do not generate it. A nonce we chose
|
|
22
|
+
// would let anyone replay one captured (challenge, signature) pair forever,
|
|
23
|
+
// which is the clone this is meant to catch.
|
|
24
|
+
// • WE DO NOT VERIFY. The signature is carried to the server untouched and
|
|
25
|
+
// checked there against a SOD-bound DG15. A client that verified its own
|
|
26
|
+
// chip would be a client an attacker can simply patch — the same reason
|
|
27
|
+
// passive authentication has always run server-side.
|
|
28
|
+
//
|
|
29
|
+
// Both are also why this file is short: reading and forwarding is the whole job.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** ICAO 9303-11 fixes the Active-Authentication challenge at 8 bytes. */
|
|
33
|
+
export const AA_CHALLENGE_BYTES = 8;
|
|
34
|
+
|
|
35
|
+
/** The server's challenge: its id (to spend) and its bytes (for the chip). */
|
|
36
|
+
export interface AaChallenge {
|
|
37
|
+
id: string;
|
|
38
|
+
bytes: Uint8Array;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ActiveAuthRead {
|
|
42
|
+
/** Base64 DG15 — the chip's AA public key. Absent ⇒ the chip has no AA. */
|
|
43
|
+
dg15?: string;
|
|
44
|
+
/** Base64 signature over the challenge. Absent ⇒ the chip would not sign. */
|
|
45
|
+
signature?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* INTERNAL AUTHENTICATE — hand the chip the challenge, take back its signature.
|
|
50
|
+
*
|
|
51
|
+
* Le is 0 (meaning "as much as you have"): the answer is one RSA modulus or one
|
|
52
|
+
* raw r||s pair, and the length varies by document. Naming a length would work
|
|
53
|
+
* on the passports we happened to test and truncate the rest.
|
|
54
|
+
*/
|
|
55
|
+
async function internalAuthenticate(
|
|
56
|
+
sm: SecureMessagingSession,
|
|
57
|
+
transceive: Transceive,
|
|
58
|
+
challenge: Uint8Array,
|
|
59
|
+
): Promise<Uint8Array | null> {
|
|
60
|
+
try {
|
|
61
|
+
const { data, statusWord } = sm.unprotect(
|
|
62
|
+
await transceive(
|
|
63
|
+
sm.protect({ cla: 0x00, ins: 0x88, p1: 0x00, p2: 0x00, data: challenge, le: 0 }),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
if (statusWord !== 0x9000 || data.length === 0) return null;
|
|
67
|
+
return data;
|
|
68
|
+
} catch {
|
|
69
|
+
// A chip that carries DG15 but refuses to sign is unusual but not a
|
|
70
|
+
// finding: it costs the anti-clone check, never the read.
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read DG15 and get the chip to sign the server's challenge.
|
|
77
|
+
*
|
|
78
|
+
* Best-effort throughout, like every optional group: most chips in the field
|
|
79
|
+
* support no Active Authentication at all, and reporting that as a problem
|
|
80
|
+
* would flag the majority of genuine passports.
|
|
81
|
+
*/
|
|
82
|
+
export async function readActiveAuth(
|
|
83
|
+
sm: SecureMessagingSession,
|
|
84
|
+
transceive: Transceive,
|
|
85
|
+
challenge: AaChallenge | undefined,
|
|
86
|
+
): Promise<ActiveAuthRead> {
|
|
87
|
+
// No challenge means the server could not issue one. Reading DG15 anyway
|
|
88
|
+
// would cost a round trip on the document for a key nothing can be checked
|
|
89
|
+
// against, so the whole step is skipped.
|
|
90
|
+
if (!challenge || challenge.bytes.length !== AA_CHALLENGE_BYTES) return {};
|
|
91
|
+
|
|
92
|
+
const dg15 = await readOptionalFile(sm, transceive, EF.DG15, 'DG15');
|
|
93
|
+
if (!dg15) return {};
|
|
94
|
+
|
|
95
|
+
const signature = await internalAuthenticate(sm, transceive, challenge.bytes);
|
|
96
|
+
return {
|
|
97
|
+
dg15: toBase64(dg15),
|
|
98
|
+
...(signature ? { signature: toBase64(signature) } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { findTlv, readTlv } from './der';
|
|
2
|
+
import { EF, type Transceive } from './files';
|
|
3
|
+
import { readOptionalFile } from './optionalRead';
|
|
4
|
+
import type { SecureMessagingSession } from './secureMessaging';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The optional detail groups: DG7 (displayed signature image), DG11
|
|
8
|
+
// (additional personal details), DG12 (additional document details).
|
|
9
|
+
//
|
|
10
|
+
// All three are BAC-readable like DG1/DG2, small (a few KB between them), and
|
|
11
|
+
// OPTIONAL BY STANDARD — many issuers simply do not write them. So the read is
|
|
12
|
+
// gated on EF.COM, the chip's own table of contents: one tiny file names
|
|
13
|
+
// exactly which data groups exist, and absent groups are skipped instead of
|
|
14
|
+
// being probed with SELECTs the chip will refuse. When COM itself cannot be
|
|
15
|
+
// read (or does not parse), every group is attempted best-effort — the worst
|
|
16
|
+
// case is a fast refusal per absent file, which readOptionalFile absorbs.
|
|
17
|
+
//
|
|
18
|
+
// Deliberately read LAST, after the portrait: they are nice-to-have context,
|
|
19
|
+
// and a session that drops while fetching them has already banked everything
|
|
20
|
+
// that matters. Like DG2 they are only read once the SOD arrived — the server
|
|
21
|
+
// authenticates each group against it, and an unverifiable extra is one an
|
|
22
|
+
// attacker could have substituted.
|
|
23
|
+
//
|
|
24
|
+
// (DG3/DG4 — fingerprints and iris — are NOT here and never will be: they sit
|
|
25
|
+
// behind Extended Access Control, decryptable only by government inspection
|
|
26
|
+
// systems holding terminal-authentication certificates.)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** LDS1 data-group tag byte → DG number (ICAO 9303-10). */
|
|
30
|
+
const TAG_TO_DG: Readonly<Record<number, number>> = {
|
|
31
|
+
0x61: 1, 0x75: 2, 0x63: 3, 0x76: 4, 0x65: 5, 0x66: 6, 0x67: 7, 0x68: 8,
|
|
32
|
+
0x69: 9, 0x6a: 10, 0x6b: 11, 0x6c: 12, 0x6d: 13, 0x6e: 14, 0x6f: 15, 0x70: 16,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse EF.COM's data-group presence list (tag 5C inside the outer 60).
|
|
37
|
+
* Null when the bytes do not parse as a COM — the caller then probes instead.
|
|
38
|
+
*/
|
|
39
|
+
export function parseComDataGroups(com: Uint8Array): Set<number> | null {
|
|
40
|
+
const outer = readTlv(com);
|
|
41
|
+
if (!outer || outer.tag !== 0x60) return null;
|
|
42
|
+
const list = findTlv(outer.value, 0x5c);
|
|
43
|
+
if (!list) return null;
|
|
44
|
+
const present = new Set<number>();
|
|
45
|
+
for (const byte of list.value) {
|
|
46
|
+
const dg = TAG_TO_DG[byte];
|
|
47
|
+
if (dg !== undefined) present.add(dg);
|
|
48
|
+
}
|
|
49
|
+
return present;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ExtraGroupReads {
|
|
53
|
+
dg7: Uint8Array | null;
|
|
54
|
+
dg11: Uint8Array | null;
|
|
55
|
+
dg12: Uint8Array | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read whichever of DG7/DG11/DG12 the chip declares (or, without a readable
|
|
60
|
+
* COM, whichever answer). Every read is best-effort — a failure costs the
|
|
61
|
+
* group, never the session's banked result.
|
|
62
|
+
*/
|
|
63
|
+
export async function readExtraGroups(
|
|
64
|
+
sm: SecureMessagingSession,
|
|
65
|
+
transceive: Transceive,
|
|
66
|
+
): Promise<ExtraGroupReads> {
|
|
67
|
+
const com = await readOptionalFile(sm, transceive, EF.COM, 'EF.COM');
|
|
68
|
+
const declared = com ? parseComDataGroups(com) : null;
|
|
69
|
+
const want = (dg: number): boolean => declared === null || declared.has(dg);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
dg7: want(7) ? await readOptionalFile(sm, transceive, EF.DG7, 'DG7') : null,
|
|
73
|
+
dg11: want(11) ? await readOptionalFile(sm, transceive, EF.DG11, 'DG11') : null,
|
|
74
|
+
dg12: want(12) ? await readOptionalFile(sm, transceive, EF.DG12, 'DG12') : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/emrtd/files.ts
CHANGED
package/src/emrtd/index.ts
CHANGED
|
@@ -29,3 +29,4 @@ export {
|
|
|
29
29
|
export { parseDg1 } from './dg1';
|
|
30
30
|
export { decodeChipImage, isNfcAvailable, nfcUnavailableReason } from './native';
|
|
31
31
|
export { cancelChipRead, readPassportChip, type ChipReadOptions } from './read';
|
|
32
|
+
export { AA_CHALLENGE_BYTES, readActiveAuth, type AaChallenge, type ActiveAuthRead } from './activeAuth';
|
package/src/emrtd/read.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Platform } from 'react-native';
|
|
|
2
2
|
|
|
3
3
|
import { nativeModule } from './native';
|
|
4
4
|
import { readChip, type EmrtdReadResult, EmrtdSessionError } from './session';
|
|
5
|
+
import type { AaChallenge } from './activeAuth';
|
|
5
6
|
import {
|
|
6
7
|
MAX_CHIP_ATTEMPTS,
|
|
7
8
|
chipRetryDelayMs,
|
|
@@ -37,6 +38,12 @@ export interface ChipReadOptions {
|
|
|
37
38
|
successMessage?: string;
|
|
38
39
|
/** Read progress, so the UI can narrate an otherwise invisible operation. */
|
|
39
40
|
onStage?: (stage: NfcReadStage) => void;
|
|
41
|
+
/**
|
|
42
|
+
* The Active-Authentication challenge issued by the SERVER (8 bytes). Absent
|
|
43
|
+
* ⇒ the anti-clone step is skipped and the read is exactly what it was
|
|
44
|
+
* before — the whole capability null-degrades rather than failing.
|
|
45
|
+
*/
|
|
46
|
+
aaChallenge?: AaChallenge;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
|
@@ -151,7 +158,7 @@ export async function readPassportChip(
|
|
|
151
158
|
|
|
152
159
|
try {
|
|
153
160
|
onStage('waiting');
|
|
154
|
-
const result = await readChip(native, mrz, onStage);
|
|
161
|
+
const result = await readChip(native, mrz, onStage, options.aaChallenge);
|
|
155
162
|
if (!result.sod && attempt < MAX_CHIP_ATTEMPTS && !abandoned()) {
|
|
156
163
|
partial = result;
|
|
157
164
|
devLog(attempt, 'security-file', new Error('EF.SOD missing — retrying for a complete read'));
|
package/src/emrtd/session.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fromBase64, toBase64 } from './bytes';
|
|
2
2
|
import { buildBacChallenge, completeBac } from './bac';
|
|
3
3
|
import { primitivesFromNative, type EmrtdPrimitives, type MrzKeyFields } from './crypto';
|
|
4
|
+
import { readExtraGroups, type ExtraGroupReads } from './extras';
|
|
5
|
+
import { readActiveAuth, type AaChallenge, type ActiveAuthRead } from './activeAuth';
|
|
4
6
|
import { EF, readFile, type Transceive } from './files';
|
|
5
7
|
import { readOptionalFile } from './optionalRead';
|
|
6
8
|
import { PaceError, PREFER_PACE, tryPace, type PaceOutcome } from './open';
|
|
@@ -45,6 +47,22 @@ export interface EmrtdReadResult {
|
|
|
45
47
|
sod?: string;
|
|
46
48
|
/** DG2 — the portrait. Best-effort; the largest file and the likeliest to drop. */
|
|
47
49
|
dg2?: string;
|
|
50
|
+
/** DG7 — the displayed signature image. Best-effort, COM-gated (see extras.ts). */
|
|
51
|
+
dg7?: string;
|
|
52
|
+
/** DG11 — additional personal details. Best-effort; many issuers omit it. */
|
|
53
|
+
dg11?: string;
|
|
54
|
+
/** DG12 — additional document details. Best-effort; many issuers omit it. */
|
|
55
|
+
dg12?: string;
|
|
56
|
+
/** DG15 — the chip's Active-Authentication public key. Absent on the many
|
|
57
|
+
* chips that support no AA at all. */
|
|
58
|
+
dg15?: string;
|
|
59
|
+
/** The chip's signature over the SERVER's challenge — the anti-clone proof.
|
|
60
|
+
* Verified server-side only; a client that checked its own chip could be
|
|
61
|
+
* patched to say yes. */
|
|
62
|
+
aaSignature?: string;
|
|
63
|
+
/** Which server-issued challenge that signature answers — echoed back so the
|
|
64
|
+
* server can spend it. Not read off the chip; carried through with it. */
|
|
65
|
+
aaChallengeId?: string;
|
|
48
66
|
/** How the chip was unlocked. Reported to the server on the submission. */
|
|
49
67
|
chipAuth: 'bac' | 'pace';
|
|
50
68
|
/**
|
|
@@ -141,6 +159,12 @@ export async function readChip(
|
|
|
141
159
|
* enough that silence reads as failure and prompts them to lift the document.
|
|
142
160
|
*/
|
|
143
161
|
onStage?: (stage: NfcReadStage) => void,
|
|
162
|
+
/**
|
|
163
|
+
* The Active-Authentication challenge the SERVER issued, if it could. Ours to
|
|
164
|
+
* carry, never ours to choose: a client-chosen nonce makes a captured
|
|
165
|
+
* signature replayable, which is exactly the clone AA exists to catch.
|
|
166
|
+
*/
|
|
167
|
+
aaChallenge?: AaChallenge,
|
|
144
168
|
): Promise<EmrtdReadResult> {
|
|
145
169
|
const p = primitivesFromNative(native);
|
|
146
170
|
|
|
@@ -178,9 +202,20 @@ export async function readChip(
|
|
|
178
202
|
const sod = await readOptionalFile(sm, transceive, EF.SOD, 'EF.SOD');
|
|
179
203
|
|
|
180
204
|
let dg2: Uint8Array | null = null;
|
|
205
|
+
let extras: ExtraGroupReads = { dg7: null, dg11: null, dg12: null };
|
|
206
|
+
let activeAuth: ActiveAuthRead = {};
|
|
181
207
|
if (sod) {
|
|
182
208
|
onStage?.('readingPhoto');
|
|
183
209
|
dg2 = await readOptionalFile(sm, transceive, EF.DG2, 'DG2');
|
|
210
|
+
// The optional detail groups come LAST: by now everything that matters is
|
|
211
|
+
// banked, so a drop here costs only nice-to-have context. Gated on the SOD
|
|
212
|
+
// like DG2 — the server authenticates each group against it.
|
|
213
|
+
onStage?.('readingDetails');
|
|
214
|
+
extras = await readExtraGroups(sm, transceive);
|
|
215
|
+
// The anti-clone challenge LAST: it is the only step that asks the chip to
|
|
216
|
+
// compute rather than read, so it is the slowest per byte and the one worth
|
|
217
|
+
// losing if the document leaves contact.
|
|
218
|
+
activeAuth = await readActiveAuth(sm, transceive, aaChallenge);
|
|
184
219
|
}
|
|
185
220
|
onStage?.('done');
|
|
186
221
|
|
|
@@ -188,6 +223,12 @@ export async function readChip(
|
|
|
188
223
|
dg1: toBase64(dg1),
|
|
189
224
|
...(sod ? { sod: toBase64(sod) } : {}),
|
|
190
225
|
...(dg2 ? { dg2: toBase64(dg2) } : {}),
|
|
226
|
+
...(extras.dg7 ? { dg7: toBase64(extras.dg7) } : {}),
|
|
227
|
+
...(extras.dg11 ? { dg11: toBase64(extras.dg11) } : {}),
|
|
228
|
+
...(extras.dg12 ? { dg12: toBase64(extras.dg12) } : {}),
|
|
229
|
+
...(activeAuth.dg15 ? { dg15: activeAuth.dg15 } : {}),
|
|
230
|
+
...(activeAuth.signature ? { aaSignature: activeAuth.signature } : {}),
|
|
231
|
+
...(activeAuth.signature && aaChallenge ? { aaChallengeId: aaChallenge.id } : {}),
|
|
191
232
|
chipAuth: access.chipAuth,
|
|
192
233
|
paceOutcome: access.outcome,
|
|
193
234
|
...(access.detail ? { paceDetail: access.detail } : {}),
|