@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.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/README.md +151 -5
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- 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 +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -176
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- 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/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- 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/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- 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 +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- 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/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +82 -119
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
// server is going to make anyway.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
import type { PoaDocumentType, ProofOfAddressConfig } from '../types/workflow';
|
|
11
|
+
import type { PoaDocumentType, PoaNameRule, ProofOfAddressConfig } from '../types/workflow';
|
|
12
12
|
|
|
13
13
|
export const POA_TYPE_LABELS: Record<PoaDocumentType, string> = {
|
|
14
14
|
utility_bill: 'Utility bill',
|
|
15
15
|
bank_statement: 'Bank statement',
|
|
16
16
|
tenancy_agreement: 'Tenancy agreement',
|
|
17
|
+
government_document: 'Government-issued document',
|
|
17
18
|
other: 'Other document',
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -21,9 +22,15 @@ const ALL_POA_TYPES: PoaDocumentType[] = [
|
|
|
21
22
|
'utility_bill',
|
|
22
23
|
'bank_statement',
|
|
23
24
|
'tenancy_agreement',
|
|
25
|
+
'government_document',
|
|
24
26
|
'other',
|
|
25
27
|
];
|
|
26
28
|
|
|
29
|
+
/** A kind THIS build can label and draw. A newer kind the dashboard offers
|
|
30
|
+
* before the SDK ships is hidden rather than rendered as a blank row. */
|
|
31
|
+
const knownKinds = (kinds: readonly string[] | undefined): PoaDocumentType[] =>
|
|
32
|
+
(kinds ?? []).filter((k): k is PoaDocumentType => (ALL_POA_TYPES as string[]).includes(k));
|
|
33
|
+
|
|
27
34
|
/** Default recency window the server checks the document date against. */
|
|
28
35
|
export const DEFAULT_POA_MAX_AGE_DAYS = 90;
|
|
29
36
|
|
|
@@ -32,10 +39,52 @@ export function hasProofOfAddressStep(poa: ProofOfAddressConfig | undefined | nu
|
|
|
32
39
|
return poa?.enabled === true;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The document kinds on offer for `country` (mirror of the web SDK's
|
|
44
|
+
* `poaOfferedKinds` — keep the three in lockstep): that country's override
|
|
45
|
+
* when the workflow declares one, else the global list, else all four.
|
|
46
|
+
*/
|
|
47
|
+
export function poaDocumentTypes(
|
|
48
|
+
poa: ProofOfAddressConfig | undefined,
|
|
49
|
+
country?: string | null,
|
|
50
|
+
): PoaDocumentType[] {
|
|
51
|
+
const override = knownKinds(country ? poa?.countryDocuments?.[country.toUpperCase()] : undefined);
|
|
52
|
+
if (override.length > 0) return override;
|
|
53
|
+
const configured = knownKinds(poa?.documentTypes);
|
|
54
|
+
return configured.length > 0 ? configured : ALL_POA_TYPES;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The name rule the server judges THIS document under — the country's per-kind
|
|
59
|
+
* exception, else the workflow default, else `required`. Mirror of the web
|
|
60
|
+
* SDK's `poaNamePolicy` and the server's `resolvePoaNamePolicy` — keep in
|
|
61
|
+
* lockstep. Read only to word the step: under `off` the header stops asking
|
|
62
|
+
* for the applicant's name.
|
|
63
|
+
*/
|
|
64
|
+
export function poaNamePolicy(
|
|
65
|
+
poa: ProofOfAddressConfig | undefined | null,
|
|
66
|
+
country: string | undefined | null,
|
|
67
|
+
kind: PoaDocumentType | undefined | null,
|
|
68
|
+
): PoaNameRule {
|
|
69
|
+
const exception = country && kind ? poa?.countryNameMatch?.[country.toUpperCase()]?.[kind] : undefined;
|
|
70
|
+
if (exception === 'required' || exception === 'optional' || exception === 'off') return exception;
|
|
71
|
+
const def = poa?.nameMatch;
|
|
72
|
+
return def === 'optional' || def === 'off' ? def : 'required';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether the org's accepted-country list admits `country`. An empty list
|
|
77
|
+
* accepts everyone; an unknown country is not refused here (the server is the
|
|
78
|
+
* gate, and it is soft on full flows).
|
|
79
|
+
*/
|
|
80
|
+
export function poaCountryAccepted(
|
|
81
|
+
poa: ProofOfAddressConfig | undefined | null,
|
|
82
|
+
country: string | undefined | null,
|
|
83
|
+
): boolean {
|
|
84
|
+
const accepted = poa?.countries;
|
|
85
|
+
if (!accepted?.length || !country) return true;
|
|
86
|
+
const code = country.toUpperCase();
|
|
87
|
+
return accepted.some((c) => c.toUpperCase() === code);
|
|
39
88
|
}
|
|
40
89
|
|
|
41
90
|
/**
|
|
@@ -63,12 +112,6 @@ export const POA_ACCEPTED_MIME_TYPES = [
|
|
|
63
112
|
'application/pdf',
|
|
64
113
|
] as const;
|
|
65
114
|
|
|
66
|
-
/**
|
|
67
|
-
* Local size cap, matching the web SDK. The server allows more, but rejecting
|
|
68
|
-
* here gives an immediate, specific message instead of a slow 413.
|
|
69
|
-
*/
|
|
70
|
-
export const POA_MAX_BYTES = 20 * 1024 * 1024;
|
|
71
|
-
|
|
72
115
|
export function isAcceptedPoaMimeType(mimeType: string | undefined): boolean {
|
|
73
116
|
const base = (mimeType?.split(';')[0] ?? '').trim().toLowerCase();
|
|
74
117
|
return (POA_ACCEPTED_MIME_TYPES as readonly string[]).includes(base);
|
package/src/config/regions.ts
CHANGED
|
@@ -116,3 +116,27 @@ export function groupCountriesByRegion(codes: string[]): RegionGroup[] {
|
|
|
116
116
|
countries: buckets.get(region)!.sort((a, b) => a.name.localeCompare(b.name)),
|
|
117
117
|
}));
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Lift the visitor's IP country out of its alphabetical or regional place to
|
|
122
|
+
* the top of a picker, so the one country most likely to be theirs is the
|
|
123
|
+
* first thing they see rather than something to scroll for.
|
|
124
|
+
*
|
|
125
|
+
* Takes the ALREADY-FILTERED list: the pin stays subject to the search, so
|
|
126
|
+
* typing narrows to what was asked for rather than keeping a row that does
|
|
127
|
+
* not match it. Generic over the row shape because the country-select step
|
|
128
|
+
* pins bare ISO codes and the dial-code sheet pins option objects; one rule,
|
|
129
|
+
* two callers. Mirrors the web SDK's CountryRegionPicker + PhoneNumberInput
|
|
130
|
+
* and Flutter's `pinGeoRow`.
|
|
131
|
+
*/
|
|
132
|
+
export function pinGeoRow<T>(
|
|
133
|
+
visible: readonly T[],
|
|
134
|
+
geoCountry: string | null | undefined,
|
|
135
|
+
codeOf: (item: T) => string,
|
|
136
|
+
): { pinned: T | null; rest: T[] } {
|
|
137
|
+
const geo = geoCountry?.trim().toUpperCase();
|
|
138
|
+
if (!geo) return { pinned: null, rest: [...visible] };
|
|
139
|
+
const hit = visible.find((item) => codeOf(item).toUpperCase() === geo);
|
|
140
|
+
if (hit === undefined) return { pinned: null, rest: [...visible] };
|
|
141
|
+
return { pinned: hit, rest: visible.filter((item) => item !== hit) };
|
|
142
|
+
}
|
|
@@ -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,20 +20,47 @@
|
|
|
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';
|
|
24
|
+
import { addressFlowSteps, type AddressFlowOptions } from '../lib/address-flow';
|
|
23
25
|
import { businessSectionSteps, hasApplicantVerification } from './businessSteps';
|
|
24
26
|
|
|
25
27
|
export interface StepOrderOptions {
|
|
26
28
|
isBusiness: boolean;
|
|
29
|
+
/** Scoped flows: no identity section — each scope's headline step is the
|
|
30
|
+
* flow (see lib/scope.ts). */
|
|
31
|
+
scope?: import('../lib/scope').WorkflowScope | null;
|
|
27
32
|
/** Business (KYB) configuration — drives the application-section steps. */
|
|
28
33
|
business?: WorkflowBusinessConfig;
|
|
29
34
|
hasDocCapture: boolean;
|
|
30
35
|
hasNfc: boolean;
|
|
31
36
|
hasLiveness: boolean;
|
|
32
37
|
hasCountrySelect: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the flow opens on the consent screen. Absent = yes. `false` is the
|
|
40
|
+
* workflow's `consentStep: false`: the host app has already asked, so the
|
|
41
|
+
* flow opens on its first real step instead (config/consentStep.ts).
|
|
42
|
+
*/
|
|
43
|
+
hasConsent?: boolean;
|
|
33
44
|
hasEmailVerification: boolean;
|
|
34
45
|
hasPhoneVerification: boolean;
|
|
35
46
|
hasPoa: boolean;
|
|
47
|
+
hasAddressCollection: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Which address screens the flow has, from the ONE `addressFlowFor` call.
|
|
50
|
+
* The list is built by `addressFlowSteps`, the same builder the address
|
|
51
|
+
* steps navigate by, so the progress bar and the flow cannot disagree.
|
|
52
|
+
* Absent falls back to the default shape (no search, entrance offered).
|
|
53
|
+
*/
|
|
54
|
+
addressFlow?: AddressFlowOptions;
|
|
36
55
|
hasQuestionnaire: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* A reviewer sent this back to redo specific steps.
|
|
58
|
+
*
|
|
59
|
+
* Applied LAST, over the fully-built order, so it narrows whatever the flow
|
|
60
|
+
* would otherwise have been rather than having to know how that order was
|
|
61
|
+
* assembled — which differs between the individual and KYB branches below.
|
|
62
|
+
*/
|
|
63
|
+
resubmit?: ResubmitConfig | null;
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
// Contact-verification OTP steps sit right after consent (both flows) — a cheap
|
|
@@ -56,10 +83,27 @@ function captureLeg(o: StepOrderOptions): KYCStep[] {
|
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
export function buildStepOrder(o: StepOrderOptions): KYCStep[] {
|
|
86
|
+
return applyResubmitSteps(fullStepOrder(o), o.resubmit);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The flow's opening screen: consent, unless the workflow switched it off. */
|
|
90
|
+
function openingSteps(o: StepOrderOptions): KYCStep[] {
|
|
91
|
+
return o.hasConsent === false ? [] : ['consent'];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The flow as configured, before any reviewer narrowing. */
|
|
95
|
+
function fullStepOrder(o: StepOrderOptions): KYCStep[] {
|
|
59
96
|
// Business (KYB) flow — the application section, then (when the workflow
|
|
60
97
|
// requires applicant verification) the ordinary individual capture leg.
|
|
61
98
|
if (o.isBusiness) {
|
|
62
|
-
|
|
99
|
+
// The questionnaire sits INSIDE the business section (before key people) —
|
|
100
|
+
// its questions are about the company, so it stays with the company form
|
|
101
|
+
// rather than trailing the applicant's own capture leg.
|
|
102
|
+
const steps: KYCStep[] = [
|
|
103
|
+
...openingSteps(o),
|
|
104
|
+
...contactSteps(o),
|
|
105
|
+
...businessSectionSteps(o.business, o.hasQuestionnaire, o.hasAddressCollection),
|
|
106
|
+
];
|
|
63
107
|
if (hasApplicantVerification(o.business)) {
|
|
64
108
|
// The applicant may hold an ID issued anywhere the org can verify —
|
|
65
109
|
// more than one granted country (hasCountrySelect, derived from the
|
|
@@ -68,16 +112,56 @@ export function buildStepOrder(o: StepOrderOptions): KYCStep[] {
|
|
|
68
112
|
if (o.hasCountrySelect) steps.push('country-select');
|
|
69
113
|
steps.push('id-type', ...captureLeg(o));
|
|
70
114
|
}
|
|
115
|
+
steps.push('submitted');
|
|
116
|
+
return steps;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Scoped flows: the scope's headline section IS the flow.
|
|
120
|
+
if (o.scope === 'address') {
|
|
121
|
+
const steps: KYCStep[] = [...openingSteps(o), ...contactSteps(o)];
|
|
122
|
+
if (o.hasPoa) steps.push('proof-of-address');
|
|
123
|
+
steps.push(
|
|
124
|
+
...addressFlowSteps(
|
|
125
|
+
o.addressFlow ?? { searchAvailable: false, photoMode: 'optional', streetViewOffered: false },
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
if (o.hasQuestionnaire) steps.push('questionnaire');
|
|
129
|
+
steps.push('submitted');
|
|
130
|
+
return steps;
|
|
131
|
+
}
|
|
132
|
+
if (o.scope === 'biometric-authentication' || o.scope === 'biometric-enrollment') {
|
|
133
|
+
const steps: KYCStep[] = [...openingSteps(o), ...contactSteps(o), 'liveness'];
|
|
71
134
|
if (o.hasQuestionnaire) steps.push('questionnaire');
|
|
72
135
|
steps.push('submitted');
|
|
73
136
|
return steps;
|
|
74
137
|
}
|
|
138
|
+
if (o.scope === 'questionnaire') {
|
|
139
|
+
return [...openingSteps(o), ...contactSteps(o), 'questionnaire', 'submitted'];
|
|
140
|
+
}
|
|
141
|
+
if (o.scope === 'contact') {
|
|
142
|
+
return [...openingSteps(o), ...contactSteps(o), 'submitted'];
|
|
143
|
+
}
|
|
75
144
|
|
|
76
145
|
const middle: KYCStep[] = [...captureLeg(o)];
|
|
77
146
|
if (o.hasPoa) middle.push('proof-of-address');
|
|
147
|
+
// The address flow is FOUR real steps on an individual flow (find it, confirm
|
|
148
|
+
// it, show it, commit it), so the progress bar advances through them and back
|
|
149
|
+
// is ordinary step navigation. KYB keeps the single premises step, inserted
|
|
150
|
+
// by businessSectionSteps above.
|
|
151
|
+
if (o.hasAddressCollection) {
|
|
152
|
+
middle.push(
|
|
153
|
+
...addressFlowSteps(
|
|
154
|
+
o.addressFlow ?? {
|
|
155
|
+
searchAvailable: false,
|
|
156
|
+
photoMode: 'optional',
|
|
157
|
+
streetViewOffered: false,
|
|
158
|
+
},
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
78
162
|
if (o.hasQuestionnaire) middle.push('questionnaire');
|
|
79
163
|
return [
|
|
80
|
-
|
|
164
|
+
...openingSteps(o),
|
|
81
165
|
...contactSteps(o),
|
|
82
166
|
...(o.hasCountrySelect ? (['country-select'] as KYCStep[]) : []),
|
|
83
167
|
'id-type',
|
|
@@ -109,10 +193,20 @@ export function nextStepInOrder(step: KYCStep, o: StepOrderOptions): KYCStep {
|
|
|
109
193
|
return order[index + 1] ?? order[order.length - 1]!;
|
|
110
194
|
}
|
|
111
195
|
|
|
112
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* The step before `step` — what the back button goes to.
|
|
198
|
+
*
|
|
199
|
+
* A step not in the order stands still, for the same reason `nextStepInOrder`
|
|
200
|
+
* refuses to guess at one: it has no predecessor in a flow that does not
|
|
201
|
+
* contain it. This used to fall through to `order[0]`, so an applicant resumed
|
|
202
|
+
* onto a step the flow had since dropped was thrown all the way back to consent
|
|
203
|
+
* by a single back-press, discarding everything they had done. Standing still
|
|
204
|
+
* is recoverable; that was not.
|
|
205
|
+
*/
|
|
113
206
|
export function previousStepInOrder(step: KYCStep, o: StepOrderOptions): KYCStep {
|
|
114
207
|
const order = buildStepOrder(o);
|
|
115
208
|
const index = order.indexOf(step);
|
|
116
|
-
if (index
|
|
209
|
+
if (index < 0) return step;
|
|
210
|
+
if (index === 0) return order[0]!;
|
|
117
211
|
return order[index - 1]!;
|
|
118
212
|
}
|
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,35 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Upload size limits.
|
|
3
|
+
//
|
|
4
|
+
// One rule for every document the applicant attaches (proof of address, the
|
|
5
|
+
// KYB company documents, the entrance photo): images up to 5 MB, PDFs up to
|
|
6
|
+
// 15 MB (user decision 2026-09-06). The server's own cap is higher, so refusing
|
|
7
|
+
// here gives an immediate, specific message instead of a slow 413. A THREE-WAY
|
|
8
|
+
// MIRROR with the web SDK's `lib/upload-limits.ts` and Flutter's
|
|
9
|
+
// `config/upload_limits.dart`, pinned to
|
|
10
|
+
// `kyc-sdk-flutter/test/upload_limits_vectors.json`.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export const IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
export const PDF_MAX_BYTES = 15 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** The line under every drop zone: what is accepted, and how big. */
|
|
17
|
+
export const UPLOAD_HINT = 'PDF, JPG, PNG · PDF max 15 MB, images max 5 MB';
|
|
18
|
+
|
|
19
|
+
export function isPdfMime(mime: string | null | undefined): boolean {
|
|
20
|
+
return (mime?.split(';')[0] ?? '').trim().toLowerCase() === 'application/pdf';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The refusal for a file over its cap, or null when it fits. An unknown size
|
|
25
|
+
* also passes: the server still judges the bytes at its own cap, and a picker
|
|
26
|
+
* that reports no size must not block a good file.
|
|
27
|
+
*/
|
|
28
|
+
export function uploadSizeError(
|
|
29
|
+
mime: string | null | undefined,
|
|
30
|
+
size: number | null | undefined,
|
|
31
|
+
): string | null {
|
|
32
|
+
if (typeof size !== 'number' || !Number.isFinite(size)) return null;
|
|
33
|
+
if (isPdfMime(mime)) return size > PDF_MAX_BYTES ? 'PDF is too large (max 15 MB).' : null;
|
|
34
|
+
return size > IMAGE_MAX_BYTES ? 'Image is too large (max 5 MB).' : null;
|
|
35
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -16,17 +16,22 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export const WORKFLOW_KEYS = [
|
|
18
18
|
'subjectType',
|
|
19
|
+
'scope',
|
|
19
20
|
'business',
|
|
20
21
|
'country',
|
|
21
22
|
'countries',
|
|
22
23
|
'idTypes',
|
|
24
|
+
'multiId',
|
|
23
25
|
'enableSelfie',
|
|
24
26
|
'enableDocumentCapture',
|
|
25
27
|
'allowDocumentUpload',
|
|
26
28
|
'enableLiveness',
|
|
27
29
|
'livenessMode',
|
|
28
30
|
'flashSequenceLength',
|
|
31
|
+
'biometric',
|
|
29
32
|
'deviceIntelligence',
|
|
33
|
+
'keyPeopleLinkRecovery',
|
|
34
|
+
'consentStep',
|
|
30
35
|
'requireMobileDevice',
|
|
31
36
|
'voiceGuidance',
|
|
32
37
|
'showThemeToggle',
|
|
@@ -39,7 +44,10 @@ export const WORKFLOW_KEYS = [
|
|
|
39
44
|
'phoneVerification',
|
|
40
45
|
'questionnaire',
|
|
41
46
|
'proofOfAddress',
|
|
47
|
+
'addressCollection',
|
|
42
48
|
'nfc',
|
|
49
|
+
// Set only on a session a reviewer sent back, never on a published flow.
|
|
50
|
+
'resubmit',
|
|
43
51
|
] as const;
|
|
44
52
|
|
|
45
53
|
export type WorkflowKey = (typeof WORKFLOW_KEYS)[number];
|
|
@@ -49,8 +57,10 @@ export type WorkflowKey = (typeof WORKFLOW_KEYS)[number];
|
|
|
49
57
|
* key it DEFINES; an absent key leaves the prop alone (which is how a flow that
|
|
50
58
|
* only sets, say, `livenessMode` doesn't wipe a prop-supplied `userId`).
|
|
51
59
|
*
|
|
52
|
-
* `appearance`
|
|
53
|
-
* that only sets `primaryColor` doesn't wipe a
|
|
60
|
+
* `appearance` and `biometric` merge shallowly with flow keys winning
|
|
61
|
+
* per-field, so a flow that only sets `primaryColor` doesn't wipe a
|
|
62
|
+
* prop-supplied `logo`, and a flow that only switches the selfie review on
|
|
63
|
+
* doesn't wipe a host app's `doneButton: false`.
|
|
54
64
|
*
|
|
55
65
|
* Pure and side-effect free.
|
|
56
66
|
*/
|
|
@@ -63,10 +73,10 @@ export function mergeWorkflowConfig<P extends Record<string, unknown>>(
|
|
|
63
73
|
for (const key of WORKFLOW_KEYS) {
|
|
64
74
|
const value = flowConfig[key];
|
|
65
75
|
if (value === undefined) continue;
|
|
66
|
-
if (key === 'appearance') {
|
|
67
|
-
const
|
|
76
|
+
if (key === 'appearance' || key === 'biometric') {
|
|
77
|
+
const propBlock = props[key];
|
|
68
78
|
merged[key] = {
|
|
69
|
-
...(typeof
|
|
79
|
+
...(typeof propBlock === 'object' && propBlock !== null ? propBlock : {}),
|
|
70
80
|
...(value as Record<string, unknown>),
|
|
71
81
|
};
|
|
72
82
|
} else {
|
|
@@ -96,6 +106,7 @@ const APPLICANT_LEG_KEYS = [
|
|
|
96
106
|
'country',
|
|
97
107
|
'countries',
|
|
98
108
|
'idTypes',
|
|
109
|
+
'multiId',
|
|
99
110
|
'enableSelfie',
|
|
100
111
|
'enableDocumentCapture',
|
|
101
112
|
'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';
|