@myazahq/kyc-sdk-react-native 2.3.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.
Files changed (129) hide show
  1. package/android/build.gradle +35 -3
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
  3. package/ios/HybridMyazaFaceDetector.swift +8 -0
  4. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  5. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  6. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  7. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  8. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  9. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  10. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  11. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  12. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  13. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  14. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  15. package/package.json +1 -2
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/BrandBar.tsx +137 -0
  18. package/src/components/DashedBorder.tsx +55 -0
  19. package/src/components/DialCodePicker.tsx +25 -24
  20. package/src/components/DocumentReviewSide.tsx +34 -14
  21. package/src/components/Icon.tsx +14 -0
  22. package/src/components/KycSheet.tsx +39 -140
  23. package/src/components/MediaSourceSheet.tsx +7 -37
  24. package/src/components/MultiIdProgress.tsx +107 -0
  25. package/src/components/MyazaDateField.tsx +6 -20
  26. package/src/components/MyazaInput.tsx +23 -4
  27. package/src/components/MyazaSelect.tsx +17 -40
  28. package/src/components/OwnershipSlider.tsx +102 -0
  29. package/src/components/PhoneNumberInput.tsx +37 -4
  30. package/src/components/SandboxBanner.tsx +92 -0
  31. package/src/components/StaggerIn.tsx +63 -0
  32. package/src/components/StepHeader.tsx +15 -2
  33. package/src/components/StepView.tsx +26 -0
  34. package/src/components/glass/ChromeGlass.tsx +65 -0
  35. package/src/components/glass/FloatingSheet.tsx +242 -0
  36. package/src/components/glass/GlassSheet.tsx +50 -0
  37. package/src/components/glass/GlassSurface.tsx +31 -3
  38. package/src/components/stepHeaderMeta.tsx +3 -1
  39. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  40. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  41. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  42. package/src/config/business.ts +6 -6
  43. package/src/config/businessDetailsValidity.ts +52 -0
  44. package/src/config/businessPrefill.ts +72 -0
  45. package/src/config/businessSteps.ts +14 -3
  46. package/src/config/keyPeople.ts +111 -7
  47. package/src/config/keyPeopleCorporate.ts +19 -0
  48. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  49. package/src/config/keyPeopleOwners.ts +26 -0
  50. package/src/config/keyPeoplePrefill.ts +138 -0
  51. package/src/config/keyPeopleSectionDefs.ts +65 -0
  52. package/src/config/keyPeopleSections.ts +167 -0
  53. package/src/config/registrationHint.ts +1 -1
  54. package/src/config/stepOrder.ts +24 -4
  55. package/src/config/theme.ts +8 -2
  56. package/src/config/website.ts +36 -0
  57. package/src/config/workflowMerge.ts +5 -0
  58. package/src/emrtd/activeAuth.ts +100 -0
  59. package/src/emrtd/extras.ts +76 -0
  60. package/src/emrtd/files.ts +5 -0
  61. package/src/emrtd/index.ts +1 -0
  62. package/src/emrtd/read.ts +8 -1
  63. package/src/emrtd/session.ts +41 -0
  64. package/src/emrtd/stages.ts +11 -0
  65. package/src/lib/contact-recovery.ts +44 -0
  66. package/src/lib/multi-id.ts +210 -0
  67. package/src/lib/resubmit.ts +130 -0
  68. package/src/lib/screen-corners.ts +50 -0
  69. package/src/lib/step-log.ts +24 -3
  70. package/src/lib/use-multi-id-plan.ts +29 -0
  71. package/src/liveness/useModelReady.ts +65 -0
  72. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  73. package/src/screens/ApplicantRoleStep.tsx +9 -5
  74. package/src/screens/BusinessCheckPanel.tsx +89 -0
  75. package/src/screens/BusinessDetailsFields.tsx +109 -0
  76. package/src/screens/BusinessDetailsStep.tsx +131 -145
  77. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  78. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  79. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  80. package/src/screens/BusinessPickedCard.tsx +86 -0
  81. package/src/screens/BusinessPickedSection.tsx +51 -0
  82. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  83. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  84. package/src/screens/BusinessSearch.tsx +199 -0
  85. package/src/screens/BusinessSearchResults.tsx +115 -0
  86. package/src/screens/CompanyInfoFields.tsx +86 -51
  87. package/src/screens/ConsentStep.tsx +31 -0
  88. package/src/screens/ContactDestinationField.tsx +1 -0
  89. package/src/screens/ContactVerificationStep.tsx +34 -3
  90. package/src/screens/IdTypeStep.tsx +12 -5
  91. package/src/screens/KeepLinksSheet.tsx +88 -0
  92. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  93. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  94. package/src/screens/KeyPeoplePending.tsx +154 -0
  95. package/src/screens/KeyPeopleSection.tsx +162 -0
  96. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  97. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  98. package/src/screens/KeyPersonCard.tsx +36 -4
  99. package/src/screens/KeyPersonForm.tsx +195 -36
  100. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  101. package/src/screens/KeyPersonOwners.tsx +109 -0
  102. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  103. package/src/screens/KeyPersonSheet.tsx +92 -108
  104. package/src/screens/LivenessStep.tsx +18 -0
  105. package/src/screens/NfcStep.tsx +14 -0
  106. package/src/screens/ProofOfAddressStep.tsx +6 -3
  107. package/src/screens/SubmittedStep.tsx +66 -10
  108. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  109. package/src/screens/useAwaitingPeople.ts +90 -0
  110. package/src/services/api-types.ts +158 -2
  111. package/src/services/api-verify-types.ts +53 -1
  112. package/src/services/api.ts +104 -0
  113. package/src/services/deviceMetadata.ts +1 -1
  114. package/src/services/errors.ts +10 -2
  115. package/src/services/workflowGate.ts +1 -0
  116. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  117. package/src/store/businessCheck.ts +113 -0
  118. package/src/store/derive.ts +15 -0
  119. package/src/store/kycStore.ts +241 -14
  120. package/src/store/serverConfig.ts +2 -0
  121. package/src/store/session.ts +208 -0
  122. package/src/store/state.ts +155 -2
  123. package/src/store/submit.ts +63 -13
  124. package/src/store/submitApplicant.ts +5 -11
  125. package/src/types/business.ts +23 -2
  126. package/src/types/config.ts +29 -3
  127. package/src/types/verification.ts +1 -1
  128. package/src/types/workflow.ts +15 -0
  129. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,65 @@
