@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.
Files changed (117) 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 -1
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/DashedBorder.tsx +55 -0
  18. package/src/components/DialCodePicker.tsx +14 -7
  19. package/src/components/Icon.tsx +9 -0
  20. package/src/components/MultiIdProgress.tsx +107 -0
  21. package/src/components/MyazaInput.tsx +23 -4
  22. package/src/components/MyazaSelect.tsx +2 -1
  23. package/src/components/OwnershipSlider.tsx +102 -0
  24. package/src/components/PhoneNumberInput.tsx +37 -4
  25. package/src/components/StaggerIn.tsx +63 -0
  26. package/src/components/StepView.tsx +26 -0
  27. package/src/components/glass/FloatingSheet.tsx +73 -21
  28. package/src/components/glass/GlassSheet.tsx +14 -2
  29. package/src/components/stepHeaderMeta.tsx +3 -1
  30. package/src/config/business.ts +6 -6
  31. package/src/config/businessDetailsValidity.ts +52 -0
  32. package/src/config/businessPrefill.ts +72 -0
  33. package/src/config/businessSteps.ts +14 -3
  34. package/src/config/keyPeople.ts +111 -7
  35. package/src/config/keyPeopleCorporate.ts +19 -0
  36. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  37. package/src/config/keyPeopleOwners.ts +26 -0
  38. package/src/config/keyPeoplePrefill.ts +138 -0
  39. package/src/config/keyPeopleSectionDefs.ts +65 -0
  40. package/src/config/keyPeopleSections.ts +167 -0
  41. package/src/config/registrationHint.ts +1 -1
  42. package/src/config/stepOrder.ts +24 -4
  43. package/src/config/theme.ts +8 -2
  44. package/src/config/website.ts +36 -0
  45. package/src/config/workflowMerge.ts +5 -0
  46. package/src/emrtd/activeAuth.ts +100 -0
  47. package/src/emrtd/extras.ts +76 -0
  48. package/src/emrtd/files.ts +5 -0
  49. package/src/emrtd/index.ts +1 -0
  50. package/src/emrtd/read.ts +8 -1
  51. package/src/emrtd/session.ts +41 -0
  52. package/src/emrtd/stages.ts +11 -0
  53. package/src/lib/contact-recovery.ts +44 -0
  54. package/src/lib/multi-id.ts +210 -0
  55. package/src/lib/resubmit.ts +130 -0
  56. package/src/lib/screen-corners.ts +50 -0
  57. package/src/lib/step-log.ts +24 -3
  58. package/src/lib/use-multi-id-plan.ts +29 -0
  59. package/src/liveness/useModelReady.ts +65 -0
  60. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  61. package/src/screens/ApplicantRoleStep.tsx +9 -5
  62. package/src/screens/BusinessCheckPanel.tsx +89 -0
  63. package/src/screens/BusinessDetailsFields.tsx +109 -0
  64. package/src/screens/BusinessDetailsStep.tsx +131 -145
  65. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  66. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  67. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  68. package/src/screens/BusinessPickedCard.tsx +86 -0
  69. package/src/screens/BusinessPickedSection.tsx +51 -0
  70. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  71. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  72. package/src/screens/BusinessSearch.tsx +199 -0
  73. package/src/screens/BusinessSearchResults.tsx +115 -0
  74. package/src/screens/CompanyInfoFields.tsx +86 -51
  75. package/src/screens/ConsentStep.tsx +31 -0
  76. package/src/screens/ContactDestinationField.tsx +1 -0
  77. package/src/screens/ContactVerificationStep.tsx +34 -3
  78. package/src/screens/IdTypeStep.tsx +12 -5
  79. package/src/screens/KeepLinksSheet.tsx +88 -0
  80. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  81. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  82. package/src/screens/KeyPeoplePending.tsx +154 -0
  83. package/src/screens/KeyPeopleSection.tsx +162 -0
  84. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  85. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  86. package/src/screens/KeyPersonCard.tsx +36 -4
  87. package/src/screens/KeyPersonForm.tsx +195 -36
  88. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  89. package/src/screens/KeyPersonOwners.tsx +109 -0
  90. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  91. package/src/screens/KeyPersonSheet.tsx +92 -108
  92. package/src/screens/LivenessStep.tsx +18 -0
  93. package/src/screens/NfcStep.tsx +14 -0
  94. package/src/screens/ProofOfAddressStep.tsx +6 -3
  95. package/src/screens/SubmittedStep.tsx +66 -10
  96. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  97. package/src/screens/useAwaitingPeople.ts +90 -0
  98. package/src/services/api-types.ts +158 -2
  99. package/src/services/api-verify-types.ts +53 -1
  100. package/src/services/api.ts +104 -0
  101. package/src/services/deviceMetadata.ts +1 -1
  102. package/src/services/errors.ts +10 -2
  103. package/src/services/workflowGate.ts +1 -0
  104. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  105. package/src/store/businessCheck.ts +113 -0
  106. package/src/store/derive.ts +15 -0
  107. package/src/store/kycStore.ts +241 -14
  108. package/src/store/serverConfig.ts +2 -0
  109. package/src/store/session.ts +208 -0
  110. package/src/store/state.ts +155 -2
  111. package/src/store/submit.ts +63 -13
  112. package/src/store/submitApplicant.ts +5 -11
  113. package/src/types/business.ts +23 -2
  114. package/src/types/config.ts +22 -1
  115. package/src/types/verification.ts +1 -1
  116. package/src/types/workflow.ts +15 -0
  117. 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 — 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'));
@@ -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 } : {}),