@myazahq/kyc-sdk-react-native 2.1.0 → 2.3.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 +43 -2
- package/package.json +1 -1
- package/src/components/Icon.tsx +3 -0
- package/src/components/KycSheet.tsx +209 -127
- package/src/components/MyazaInput.tsx +6 -1
- package/src/components/MyazaSelect.tsx +5 -0
- package/src/components/PoweredBy.tsx +20 -15
- package/src/components/ProgressBar.tsx +91 -0
- package/src/components/StepIndicator.tsx +145 -31
- package/src/components/fonts.ts +23 -0
- package/src/config/keyPeople.ts +10 -0
- package/src/config/questionnaire.ts +45 -4
- package/src/config/workflowMerge.ts +1 -0
- package/src/emrtd/crypto.ts +1 -1
- package/src/emrtd/dg1.ts +55 -0
- package/src/emrtd/ec-curves.ts +142 -0
- package/src/emrtd/ec.ts +196 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/mrzKey.ts +21 -1
- package/src/emrtd/open.ts +169 -0
- package/src/emrtd/pace-params.ts +169 -0
- package/src/emrtd/pace.ts +295 -0
- package/src/emrtd/secureMessaging.ts +32 -14
- package/src/emrtd/session.ts +124 -20
- package/src/emrtd/suites.ts +99 -0
- package/src/index.ts +1 -0
- package/src/lib/step-log.ts +43 -0
- package/src/lib/step-window.ts +96 -0
- package/src/liveness/useLiveness.ts +1 -1
- package/src/screens/ApplicantRoleStep.tsx +1 -10
- package/src/screens/BusinessKeyPeopleStep.tsx +101 -45
- package/src/screens/IdTypeStep.tsx +15 -2
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/{BusinessKeyPersonRow.tsx → KeyPersonForm.tsx} +35 -57
- package/src/screens/KeyPersonSheet.tsx +184 -0
- package/src/screens/NfcStep.tsx +12 -0
- package/src/screens/QuestionnaireField.tsx +30 -0
- package/src/screens/QuestionnaireStep.tsx +3 -1
- package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
- package/src/services/deviceMetadata.ts +9 -1
- package/src/store/derive.ts +7 -0
- package/src/store/kycStore.ts +25 -1
- package/src/types/config.ts +17 -0
- package/src/types/workflow.ts +10 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing, useAnimatedValue, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from './runtime';
|
|
5
|
+
|
|
6
|
+
// The quiet alternative to StepIndicator: a single thin bar sitting ON the
|
|
7
|
+
// header's bottom edge, replacing its border rather than adding a row beneath
|
|
8
|
+
// it — so choosing it costs the header no height at all.
|
|
9
|
+
//
|
|
10
|
+
// Unlike the step circles it does not say WHICH step you are on or how many
|
|
11
|
+
// there are, which is the trade: it is unaffected by step count, so a 14-step
|
|
12
|
+
// KYB flow draws exactly like a 4-step one. Hosts who would rather the chrome
|
|
13
|
+
// said less opt in with `progressStyle: 'bar'`.
|
|
14
|
+
|
|
15
|
+
export interface ProgressBarProps {
|
|
16
|
+
/** 0.0–1.0 progress fraction. */
|
|
17
|
+
progress: number;
|
|
18
|
+
/** Steps in the flow — announced, not drawn. */
|
|
19
|
+
stepCount: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Thickness of the bar.
|
|
24
|
+
*
|
|
25
|
+
* 5, not the 1px border it replaces: at hairline weight it read as a rendering
|
|
26
|
+
* artefact rather than a deliberate indicator, and the filled portion needs
|
|
27
|
+
* enough body for its colour to register against the track at a glance.
|
|
28
|
+
*/
|
|
29
|
+
const HEIGHT = 5;
|
|
30
|
+
|
|
31
|
+
export function ProgressBar({ progress, stepCount }: ProgressBarProps): React.ReactElement {
|
|
32
|
+
const { colors } = useTheme();
|
|
33
|
+
const fraction = Math.min(Math.max(progress, 0), 1);
|
|
34
|
+
const step = Math.min(Math.max(Math.round(fraction * stepCount), 1), stepCount);
|
|
35
|
+
|
|
36
|
+
// Animated so advancing a step reads as movement rather than a jump — the
|
|
37
|
+
// motion IS the feedback that the step was accepted. 250ms sits inside the
|
|
38
|
+
// 150–300ms micro-interaction band; ease-out because it is entering.
|
|
39
|
+
const width = useAnimatedValue(fraction);
|
|
40
|
+
const first = useRef(true);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (first.current) {
|
|
43
|
+
// Don't animate the initial mount from 0 — the flow may be resumed
|
|
44
|
+
// mid-way, and a bar sweeping in from empty would misreport where the
|
|
45
|
+
// user actually is.
|
|
46
|
+
first.current = false;
|
|
47
|
+
width.setValue(fraction);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
Animated.timing(width, {
|
|
51
|
+
toValue: fraction,
|
|
52
|
+
duration: 250,
|
|
53
|
+
easing: Easing.out(Easing.cubic),
|
|
54
|
+
// Width cannot be driven natively, and a scaleX transform would squash
|
|
55
|
+
// the rounded cap. The bar is one small view, so the JS driver is fine.
|
|
56
|
+
useNativeDriver: false,
|
|
57
|
+
}).start();
|
|
58
|
+
}, [fraction, width]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
style={{
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
left: 0,
|
|
65
|
+
right: 0,
|
|
66
|
+
bottom: 0,
|
|
67
|
+
height: HEIGHT,
|
|
68
|
+
// The track doubles as the header's bottom border, which is why the
|
|
69
|
+
// header drops its own when this is shown.
|
|
70
|
+
backgroundColor: colors.border,
|
|
71
|
+
}}
|
|
72
|
+
accessible
|
|
73
|
+
accessibilityRole="progressbar"
|
|
74
|
+
accessibilityLabel={`Step ${step} of ${stepCount}`}
|
|
75
|
+
accessibilityValue={{ min: 1, max: stepCount, now: step }}
|
|
76
|
+
>
|
|
77
|
+
<Animated.View
|
|
78
|
+
style={{
|
|
79
|
+
height: HEIGHT,
|
|
80
|
+
backgroundColor: colors.primary,
|
|
81
|
+
borderTopRightRadius: HEIGHT / 2,
|
|
82
|
+
borderBottomRightRadius: HEIGHT / 2,
|
|
83
|
+
width: width.interpolate({
|
|
84
|
+
inputRange: [0, 1],
|
|
85
|
+
outputRange: ['0%', '100%'],
|
|
86
|
+
}),
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { View } from 'react-native';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { PixelRatio, useWindowDimensions, View, type LayoutChangeEvent } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { spacing } from '../config/theme';
|
|
5
|
+
import { fitStepCircles, windowedSteps, type StepSlot } from '../lib/step-window';
|
|
5
6
|
import { useTheme } from './runtime';
|
|
6
7
|
import { MyazaText } from './Typography';
|
|
7
8
|
import { Icon } from './Icon';
|
|
8
9
|
|
|
9
10
|
// Segmented numbered step indicator — 1:1 with the Flutter SDK's _StepIndicator.
|
|
10
|
-
// N circles connected by thin lines:
|
|
11
|
-
// • completed → filled primary +
|
|
12
|
-
// • active → filled primary +
|
|
11
|
+
// N numbered circles connected by thin lines:
|
|
12
|
+
// • completed → filled primary + number + a check BADGE
|
|
13
|
+
// • active → filled primary + number
|
|
13
14
|
// • upcoming → outlined (primary200) + muted number
|
|
14
15
|
// activeIndex = round(progress * stepCount) - 1.
|
|
16
|
+
//
|
|
17
|
+
// Long flows COLLAPSE rather than overflow, but only as a LAST RESORT: the row
|
|
18
|
+
// measures itself and shows as many real circles as the width allows, windowing
|
|
19
|
+
// around the current step (first and last always shown) only once the connectors
|
|
20
|
+
// would stop reading as a chain. See lib/step-window.
|
|
15
21
|
|
|
16
22
|
export interface StepIndicatorProps {
|
|
17
23
|
/** 0.0–1.0 progress fraction. */
|
|
@@ -19,47 +25,155 @@ export interface StepIndicatorProps {
|
|
|
19
25
|
stepCount: number;
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
/** Base circle size, before text scaling. */
|
|
29
|
+
const CIRCLE = 26;
|
|
30
|
+
|
|
22
31
|
export function StepIndicator({ progress, stepCount }: StepIndicatorProps): React.ReactElement {
|
|
23
32
|
const { colors } = useTheme();
|
|
24
33
|
const active = Math.round(progress * stepCount) - 1;
|
|
25
34
|
|
|
35
|
+
// Grow the circle with the system text size, or the number inside it clips
|
|
36
|
+
// the moment a user turns Dynamic Type up — the circle was a hard 26px while
|
|
37
|
+
// the label scaled freely. Capped at 1.4: past that the row matters less than
|
|
38
|
+
// the step content below it, and an indicator that pushes the title off screen
|
|
39
|
+
// helps nobody.
|
|
40
|
+
const scale = Math.min(PixelRatio.getFontScale(), 1.4);
|
|
41
|
+
const size = Math.round(CIRCLE * scale);
|
|
42
|
+
const badge = Math.round(13 * scale);
|
|
43
|
+
|
|
44
|
+
// How many circles actually fit. Measured rather than assumed, so a flow
|
|
45
|
+
// collapses on a narrow phone and stays whole on a wide one instead of both
|
|
46
|
+
// obeying the same hardcoded cap. The window width is the first estimate — it
|
|
47
|
+
// is right on the first frame, which avoids rendering a collapsed row and then
|
|
48
|
+
// snapping to a full one — and onLayout corrects it if the row is inset by
|
|
49
|
+
// anything this does not know about.
|
|
50
|
+
const { width: screenWidth } = useWindowDimensions();
|
|
51
|
+
const [measured, setMeasured] = useState(0);
|
|
52
|
+
const rowWidth = measured || Math.max(0, screenWidth - spacing.md * 2);
|
|
53
|
+
const slots = windowedSteps(stepCount, active, fitStepCircles(rowWidth, size));
|
|
54
|
+
|
|
55
|
+
const onLayout = (e: LayoutChangeEvent): void => {
|
|
56
|
+
const w = e.nativeEvent.layout.width - spacing.md * 2;
|
|
57
|
+
// Only react to real changes (rotation, split screen). Setting state on
|
|
58
|
+
// every layout pass would re-render the row for nothing.
|
|
59
|
+
setMeasured((prev) => (Math.abs(prev - w) > 1 ? w : prev));
|
|
60
|
+
};
|
|
61
|
+
|
|
26
62
|
return (
|
|
27
|
-
<View
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
<View
|
|
64
|
+
style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md }}
|
|
65
|
+
onLayout={onLayout}
|
|
66
|
+
// ONE label for the whole row. Previously a screen reader walked ten
|
|
67
|
+
// unlabelled circles and announced a bare "6", which says nothing about
|
|
68
|
+
// how far through the flow that is.
|
|
69
|
+
accessible
|
|
70
|
+
accessibilityRole="progressbar"
|
|
71
|
+
accessibilityLabel={`Step ${Math.min(Math.max(active + 1, 1), stepCount)} of ${stepCount}`}
|
|
72
|
+
accessibilityValue={{
|
|
73
|
+
min: 1,
|
|
74
|
+
max: stepCount,
|
|
75
|
+
now: Math.min(Math.max(active + 1, 1), stepCount),
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{slots.map((slot: StepSlot, position) => {
|
|
79
|
+
const isEllipsis = slot === 'ellipsis';
|
|
80
|
+
const completed = !isEllipsis && slot < active;
|
|
81
|
+
const isActive = !isEllipsis && slot === active;
|
|
31
82
|
const filled = completed || isActive;
|
|
32
83
|
return (
|
|
33
|
-
<React.Fragment key={
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
borderWidth: 1.5,
|
|
43
|
-
borderColor: filled ? colors.primary : colors.primary200,
|
|
44
|
-
}}
|
|
45
|
-
>
|
|
46
|
-
{completed ? (
|
|
47
|
-
<Icon name="check" size={14} color="#FFFFFF" />
|
|
48
|
-
) : (
|
|
84
|
+
<React.Fragment key={isEllipsis ? `gap-${position}` : `step-${slot}`}>
|
|
85
|
+
{isEllipsis ? (
|
|
86
|
+
// Collapsed run. Sized to the circle's height so the connectors on
|
|
87
|
+
// either side stay on the same centre line and the chain reads as
|
|
88
|
+
// continuous rather than broken in two.
|
|
89
|
+
<View
|
|
90
|
+
style={{ height: size, justifyContent: 'center', paddingHorizontal: 2 }}
|
|
91
|
+
importantForAccessibility="no"
|
|
92
|
+
>
|
|
49
93
|
<MyazaText
|
|
50
94
|
variant="bodySmall"
|
|
51
|
-
color={
|
|
52
|
-
style={{ fontSize:
|
|
95
|
+
color={colors.textMuted}
|
|
96
|
+
style={{ fontSize: 13, letterSpacing: 1 }}
|
|
97
|
+
allowFontScaling={false}
|
|
53
98
|
>
|
|
54
|
-
|
|
99
|
+
···
|
|
55
100
|
</MyazaText>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
101
|
+
</View>
|
|
102
|
+
) : (
|
|
103
|
+
// Positioning context for the badge, which straddles the circle's
|
|
104
|
+
// edge. Nothing here clips, so the overhang renders.
|
|
105
|
+
<View style={{ width: size, height: size }} importantForAccessibility="no">
|
|
106
|
+
<View
|
|
107
|
+
style={{
|
|
108
|
+
width: size,
|
|
109
|
+
height: size,
|
|
110
|
+
borderRadius: size / 2,
|
|
111
|
+
alignItems: 'center',
|
|
112
|
+
justifyContent: 'center',
|
|
113
|
+
backgroundColor: filled ? colors.primary : 'transparent',
|
|
114
|
+
borderWidth: 1.5,
|
|
115
|
+
borderColor: filled ? colors.primary : colors.primary200,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{/* The NUMBER stays, completed or not. A check alone says a
|
|
119
|
+
step is done but not WHICH step — and once the row is
|
|
120
|
+
windowed ("1 ··· 5 6 7 ··· 10") that is precisely what the
|
|
121
|
+
numbers are there to answer. */}
|
|
122
|
+
<MyazaText
|
|
123
|
+
variant="bodySmall"
|
|
124
|
+
color={filled ? '#FFFFFF' : colors.textMuted}
|
|
125
|
+
style={{ fontSize: 12, fontWeight: '700' }}
|
|
126
|
+
// The circle already scales with the system text size, so
|
|
127
|
+
// scaling the glyph again would overflow it.
|
|
128
|
+
allowFontScaling={false}
|
|
129
|
+
>
|
|
130
|
+
{slot + 1}
|
|
131
|
+
</MyazaText>
|
|
132
|
+
</View>
|
|
133
|
+
|
|
134
|
+
{/* Completion rides as a badge tucked onto the circle's corner.
|
|
135
|
+
The ring is WHITE — the same colour as the number inside the
|
|
136
|
+
circle — because the badge sits on the circle, not on the
|
|
137
|
+
page. Ringing it in the page background (which the chip
|
|
138
|
+
portrait's CheckBadge does, correctly, because it sits on a
|
|
139
|
+
photo) painted a near-black blob around the check on the dark
|
|
140
|
+
theme, where background is #040218. */}
|
|
141
|
+
{completed ? (
|
|
142
|
+
<View
|
|
143
|
+
style={{
|
|
144
|
+
position: 'absolute',
|
|
145
|
+
top: -3,
|
|
146
|
+
right: -2,
|
|
147
|
+
width: badge,
|
|
148
|
+
height: badge,
|
|
149
|
+
borderRadius: badge / 2,
|
|
150
|
+
backgroundColor: colors.success,
|
|
151
|
+
// 1px, not 1.5: on a 13px badge a 1.5px ring eats nearly a
|
|
152
|
+
// quarter of the diameter and reads as a white outline
|
|
153
|
+
// rather than a hairline separating badge from circle.
|
|
154
|
+
borderWidth: 1,
|
|
155
|
+
borderColor: '#FFFFFF',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
justifyContent: 'center',
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<Icon name="check" size={Math.round(badge * 0.6)} color="#FFFFFF" />
|
|
161
|
+
</View>
|
|
162
|
+
) : null}
|
|
163
|
+
</View>
|
|
164
|
+
)}
|
|
165
|
+
{position < slots.length - 1 ? (
|
|
59
166
|
<View
|
|
60
167
|
style={{
|
|
61
168
|
flex: 1,
|
|
169
|
+
// Floor, so an unforeseen overflow degrades to a visibly short
|
|
170
|
+
// connector rather than a row of circles with no chain at all.
|
|
171
|
+
minWidth: 6,
|
|
62
172
|
height: 2,
|
|
173
|
+
// UNIFORM, deliberately: fitStepCircles budgets 3+3 per
|
|
174
|
+
// connector, so widening this for completed steps made the row
|
|
175
|
+
// need more space than was reserved. The badge is tucked in
|
|
176
|
+
// far enough (right: -2) not to need the extra.
|
|
63
177
|
marginHorizontal: 3,
|
|
64
178
|
borderRadius: 1,
|
|
65
179
|
backgroundColor: completed ? colors.primary : colors.primary200,
|
package/src/components/fonts.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import type { TextStyle } from 'react-native';
|
|
1
2
|
import { useFonts } from 'expo-font';
|
|
2
3
|
import { SpaceGrotesk_500Medium, SpaceGrotesk_600SemiBold, SpaceGrotesk_700Bold } from '@expo-google-fonts/space-grotesk';
|
|
3
4
|
import { Karla_400Regular, Karla_500Medium, Karla_600SemiBold, Karla_700Bold } from '@expo-google-fonts/karla';
|
|
5
|
+
import { fontFamilyFor } from '../config/font-resolve';
|
|
6
|
+
import { useTheme } from './theme-provider';
|
|
7
|
+
|
|
4
8
|
export { fontFamilyFor, markFamilyName, brandFamilyName, BRAND_WEIGHTS } from '../config/font-resolve';
|
|
5
9
|
|
|
6
10
|
|
|
@@ -30,3 +34,22 @@ export function useMyazaFonts(): boolean {
|
|
|
30
34
|
* Returns `undefined` until fonts are loaded so text falls back to the system
|
|
31
35
|
* font + `fontWeight` without triggering an "unrecognized font" warning.
|
|
32
36
|
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The body font family for a TEXT INPUT, at the given weight.
|
|
40
|
+
*
|
|
41
|
+
* `TextInput` does not inherit `fontFamily` from any ancestor the way web
|
|
42
|
+
* inputs inherit from a stylesheet — RN resolves it per-element — so an input
|
|
43
|
+
* that never sets one renders in the system face while every `MyazaText`
|
|
44
|
+
* beside it renders in the brand's. The placeholder follows the input's own
|
|
45
|
+
* family too, which is why it looked wrong as well.
|
|
46
|
+
*
|
|
47
|
+
* Goes through the same resolver as MyazaText so an org's uploaded brand font
|
|
48
|
+
* reaches inputs, not just text. Returns undefined until fonts load, which is
|
|
49
|
+
* the caller's cue to leave `fontFamily` unset rather than name a family the
|
|
50
|
+
* platform has not registered.
|
|
51
|
+
*/
|
|
52
|
+
export function useInputFontFamily(weight: TextStyle['fontWeight'] = '400'): string | undefined {
|
|
53
|
+
const { fontsLoaded, brandFonts } = useTheme();
|
|
54
|
+
return fontFamilyFor(false, weight, fontsLoaded, brandFonts);
|
|
55
|
+
}
|
package/src/config/keyPeople.ts
CHANGED
|
@@ -106,6 +106,16 @@ export function keyPeoplePayload(
|
|
|
106
106
|
}));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** "Richard Ingwe" → "RI" — the avatar monogram used on person cards. */
|
|
110
|
+
export function initialsOf(name: string): string {
|
|
111
|
+
return name
|
|
112
|
+
.trim()
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.slice(0, 2)
|
|
115
|
+
.map((t) => t[0]?.toUpperCase() ?? '')
|
|
116
|
+
.join('');
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
/** Split a typed full name into first/last (best-effort). */
|
|
110
120
|
export function splitFullName(name: string): { firstName?: string; lastName?: string } | undefined {
|
|
111
121
|
const trimmed = name.trim();
|
|
@@ -39,17 +39,24 @@ export function currencyKeyFor(field: QuestionnaireField): string {
|
|
|
39
39
|
return `${field.key}_currency`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** The companion key holding what an "Other" choice actually was. */
|
|
43
|
+
export function otherKeyFor(field: QuestionnaireField): string {
|
|
44
|
+
return `${field.key}_other`;
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
/**
|
|
43
|
-
* Every answer key a definition can produce — money questions contribute two
|
|
44
|
-
* The server uses the same
|
|
45
|
-
*
|
|
46
|
-
* rather than mapping
|
|
48
|
+
* Every answer key a definition can produce — money questions contribute two,
|
|
49
|
+
* and so does a choice question offering an "Other". The server uses the same
|
|
50
|
+
* expansion to cross-check decision-graph references, so anything reasoning
|
|
51
|
+
* about "the keys this questionnaire yields" must use it rather than mapping
|
|
52
|
+
* over `fields`.
|
|
47
53
|
*/
|
|
48
54
|
export function questionnaireAnswerKeys(questionnaire: QuestionnaireConfig | undefined): string[] {
|
|
49
55
|
const keys: string[] = [];
|
|
50
56
|
for (const field of questionnaire?.fields ?? []) {
|
|
51
57
|
keys.push(field.key);
|
|
52
58
|
if (field.type === 'money') keys.push(currencyKeyFor(field));
|
|
59
|
+
if ((field.options ?? []).some((o) => o.requiresDetail)) keys.push(otherKeyFor(field));
|
|
53
60
|
}
|
|
54
61
|
return keys;
|
|
55
62
|
}
|
|
@@ -78,6 +85,23 @@ export function validateQuestionnaire(
|
|
|
78
85
|
continue;
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
// An "Other" choice obliges a description, whether or not the question
|
|
89
|
+
// itself is required: an unexplained "Other" is the answer that most needs
|
|
90
|
+
// explaining. Checked before the per-type rules so it applies to select and
|
|
91
|
+
// multiselect alike.
|
|
92
|
+
const detailOption = (field.options ?? []).find(
|
|
93
|
+
(o) =>
|
|
94
|
+
o.requiresDetail &&
|
|
95
|
+
(Array.isArray(value) ? (value as string[]).includes(o.value) : value === o.value),
|
|
96
|
+
);
|
|
97
|
+
if (detailOption) {
|
|
98
|
+
const detail = answers[`${field.key}_other`];
|
|
99
|
+
if (typeof detail !== 'string' || !detail.trim()) {
|
|
100
|
+
errors[field.key] = `Tell us more about "${detailOption.label}".`;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
if (field.type === 'number' || field.type === 'money') {
|
|
82
106
|
const num = Number(value);
|
|
83
107
|
if (!Number.isFinite(num) || (field.type === 'money' && num < 0)) {
|
|
@@ -147,6 +171,23 @@ export function questionnairePayload(
|
|
|
147
171
|
}
|
|
148
172
|
|
|
149
173
|
payload[field.key] = value as QuestionnaireAnswerValue;
|
|
174
|
+
|
|
175
|
+
// The description behind an "Other" choice. Carried explicitly for the same
|
|
176
|
+
// reason `_currency` is: this builds the request from the FIELD LIST, so a
|
|
177
|
+
// companion answer that is not named here is silently dropped — the user
|
|
178
|
+
// types it, the client accepts it, and the server then rejects the
|
|
179
|
+
// submission for a missing detail it was never sent.
|
|
180
|
+
const detailOption = (field.options ?? []).find(
|
|
181
|
+
(o) =>
|
|
182
|
+
o.requiresDetail &&
|
|
183
|
+
(Array.isArray(value) ? (value as string[]).includes(o.value) : value === o.value),
|
|
184
|
+
);
|
|
185
|
+
if (detailOption) {
|
|
186
|
+
const detail = answers[otherKeyFor(field)];
|
|
187
|
+
if (typeof detail === 'string' && detail.trim() !== '') {
|
|
188
|
+
payload[otherKeyFor(field)] = detail.trim();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
150
191
|
}
|
|
151
192
|
|
|
152
193
|
return payload;
|
package/src/emrtd/crypto.ts
CHANGED
|
@@ -99,7 +99,7 @@ export function deriveKey(
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
// The MRZ-derived key lives in ./mrzKey; re-exported so callers keep one import.
|
|
102
|
-
export { keySeed, mrzKeySeedInput, type MrzKeyFields } from './mrzKey';
|
|
102
|
+
export { keySeed, paceKeySeed, mrzKeySeedInput, type MrzKeyFields } from './mrzKey';
|
|
103
103
|
|
|
104
104
|
/** The pair of session keys derived from a seed. */
|
|
105
105
|
export interface SessionKeys {
|
package/src/emrtd/dg1.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { fromBase64 } from './bytes';
|
|
2
|
+
import { findTlvDeep } from './der';
|
|
3
|
+
import { parseMrz, type MrzScan } from '../mrz/parse';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// THE CHIP'S OWN MRZ.
|
|
7
|
+
//
|
|
8
|
+
// DG1 is the machine-readable zone exactly as the ISSUING STATE wrote it, and
|
|
9
|
+
// passive authentication hashes it against the signed security object. It is
|
|
10
|
+
// the strongest copy of the holder's details that exists on the document.
|
|
11
|
+
//
|
|
12
|
+
// The camera scan is a GUESS at the same characters, and it is not a safe
|
|
13
|
+
// substitute for display. Measured on a real Nigerian passport read on a Galaxy
|
|
14
|
+
// S24: the on-device recogniser read the first '<' of the '<<' surname
|
|
15
|
+
// separator as 'K', so `INGWE<<RICHARD<UNIMKE` arrived as `INGWEK<RICHARD…`.
|
|
16
|
+
// The split then never fired at the surname boundary and landed in the trailing
|
|
17
|
+
// filler instead, yielding lastName "INGWEK RICHARD UNIMKE" and firstName
|
|
18
|
+
// "KKKK" — displayed under a caption promising we had read the secure chip.
|
|
19
|
+
//
|
|
20
|
+
// TD3 carries NO check digit over the name field (only line 2 is protected), so
|
|
21
|
+
// that corruption passes `parseMrz` validation silently. There is no way to
|
|
22
|
+
// detect it from the scan alone, which is exactly why the chip has to be the
|
|
23
|
+
// source once we hold it.
|
|
24
|
+
//
|
|
25
|
+
// Reuses `parseMrz` rather than parsing here: DG1's value IS the continuous
|
|
26
|
+
// 88/90-character string that function already takes, and a second MRZ parser
|
|
27
|
+
// would be one more thing to drift.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** DG1's MRZ lives under tag 5F1F, inside the 0x61 template. */
|
|
31
|
+
const TAG_MRZ = 0x5f1f;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The MRZ read off the chip, or null when DG1 is absent, malformed, or not a
|
|
35
|
+
* size `parseMrz` recognises.
|
|
36
|
+
*
|
|
37
|
+
* Null is a normal outcome the caller falls back from, never an error: the
|
|
38
|
+
* read itself already succeeded and its bytes still go to the server, which
|
|
39
|
+
* parses DG1 authoritatively regardless of what this returns.
|
|
40
|
+
*/
|
|
41
|
+
export function parseDg1(dg1Base64: string | undefined | null): MrzScan | null {
|
|
42
|
+
if (!dg1Base64) return null;
|
|
43
|
+
try {
|
|
44
|
+
const bytes = fromBase64(dg1Base64);
|
|
45
|
+
const mrz = findTlvDeep(bytes, TAG_MRZ);
|
|
46
|
+
if (!mrz) return null;
|
|
47
|
+
let text = '';
|
|
48
|
+
for (const byte of mrz.value) text += String.fromCharCode(byte);
|
|
49
|
+
return parseMrz(text);
|
|
50
|
+
} catch {
|
|
51
|
+
// Display-path only. A malformed DG1 must never take down the success
|
|
52
|
+
// screen of a read that otherwise worked.
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Standardised elliptic-curve domain parameters (ICAO 9303 / BSI TR-03110).
|
|
3
|
+
//
|
|
4
|
+
// A chip names its PACE curve by a standardised parameter id rather than
|
|
5
|
+
// sending the parameters, so both sides must agree on this table. Every curve
|
|
6
|
+
// here is a prime-field short-Weierstrass curve (y² = x³ + ax + b), so one set
|
|
7
|
+
// of point arithmetic (ec.ts) covers all of them.
|
|
8
|
+
//
|
|
9
|
+
// The constants are transcribed from the authoritative sources — NIST FIPS
|
|
10
|
+
// 186-4 for the secp/P- curves and RFC 5639 for the brainpool curves — and are
|
|
11
|
+
// verified in ec.test.ts, which asserts G lies on each curve and that n·G is
|
|
12
|
+
// the point at infinity. A transcription typo fails that test rather than a
|
|
13
|
+
// passport.
|
|
14
|
+
//
|
|
15
|
+
// The six curves below are the ones issued eMRTDs actually use. The other
|
|
16
|
+
// standardised ids (the 192/224-bit curves, DH groups) are left unmapped: an
|
|
17
|
+
// unknown id resolves to null, which the caller treats exactly like a chip that
|
|
18
|
+
// does not offer PACE — it reads over BAC. So the coverage gap can only ever
|
|
19
|
+
// cost a fallback, never a failed read.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** A prime-field short-Weierstrass curve and its generator. */
|
|
23
|
+
export interface EcCurve {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
/** Field prime. */
|
|
26
|
+
readonly p: bigint;
|
|
27
|
+
readonly a: bigint;
|
|
28
|
+
readonly b: bigint;
|
|
29
|
+
/** Generator coordinates. */
|
|
30
|
+
readonly gx: bigint;
|
|
31
|
+
readonly gy: bigint;
|
|
32
|
+
/** Group order. */
|
|
33
|
+
readonly n: bigint;
|
|
34
|
+
/** Field width in bytes — the fixed length x/y coordinates encode to. */
|
|
35
|
+
readonly byteLen: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hex = (s: string): bigint => BigInt(`0x${s.replace(/\s+/g, '')}`);
|
|
39
|
+
|
|
40
|
+
function curve(
|
|
41
|
+
name: string,
|
|
42
|
+
byteLen: number,
|
|
43
|
+
p: string,
|
|
44
|
+
a: string,
|
|
45
|
+
b: string,
|
|
46
|
+
gx: string,
|
|
47
|
+
gy: string,
|
|
48
|
+
n: string,
|
|
49
|
+
): EcCurve {
|
|
50
|
+
return { name, byteLen, p: hex(p), a: hex(a), b: hex(b), gx: hex(gx), gy: hex(gy), n: hex(n) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const secp256r1 = curve(
|
|
54
|
+
'secp256r1',
|
|
55
|
+
32,
|
|
56
|
+
'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF',
|
|
57
|
+
'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC',
|
|
58
|
+
'5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B',
|
|
59
|
+
'6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296',
|
|
60
|
+
'4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5',
|
|
61
|
+
'FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551',
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const brainpoolP256r1 = curve(
|
|
65
|
+
'brainpoolP256r1',
|
|
66
|
+
32,
|
|
67
|
+
'A9FB57DBA1EEA9BC3E660A909D838D726E3BF623D52620282013481D1F6E5377',
|
|
68
|
+
'7D5A0975FC2C3057EEF67530417AFFE7FB8055C126DC5C6CE94A4B44F330B5D9',
|
|
69
|
+
'26DC5C6CE94A4B44F330B5D9BBD77CBF958416295CF7E1CE6BCCDC18FF8C07B6',
|
|
70
|
+
'8BD2AEB9CB7E57CB2C4B482FFC81B7AFB9DE27E1E3BD23C23A4453BD9ACE3262',
|
|
71
|
+
'547EF835C3DAC4FD97F8461A14611DC9C27745132DED8E545C1D54C72F046997',
|
|
72
|
+
'A9FB57DBA1EEA9BC3E660A909D838D718C397AA3B561A6F7901E0E82974856A7',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const secp384r1 = curve(
|
|
76
|
+
'secp384r1',
|
|
77
|
+
48,
|
|
78
|
+
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFF',
|
|
79
|
+
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFC',
|
|
80
|
+
'B3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF',
|
|
81
|
+
'AA87CA22BE8B05378EB1C71EF320AD746E1D3B628BA79B9859F741E082542A385502F25DBF55296C3A545E3872760AB7',
|
|
82
|
+
'3617DE4A96262C6F5D9E98BF9292DC29F8F41DBD289A147CE9DA3113B5F0B8C00A60B1CE1D7E819D7A431D7C90EA0E5F',
|
|
83
|
+
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973',
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const brainpoolP384r1 = curve(
|
|
87
|
+
'brainpoolP384r1',
|
|
88
|
+
48,
|
|
89
|
+
'8CB91E82A3386D280F5D6F7E50E641DF152F7109ED5456B412B1DA197FB71123ACD3A729901D1A71874700133107EC53',
|
|
90
|
+
'7BC382C63D8C150C3C72080ACE05AFA0C2BEA28E4FB22787139165EFBA91F90F8AA5814A503AD4EB04A8C7DD22CE2826',
|
|
91
|
+
'04A8C7DD22CE28268B39B55416F0447C2FB77DE107DCD2A62E880EA53EEB62D57CB4390295DBC9943AB78696FA504C11',
|
|
92
|
+
'1D1C64F068CF45FFA2A63A81B7C13F6B8847A3E77EF14FE3DB7FCAFE0CBD10E8E826E03436D646AAEF87B2E247D4AF1E',
|
|
93
|
+
'8ABE1D7520F9C2A45CB1EB8E95CFD55262B70B29FEEC5864E19C054FF99129280E4646217791811142820341263C5315',
|
|
94
|
+
'8CB91E82A3386D280F5D6F7E50E641DF152F7109ED5456B31F166E6CAC0425A7CF3AB6AF6B7FC3103B883202E9046565',
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const brainpoolP512r1 = curve(
|
|
98
|
+
'brainpoolP512r1',
|
|
99
|
+
64,
|
|
100
|
+
'AADD9DB8DBE9C48B3FD4E6AE33C9FC07CB308DB3B3C9D20ED6639CCA703308717D4D9B009BC66842AECDA12AE6A380E62881FF2F2D82C68528AA6056583A48F3',
|
|
101
|
+
'7830A3318B603B89E2327145AC234CC594CBDD8D3DF91610A83441CAEA9863BC2DED5D5AA8253AA10A2EF1C98B9AC8B57F1117A72BF2C7B9E7C1AC4D77FC94CA',
|
|
102
|
+
'3DF91610A83441CAEA9863BC2DED5D5AA8253AA10A2EF1C98B9AC8B57F1117A72BF2C7B9E7C1AC4D77FC94CADC083E67984050B75EBAE5DD2809BD638016F723',
|
|
103
|
+
'81AEE4BDD82ED9645A21322E9C4C6A9385ED9F70B5D916C1B43B62EEF4D0098EFF3B1F78E2D0D48D50D1687B93B97D5F7C6D5047406A5E688B352209BCB9F822',
|
|
104
|
+
'7DDE385D566332ECC0EABFA9CF7822FDF209F70024A57B1AA000C55B881F8111B2DCDE494A5F485E5BCA4BD88A2763AED1CA2B2FA8F0540678CD1E0F3AD80892',
|
|
105
|
+
'AADD9DB8DBE9C48B3FD4E6AE33C9FC07CB308DB3B3C9D20ED6639CCA70330870553E5C414CA92619418661197FAC10471DB1D381085DDADDB58796829CA90069',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const secp521r1 = curve(
|
|
109
|
+
'secp521r1',
|
|
110
|
+
66,
|
|
111
|
+
'01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
|
|
112
|
+
'01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC',
|
|
113
|
+
'0051953EB9618E1C9A1F929A21A0B68540EEA2DA725B99B315F3B8B489918EF109E156193951EC7E937B1652C0BD3BB1BF073573DF883D2C34F1EF451FD46B503F00',
|
|
114
|
+
'00C6858E06B70404E9CD9E3ECB662395B4429C648139053FB521F828AF606B4D3DBAA14B5E77EFE75928FE1DC127A2FFA8DE3348B3C1856A429BF97E7E31C2E5BD66',
|
|
115
|
+
'011839296A789A3BC0045C8A5FB42C7D1BD998F54449579B446817AFBD17273E662C97EE72995EF42640C550B9013FAD0761353C7086A272C24088BE94769FD16650',
|
|
116
|
+
'01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFA51868783BF2F966B7FCC0148F709A5D03BB5C9B8899C47AEBB6FB71E91386409',
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/** ICAO standardised parameter id → curve, for the curves this build runs. */
|
|
120
|
+
const BY_ID: Record<number, EcCurve> = {
|
|
121
|
+
12: secp256r1,
|
|
122
|
+
13: brainpoolP256r1,
|
|
123
|
+
15: secp384r1,
|
|
124
|
+
16: brainpoolP384r1,
|
|
125
|
+
17: brainpoolP512r1,
|
|
126
|
+
18: secp521r1,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** The curve for a standardised parameter id, or null when unmapped. */
|
|
130
|
+
export function curveForParameterId(id: number): EcCurve | null {
|
|
131
|
+
return BY_ID[id] ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Exposed for tests — every curve this build claims to support. */
|
|
135
|
+
export const ALL_CURVES: readonly EcCurve[] = [
|
|
136
|
+
secp256r1,
|
|
137
|
+
brainpoolP256r1,
|
|
138
|
+
secp384r1,
|
|
139
|
+
brainpoolP384r1,
|
|
140
|
+
brainpoolP512r1,
|
|
141
|
+
secp521r1,
|
|
142
|
+
];
|