1
+ import type { KeyPeopleSection } from './keyPeopleSections';
2
+ import type { WorkflowBusinessConfig } from '../types/business';
3
+
4
+ // The step's section definitions — which sections show and what they say.
5
+ // Split from keyPeopleSections.ts (200-line rule).
6
+
7
+ /**
8
+ * The register's default beneficial-ownership line, when the workflow does
9
+ * not set its own. Mirrors the server's `uboThresholdFor`: 25 is the
10
+ * FATF/EU/FinCEN indicative figure; Nigeria's CAMA files significant control
11
+ * from a lower bar, so NG defaults to 10. Keep in lockstep with the web SDK.
12
+ */
13
+ export function defaultUboThreshold(country?: string | null): number {
14
+ return (country ?? '').toUpperCase() === 'NG' ? 10 : 25;
15
+ }
16
+
17
+ export interface KeyPeopleSectionDef {
18
+ key: KeyPeopleSection;
19
+ title: string;
20
+ description: string;
21
+ addLabel: string;
22
+ }
23
+
24
+ /**
25
+ * Which sections the step shows, with their plain-language definitions. The
26
+ * definitions carry the REAL threshold (workflow override or the register's
27
+ * default) — a printed band the server does not enforce would be a lie the
28
+ * applicant plans around. Scope follows the workflow's `keyPeople.roles`.
29
+ */
30
+ export function keyPeopleSectionList(
31
+ business: WorkflowBusinessConfig | undefined,
32
+ threshold: number,
33
+ ): KeyPeopleSectionDef[] {
34
+ const scoped = business?.keyPeople?.roles;
35
+ const inScope = (roles: string[]): boolean =>
36
+ !scoped || scoped.length === 0 || roles.some((r) => scoped.includes(r as never));
37
+
38
+ const t = String(threshold);
39
+ const out: KeyPeopleSectionDef[] = [];
40
+ if (inScope(['beneficial_owner'])) {
41
+ out.push({
42
+ key: 'ubos',
43
+ title: 'Beneficial owners',
44
+ description: `Individuals who own ${t}% or more of the company.`,
45
+ addLabel: 'Add a beneficial owner',
46
+ });
47
+ }
48
+ if (inScope(['shareholder'])) {
49
+ out.push({
50
+ key: 'shareholders',
51
+ title: 'Shareholders',
52
+ description: `People or companies holding under ${t}%.`,
53
+ addLabel: 'Add a shareholder',
54
+ });
55
+ }
56
+ if (inScope(['director', 'signatory'])) {
57
+ out.push({
58
+ key: 'representatives',
59
+ title: 'Directors & representatives',
60
+ description: 'People who act on behalf of the company.',
61
+ addLabel: 'Add a representative',
62
+ });
63
+ }
64
+ return out;
65
+ }
@@ -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 — no spaces, e.g. RC0000000.',
73
+ 'Start with RC, BN, IT, LP or LLP followed by the number, no spaces, e.g. RC0000000.',
74
74
  };
75
75
  }
76
76
 
@@ -11,8 +11,8 @@
11
11
  // capture → (nfc) → (liveness) → (poa) → (questionnaire) →
12
12
  // submitted
13
13
  // business: consent → (email) → (phone) → business-details →
14
- // (key-people) → (documents) → (applicant-role → capture leg) →
15
- // (questionnaire) → submitted
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
- const steps: KYCStep[] = ['consent', ...contactSteps(o), ...businessSectionSteps(o.business)];
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
  }
@@ -37,7 +37,13 @@ export const LIGHT_COLORS: MyazaColorScheme = {
37
37
  backgroundSecondary: '#F6F5FE',
38
38
  textDark: '#070330',
39
39
  textSecondary: '#5A5775',
40
- textMuted: '#828197',
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: '#5A5775',
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
+ }
@@ -26,6 +26,11 @@ export const EF = {
26
26
  COM: 0x011e,
27
27
  DG1: 0x0101,
28
28
  DG2: 0x0102,
29
+ DG7: 0x0107,
30
+ DG11: 0x010b,
31
+ DG12: 0x010c,
32
+ /** DG15 — the Active-Authentication public key (see activeAuth.ts). */
33
+ DG15: 0x010f,
29
34
  SOD: 0x011d,
30
35
  } as const;
31
36
 
@@ -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'));