@myazahq/kyc-sdk-react-native 2.2.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.
@@ -0,0 +1,295 @@
1
+ import { concat, timingSafeEqual, unpadFromBlock } from './bytes';
2
+ import { encodeTlv, findTlv, readTlv } from './der';
3
+ import type { EcCurve } from './ec-curves';
4
+ import {
5
+ bigIntToBytes,
6
+ bytesToBigInt,
7
+ decodePoint,
8
+ encodePoint,
9
+ generator,
10
+ pointAdd,
11
+ pointMultiply,
12
+ randomScalar,
13
+ type EcPoint,
14
+ } from './ec';
15
+ import type { EmrtdPrimitives } from './crypto';
16
+ import { SecureMessagingSession } from './secureMessaging';
17
+ import type { PaceProtocol } from './pace-params';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // PACE, Generic Mapping over elliptic curves (ICAO 9303 Part 11 §4.4).
21
+ //
22
+ // The newer way into a chip. BAC derives its keys straight from the MRZ, so an
23
+ // attacker who photographs the passport page can decrypt a recorded session
24
+ // forever. PACE uses the MRZ only to unlock a fresh random nonce, then runs a
25
+ // Diffie-Hellman exchange over a generator derived from it — so the session
26
+ // keys are new every time and the MRZ alone never reveals them.
27
+ //
28
+ // The handshake is four exchanges after a setup command:
29
+ //
30
+ // 0. MSE:Set AT name the protocol and which password we hold
31
+ // 1. get nonce chip sends a nonce encrypted under the password key
32
+ // 2. map nonce both sides contribute a key; the nonce and the shared
33
+ // secret combine into a fresh generator
34
+ // 3. key agreement a second exchange over THAT generator gives the session
35
+ // secret, and the session keys derive from it
36
+ // 4. mutual auth each side proves it derived the same keys, by MACing
37
+ // the other's public key
38
+ //
39
+ // Steps 1-3 are sent with the command-chaining class byte; step 4 closes the
40
+ // chain. Getting that wrong makes a chip abandon the handshake midway.
41
+ //
42
+ // Mirrors the Flutter SDK's emrtd_pace.dart step for step, so a chip that reads
43
+ // on one platform reads on the other.
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export class PaceError extends Error {
47
+ constructor(
48
+ message: string,
49
+ /**
50
+ * `auth_failed` when the chip refuses the password, which in practice
51
+ * always means a wrong MRZ. Anything else is a protocol or transport fault.
52
+ */
53
+ readonly code: 'auth_failed' | 'read_failed',
54
+ ) {
55
+ super(message);
56
+ this.name = 'PaceError';
57
+ Object.setPrototypeOf(this, PaceError.prototype);
58
+ }
59
+ }
60
+
61
+ /** Data objects inside the dynamic authentication template, by step. */
62
+ const DO = {
63
+ template: 0x7c,
64
+ encryptedNonce: 0x80,
65
+ mappingCommand: 0x81,
66
+ mappingResponse: 0x82,
67
+ keyCommand: 0x83,
68
+ keyResponse: 0x84,
69
+ tokenCommand: 0x85,
70
+ tokenResponse: 0x86,
71
+ publicKey: 0x7f49,
72
+ objectIdentifier: 0x06,
73
+ ecPoint: 0x86,
74
+ } as const;
75
+
76
+ const SW_OK = 0x9000;
77
+
78
+ /** Sends one raw APDU and returns the response data with its status word. */
79
+ export type PaceTransceive = (
80
+ command: Uint8Array,
81
+ ) => Promise<{ data: Uint8Array; statusWord: number }>;
82
+
83
+ /**
84
+ * Run PACE-GM over an elliptic curve and return the secured session.
85
+ *
86
+ * `passwordKey` is the MRZ-derived password key (counter 3), NOT a session key
87
+ * — PACE uses the MRZ only to unlock the chip's nonce.
88
+ */
89
+ export async function runPaceEcdhGm(options: {
90
+ p: EmrtdPrimitives;
91
+ transceive: PaceTransceive;
92
+ protocol: PaceProtocol;
93
+ curve: EcCurve;
94
+ passwordKey: Uint8Array;
95
+ /** Injectable for tests; production draws real randomness. */
96
+ fixed?: { mapPrivate?: bigint; sessionPrivate?: bigint };
97
+ }): Promise<SecureMessagingSession> {
98
+ const { p, transceive, protocol, curve, passwordKey } = options;
99
+ const suite = protocol.suite;
100
+
101
+ // Step 0 — announce the protocol and which password we are using. No Le:
102
+ // MSE:Set AT returns no data, and appending one makes it a case-4 command
103
+ // the chip answers 0x6700 "wrong length" to.
104
+ await exchange(
105
+ transceive,
106
+ concat(
107
+ new Uint8Array([0x00, 0x22, 0xc1, 0xa4]),
108
+ body(
109
+ [encodeTlv(0x80, protocol.oid), encodeTlv(0x83, new Uint8Array([0x01]))],
110
+ false,
111
+ ),
112
+ ),
113
+ 'select',
114
+ );
115
+
116
+ // Step 1 — the chip's nonce, encrypted under the password key. A chip that
117
+ // refuses here has decided the password is wrong.
118
+ const nonceReply = await generalAuthenticate(
119
+ transceive,
120
+ encodeTlv(DO.template, new Uint8Array(0)),
121
+ false,
122
+ 'nonce',
123
+ );
124
+ // No unpadding: the nonce is whole blocks of random, not a padded message.
125
+ const nonce = suite.decrypt(p, passwordKey, expect(nonceReply, DO.encryptedNonce, 'nonce'));
126
+
127
+ // Step 2 — map the nonce onto a fresh generator. Each side sends an ephemeral
128
+ // public key; the shared point plus the nonce give a generator neither side
129
+ // chose alone.
130
+ const mapPrivate = options.fixed?.mapPrivate ?? randomScalar(curve, p.randomBytes);
131
+ const mapPublic = pointMultiply(curve, mapPrivate, generator(curve));
132
+ const mapReply = await generalAuthenticate(
133
+ transceive,
134
+ encodeTlv(DO.template, encodeTlv(DO.mappingCommand, encodePoint(curve, mapPublic))),
135
+ false,
136
+ 'mapping',
137
+ );
138
+ const chipMapPoint = decodeChipPoint(curve, expect(mapReply, DO.mappingResponse, 'mapping'));
139
+
140
+ const shared = pointMultiply(curve, mapPrivate, chipMapPoint);
141
+ const mappedGenerator = pointAdd(
142
+ curve,
143
+ pointMultiply(curve, bytesToBigInt(nonce) % curve.n, generator(curve)),
144
+ shared,
145
+ );
146
+ if (mappedGenerator.inf) {
147
+ throw new PaceError('The mapped generator was degenerate.', 'read_failed');
148
+ }
149
+
150
+ // Step 3 — the real key agreement, over the mapped generator.
151
+ const sessionPrivate = options.fixed?.sessionPrivate ?? randomScalar(curve, p.randomBytes);
152
+ const sessionPublic = pointMultiply(curve, sessionPrivate, mappedGenerator);
153
+ const keyReply = await generalAuthenticate(
154
+ transceive,
155
+ encodeTlv(DO.template, encodeTlv(DO.keyCommand, encodePoint(curve, sessionPublic))),
156
+ false,
157
+ 'key agreement',
158
+ );
159
+ const chipSessionPoint = decodeChipPoint(
160
+ curve,
161
+ expect(keyReply, DO.keyResponse, 'key agreement'),
162
+ );
163
+
164
+ // Identical public keys mean the exchange contributed nothing.
165
+ if (chipSessionPoint.x === sessionPublic.x && chipSessionPoint.y === sessionPublic.y) {
166
+ throw new PaceError('The chip echoed our public key.', 'read_failed');
167
+ }
168
+
169
+ const agreed = pointMultiply(curve, sessionPrivate, chipSessionPoint);
170
+ if (agreed.inf) {
171
+ throw new PaceError('Key agreement produced no secret.', 'read_failed');
172
+ }
173
+ // The shared secret is the agreed point's x coordinate, left-padded to the
174
+ // curve's field width: a short value must not shorten the key derivation.
175
+ const secret = bigIntToBytes(agreed.x, curve.byteLen);
176
+
177
+ const ksEnc = suite.deriveKey(p, secret, 1);
178
+ const ksMac = suite.deriveKey(p, secret, 2);
179
+
180
+ // Step 4 — each side MACs the OTHER side's public key. Matching tokens prove
181
+ // both derived the same session keys without either revealing them.
182
+ const ourToken = suite.token(p, ksMac, tokenInput(curve, protocol.oid, chipSessionPoint));
183
+ const tokenReply = await generalAuthenticate(
184
+ transceive,
185
+ encodeTlv(DO.template, encodeTlv(DO.tokenCommand, ourToken)),
186
+ true,
187
+ 'authentication',
188
+ );
189
+ const chipToken = expect(tokenReply, DO.tokenResponse, 'authentication');
190
+ const expected = suite.token(p, ksMac, tokenInput(curve, protocol.oid, sessionPublic));
191
+ if (!timingSafeEqual(chipToken, expected)) {
192
+ throw new PaceError(
193
+ 'The chip proved a different key. The document details do not match.',
194
+ 'auth_failed',
195
+ );
196
+ }
197
+
198
+ // A PACE session starts its counter at zero, unlike BAC's nonce-derived one.
199
+ return new SecureMessagingSession(p, { ksEnc, ksMac }, new Uint8Array(suite.blockSize), suite);
200
+ }
201
+
202
+ /**
203
+ * GENERAL AUTHENTICATE. Steps before the last are sent with the chaining class
204
+ * byte, which tells the chip more of the same command is coming.
205
+ */
206
+ async function generalAuthenticate(
207
+ transceive: PaceTransceive,
208
+ data: Uint8Array,
209
+ last: boolean,
210
+ step: string,
211
+ ): Promise<Uint8Array> {
212
+ const command = concat(
213
+ new Uint8Array([last ? 0x00 : 0x10, 0x86, 0x00, 0x00]),
214
+ body([data], true),
215
+ );
216
+ const response = await exchange(transceive, command, step);
217
+ const template = readTlv(response);
218
+ if (!template || template.tag !== DO.template) {
219
+ throw new PaceError(`The ${step} reply was not authentication data.`, 'read_failed');
220
+ }
221
+ return template.value;
222
+ }
223
+
224
+ async function exchange(
225
+ transceive: PaceTransceive,
226
+ command: Uint8Array,
227
+ step: string,
228
+ ): Promise<Uint8Array> {
229
+ const { data, statusWord } = await transceive(command);
230
+ if (statusWord !== SW_OK) {
231
+ // 0x6300 and 0x63CX are how chips say the password was wrong; some older
232
+ // ones use the latter specifically for bad session data.
233
+ const wrongPassword = statusWord === 0x6300 || (statusWord & 0xfff0) === 0x63c0;
234
+ throw new PaceError(
235
+ // The STEP is in both messages on purpose. A refusal at 'authentication'
236
+ // means our token did not match, which points at the password key
237
+ // derivation; one at 'nonce' means the chip would not even start. Without
238
+ // the step both read as "wrong MRZ" and send debugging the wrong way —
239
+ // which is exactly what happened the first time this ran against a real
240
+ // passport.
241
+ wrongPassword
242
+ ? `The chip rejected the document details at the ${step} step.`
243
+ : `The chip returned ${statusWord.toString(16).padStart(4, '0')} at the ${step} step.`,
244
+ wrongPassword ? 'auth_failed' : 'read_failed',
245
+ );
246
+ }
247
+ return data;
248
+ }
249
+
250
+ function expect(template: Uint8Array, tag: number, step: string): Uint8Array {
251
+ const found = findTlv(template, tag);
252
+ if (!found) {
253
+ throw new PaceError(`The ${step} reply was missing its data.`, 'read_failed');
254
+ }
255
+ return found.value;
256
+ }
257
+
258
+ /** A chip-supplied point, validated before any arithmetic touches it. */
259
+ function decodeChipPoint(curve: EcCurve, bytes: Uint8Array): EcPoint {
260
+ const pt = decodePoint(curve, bytes);
261
+ // A point off the curve or at infinity would leak our private scalar to a
262
+ // chip that chose it deliberately, so it is refused rather than used.
263
+ if (!pt) throw new PaceError('The chip sent an invalid key.', 'read_failed');
264
+ return pt;
265
+ }
266
+
267
+ /**
268
+ * The object each side MACs to prove it holds the session keys: the protocol
269
+ * identifier and the other party's public point.
270
+ */
271
+ function tokenInput(curve: EcCurve, oid: Uint8Array, pt: EcPoint): Uint8Array {
272
+ return encodeTlv(
273
+ DO.publicKey,
274
+ concat(encodeTlv(DO.objectIdentifier, oid), encodeTlv(DO.ecPoint, encodePoint(curve, pt))),
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Lc, body, and Le only when the command actually returns data.
280
+ *
281
+ * ISO 7816 distinguishes a command that sends data (case 3) from one that
282
+ * sends and receives (case 4) by the presence of that trailing byte, and a
283
+ * chip asked for a response it has none of replies 0x6700.
284
+ */
285
+ function body(parts: Uint8Array[], expectsResponse: boolean): Uint8Array {
286
+ const joined = concat(...parts);
287
+ return concat(
288
+ new Uint8Array([joined.length]),
289
+ joined,
290
+ expectsResponse ? new Uint8Array([0x00]) : new Uint8Array(0),
291
+ );
292
+ }
293
+
294
+ /** Re-exported so the session can strip padding from a PACE-decrypted body. */
295
+ export { unpadFromBlock };
@@ -1,13 +1,7 @@
1
1
  import { concat, padToBlock, timingSafeEqual, unpadFromBlock } from './bytes';
2
2
  import { encodeTlv, readTlvSequence } from './der';
3
- import {
4
- BLOCK_SIZE,
5
- decrypt3Des,
6
- encrypt3Des,
7
- macWithPadding,
8
- type EmrtdPrimitives,
9
- type SessionKeys,
10
- } from './crypto';
3
+ import type { EmrtdPrimitives, SessionKeys } from './crypto';
4
+ import { DES_EDE2_SUITE, type CipherSuite } from './suites';
11
5
 
12
6
  // ---------------------------------------------------------------------------
13
7
  // Secure messaging (ICAO 9303 Part 11 §9.8).
@@ -61,6 +55,13 @@ export class SecureMessagingSession {
61
55
  private readonly p: EmrtdPrimitives,
62
56
  private readonly keys: SessionKeys,
63
57
  initialSsc: Uint8Array,
58
+ /**
59
+ * Which cipher the session runs on. BAC is always 3DES (the default, so
60
+ * every existing call site is unchanged); a PACE session may have
61
+ * negotiated AES, which changes the block size, the padding unit, the
62
+ * counter width and how the IV is derived.
63
+ */
64
+ private readonly suite: CipherSuite = DES_EDE2_SUITE,
64
65
  ) {
65
66
  this.ssc = new Uint8Array(initialSsc);
66
67
  }
@@ -80,15 +81,21 @@ export class SecureMessagingSession {
80
81
  // The class byte gains the secure-messaging bits, and the MAC is computed
81
82
  // over the PADDED header — a detail the standard is easy to misread.
82
83
  const cla = apdu.cla | 0x0c;
84
+ const block = this.suite.blockSize;
83
85
  const header = padToBlock(
84
86
  new Uint8Array([cla, apdu.ins, apdu.p1, apdu.p2]),
85
- BLOCK_SIZE,
87
+ block,
86
88
  );
87
89
 
88
90
  const parts: Uint8Array[] = [];
89
91
 
90
92
  if (apdu.data && apdu.data.length > 0) {
91
- const encrypted = encrypt3Des(this.p, this.keys.ksEnc, padToBlock(apdu.data, BLOCK_SIZE));
93
+ const encrypted = this.suite.encrypt(
94
+ this.p,
95
+ this.keys.ksEnc,
96
+ padToBlock(apdu.data, block),
97
+ this.ssc,
98
+ );
92
99
  // The leading 0x01 is the padding-content indicator: "ISO 9797-1 method
93
100
  // 2 was used". Omitting it is a common source of chips rejecting DO'87'.
94
101
  parts.push(encodeTlv(DO87, concat(new Uint8Array([0x01]), encrypted)));
@@ -103,7 +110,11 @@ export class SecureMessagingSession {
103
110
  }
104
111
 
105
112
  const body = concat(...parts);
106
- const mac = macWithPadding(this.p, this.keys.ksMac, concat(this.ssc, header, body));
113
+ const mac = this.suite.mac(
114
+ this.p,
115
+ this.keys.ksMac,
116
+ padToBlock(concat(this.ssc, header, body), block),
117
+ );
107
118
  const checksum = encodeTlv(DO8E, mac);
108
119
 
109
120
  const payload = concat(body, checksum);
@@ -140,7 +151,11 @@ export class SecureMessagingSession {
140
151
  this.ssc,
141
152
  ...objects.map((o) => encodeTlv(o.tag, o.value)),
142
153
  );
143
- const expected = macWithPadding(this.p, this.keys.ksMac, macInput);
154
+ const expected = this.suite.mac(
155
+ this.p,
156
+ this.keys.ksMac,
157
+ padToBlock(macInput, this.suite.blockSize),
158
+ );
144
159
  if (!timingSafeEqual(expected, checksum.value)) {
145
160
  throw new SecureMessagingError('The response failed its integrity check.');
146
161
  }
@@ -161,12 +176,15 @@ export class SecureMessagingSession {
161
176
  // does not.
162
177
  const body =
163
178
  encrypted.tag === DO87 ? encrypted.value.subarray(1) : encrypted.value;
164
- if (body.length === 0 || body.length % BLOCK_SIZE !== 0) {
179
+ if (body.length === 0 || body.length % this.suite.blockSize !== 0) {
165
180
  throw new SecureMessagingError('The response ciphertext was misaligned.');
166
181
  }
167
182
 
168
183
  return {
169
- data: unpadFromBlock(decrypt3Des(this.p, this.keys.ksEnc, body), BLOCK_SIZE),
184
+ data: unpadFromBlock(
185
+ this.suite.decrypt(this.p, this.keys.ksEnc, body, this.ssc),
186
+ this.suite.blockSize,
187
+ ),
170
188
  statusWord,
171
189
  };
172
190
  }
@@ -3,6 +3,7 @@ import { buildBacChallenge, completeBac } from './bac';
3
3
  import { primitivesFromNative, type EmrtdPrimitives, type MrzKeyFields } from './crypto';
4
4
  import { EF, readFile, type Transceive } from './files';
5
5
  import { readOptionalFile } from './optionalRead';
6
+ import { PaceError, PREFER_PACE, tryPace, type PaceOutcome } from './open';
6
7
  import { SecureMessagingSession } from './secureMessaging';
7
8
  import type { NfcReadStage } from './stages';
8
9
 
@@ -44,8 +45,15 @@ export interface EmrtdReadResult {
44
45
  sod?: string;
45
46
  /** DG2 — the portrait. Best-effort; the largest file and the likeliest to drop. */
46
47
  dg2?: string;
47
- /** How the chip was unlocked. */
48
- chipAuth: 'bac';
48
+ /** How the chip was unlocked. Reported to the server on the submission. */
49
+ chipAuth: 'bac' | 'pace';
50
+ /**
51
+ * Why the session is on that protocol, and the negotiated variant when PACE
52
+ * ran. Diagnostics only: it never changes the read, and it is what
53
+ * distinguishes "the chip does not speak PACE" from "our PACE failed".
54
+ */
55
+ paceOutcome?: PaceOutcome;
56
+ paceDetail?: string;
49
57
  }
50
58
 
51
59
  /** The raw transport, before secure messaging wraps it. */
@@ -137,23 +145,8 @@ export async function readChip(
137
145
  const p = primitivesFromNative(native);
138
146
 
139
147
  onStage?.('authenticating');
140
- // SELECT + BAC are retried ONCE in place, on the same live connection.
141
- // On Android the tag is routinely dispatched while the platform is still
142
- // settling the link, so the first exchange dies with the chip right there —
143
- // and an immediate second attempt succeeds with the phone untouched. The
144
- // Flutter SDK documents and fixes this exact failure the same way
145
- // (nfc_reader_emrtd.dart); a full session teardown cannot fix it, because
146
- // Android never re-dispatches a tag that stayed in the field, so the outer
147
- // retry loop just waited for a re-tap nobody knew to perform.
148
- let sm: Awaited<ReturnType<typeof openSession>> | null = null;
149
- for (let attempt = 0; sm === null; attempt += 1) {
150
- try {
151
- await selectApplication(native);
152
- sm = await openSession(p, native, mrz);
153
- } catch (err) {
154
- if (attempt >= 1) throw err;
155
- }
156
- }
148
+ const access = await establishSession(p, native, mrz);
149
+ const sm = access.sm;
157
150
 
158
151
  const transceive: Transceive = async (command) => {
159
152
  const { data, statusWord } = await native.transceive(toBase64(command));
@@ -195,6 +188,117 @@ export async function readChip(
195
188
  dg1: toBase64(dg1),
196
189
  ...(sod ? { sod: toBase64(sod) } : {}),
197
190
  ...(dg2 ? { dg2: toBase64(dg2) } : {}),
198
- chipAuth: 'bac',
191
+ chipAuth: access.chipAuth,
192
+ paceOutcome: access.outcome,
193
+ ...(access.detail ? { paceDetail: access.detail } : {}),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Open a secured session, trying both access protocols as needed.
199
+ *
200
+ * The ordering lives in open.ts (PREFER_PACE) along with why it is what it is.
201
+ * Whichever protocol goes first, the other still runs as the fallback, so a
202
+ * document that read before still reads.
203
+ */
204
+ async function establishSession(
205
+ p: EmrtdPrimitives,
206
+ native: EmrtdTransport,
207
+ mrz: MrzKeyFields,
208
+ ): Promise<{
209
+ sm: SecureMessagingSession;
210
+ chipAuth: 'bac' | 'pace';
211
+ outcome: PaceOutcome;
212
+ detail?: string;
213
+ }> {
214
+ // SELECT + BAC are retried ONCE in place, on the same live connection.
215
+ // On Android the tag is routinely dispatched while the platform is still
216
+ // settling the link, so the first exchange dies with the chip right there —
217
+ // and an immediate second attempt succeeds with the phone untouched. The
218
+ // Flutter SDK documents and fixes this exact failure the same way
219
+ // (nfc_reader_emrtd.dart); a full session teardown cannot fix it, because
220
+ // Android never re-dispatches a tag that stayed in the field, so the outer
221
+ // retry loop just waited for a re-tap nobody knew to perform.
222
+ const bac = async (): Promise<SecureMessagingSession> => {
223
+ let sm: SecureMessagingSession | null = null;
224
+ for (let attempt = 0; sm === null; attempt += 1) {
225
+ try {
226
+ await selectApplication(native);
227
+ sm = await openSession(p, native, mrz);
228
+ } catch (err) {
229
+ if (attempt >= 1) throw err;
230
+ }
231
+ }
232
+ return sm;
233
+ };
234
+
235
+ // PACE leaves the chip mid-protocol when it fails, so BAC after a failed
236
+ // PACE runs against a chip in an unknown state. Both orders below therefore
237
+ // re-SELECT the application first, which bac() already does.
238
+ const pace = async (): Promise<
239
+ { sm: SecureMessagingSession; detail: string } | { outcome: PaceOutcome; detail?: string }
240
+ > => {
241
+ try {
242
+ return await tryPace(p, native, mrz);
243
+ } catch (err) {
244
+ // A chip that fails PACE may still answer BAC, so this is never fatal
245
+ // by itself — only the reason is kept.
246
+ const detail =
247
+ err instanceof PaceError ? `${err.code}: ${err.message}` : String(err ?? '');
248
+ return { outcome: 'failed', detail };
249
+ }
199
250
  };
251
+
252
+ if (PREFER_PACE) {
253
+ const attempted = await pace();
254
+ if ('sm' in attempted) {
255
+ // After PACE the application must be selected through the secure channel.
256
+ await selectApplicationSecure(attempted.sm, native);
257
+ return { sm: attempted.sm, chipAuth: 'pace', outcome: 'used', detail: attempted.detail };
258
+ }
259
+ return { sm: await bac(), chipAuth: 'bac', outcome: attempted.outcome, detail: attempted.detail };
260
+ }
261
+
262
+ try {
263
+ return { sm: await bac(), chipAuth: 'bac', outcome: 'notAttempted' };
264
+ } catch (bacFailure) {
265
+ // BAC was refused. A chip that has retired it may still open with PACE, and
266
+ // trying costs one exchange against a document that has otherwise failed.
267
+ const attempted = await pace();
268
+ if ('sm' in attempted) {
269
+ await selectApplicationSecure(attempted.sm, native);
270
+ return { sm: attempted.sm, chipAuth: 'pace', outcome: 'used', detail: attempted.detail };
271
+ }
272
+ // Both refused. The BAC failure is the one the user is told about: its
273
+ // message already says the document details did not match.
274
+ throw bacFailure;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * SELECT the eMRTD application through an established PACE channel.
280
+ *
281
+ * PACE authenticates at the Master File, so the application still has to be
282
+ * selected afterwards — and now every command is wrapped, so it goes through
283
+ * secure messaging rather than the raw transport.
284
+ */
285
+ async function selectApplicationSecure(
286
+ sm: SecureMessagingSession,
287
+ transport: EmrtdTransport,
288
+ ): Promise<void> {
289
+ const wrapped = sm.protect({ cla: 0x00, ins: 0xa4, p1: 0x04, p2: 0x0c, data: AID });
290
+ const { data, statusWord } = await transport.transceive(toBase64(wrapped));
291
+ const body = fromBase64(data);
292
+ const framed = new Uint8Array(body.length + 2);
293
+ framed.set(body);
294
+ framed[body.length] = (statusWord >> 8) & 0xff;
295
+ framed[body.length + 1] = statusWord & 0xff;
296
+
297
+ const unwrapped = sm.unprotect(framed);
298
+ if (unwrapped.statusWord !== SW_OK) {
299
+ throw new EmrtdSessionError(
300
+ 'The secured session did not hold when selecting the passport application.',
301
+ 'select_failed',
302
+ );
303
+ }
200
304
  }
@@ -0,0 +1,99 @@
1
+ import { concat, padToBlock } from './bytes';
2
+ import {
3
+ adjustParity,
4
+ decrypt3Des,
5
+ deriveDigestSha1,
6
+ encrypt3Des,
7
+ macWithPadding,
8
+ retailMac,
9
+ type EmrtdPrimitives,
10
+ } from './crypto';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Cipher suites (ICAO 9303 Part 11 §9.7 and §9.8).
14
+ //
15
+ // BAC always runs two-key 3DES with the ISO 9797-1 retail MAC. PACE negotiates:
16
+ // it may use that same suite, or AES at 128, 192 or 256 bits with CMAC.
17
+ //
18
+ // They differ in more than the cipher, which is why this is one abstraction
19
+ // rather than a flag:
20
+ //
21
+ // • AES has a 16-byte block, so the padding unit AND the send-sequence
22
+ // counter are 16 bytes too.
23
+ // • AES derives a fresh IV per message by encrypting the counter, where 3DES
24
+ // uses a zero IV throughout.
25
+ // • AES-192/256 key derivation moves from SHA-1 to SHA-256, because SHA-1
26
+ // does not produce enough output.
27
+ // • The PACE authentication token pads for the retail MAC but must NOT be
28
+ // pre-padded for CMAC, which pads internally and takes a different branch
29
+ // for exact block multiples.
30
+ //
31
+ // Each of those, gotten wrong, produces a session that handshakes cleanly and
32
+ // then fails on the first real command. So they are expressed here once.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface CipherSuite {
36
+ readonly name: string;
37
+ /** Cipher block size — also the padding unit and the SSC width. */
38
+ readonly blockSize: number;
39
+ /** Derived session-key length in bytes. */
40
+ readonly keyLength: number;
41
+ /** ICAO §9.7.1 key derivation: hash the secret with a 4-byte counter. */
42
+ deriveKey(p: EmrtdPrimitives, secret: Uint8Array, counter: number): Uint8Array;
43
+ /** `ssc` is present only for AES, which derives its IV from it. */
44
+ encrypt(p: EmrtdPrimitives, key: Uint8Array, padded: Uint8Array, ssc?: Uint8Array): Uint8Array;
45
+ decrypt(p: EmrtdPrimitives, key: Uint8Array, data: Uint8Array, ssc?: Uint8Array): Uint8Array;
46
+ /** MAC over data the caller has already padded. */
47
+ mac(p: EmrtdPrimitives, key: Uint8Array, data: Uint8Array): Uint8Array;
48
+ /** The PACE authentication token over an UNPADDED encoding. */
49
+ token(p: EmrtdPrimitives, key: Uint8Array, data: Uint8Array): Uint8Array;
50
+ }
51
+
52
+ /** Two-key 3DES with the retail MAC: what BAC always uses. */
53
+ export const DES_EDE2_SUITE: CipherSuite = {
54
+ name: '3DES',
55
+ blockSize: 8,
56
+ keyLength: 16,
57
+ deriveKey: (p, secret, counter) =>
58
+ adjustParity(deriveDigestSha1(p, secret, counter).subarray(0, 16)),
59
+ encrypt: (p, key, padded) => encrypt3Des(p, key, padded),
60
+ decrypt: (p, key, data) => decrypt3Des(p, key, data),
61
+ mac: (p, key, data) => retailMac(p, key, data),
62
+ token: (p, key, data) => macWithPadding(p, key, data),
63
+ };
64
+
65
+ /** AES-CBC with CMAC, at 128, 192 or 256 bits. Only PACE reaches this. */
66
+ export function aesSuite(keyLength: 16 | 24 | 32): CipherSuite {
67
+ return {
68
+ name: `AES-${keyLength * 8}`,
69
+ blockSize: 16,
70
+ keyLength,
71
+ deriveKey: (p, secret, counter) =>
72
+ // AES-128 keeps SHA-1; the longer keys need SHA-256 for enough output.
73
+ // No parity adjustment: that is a DES-only convention, and applying it to
74
+ // an AES key silently produces a different key.
75
+ keyLength === 16
76
+ ? deriveDigestSha1(p, secret, counter).subarray(0, 16)
77
+ : p.sha256(concat(secret, new Uint8Array([0, 0, 0, counter]))).subarray(0, keyLength),
78
+ encrypt: (p, key, padded, ssc) => p.aesCbc(key, padded, aesIv(p, key, ssc), true),
79
+ decrypt: (p, key, data, ssc) => p.aesCbc(key, data, aesIv(p, key, ssc), false),
80
+ // ICAO truncates the 16-byte CMAC to its leading 8 bytes.
81
+ mac: (p, key, data) => p.aesCmac(key, data).subarray(0, 8),
82
+ token: (p, key, data) => p.aesCmac(key, data).subarray(0, 8),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The per-message IV: AES encrypts the send-sequence counter with the session
88
+ * key, so two identical commands never produce identical ciphertext. A zero IV
89
+ * here (the 3DES convention) would leak that they were the same.
90
+ */
91
+ function aesIv(p: EmrtdPrimitives, key: Uint8Array, ssc?: Uint8Array): Uint8Array {
92
+ if (!ssc) return new Uint8Array(16);
93
+ return p.aesCbc(key, ssc, new Uint8Array(16), true);
94
+ }
95
+
96
+ /** Pad for this suite's block size (ISO 9797-1 method 2). */
97
+ export function padForSuite(suite: CipherSuite, data: Uint8Array): Uint8Array {
98
+ return padToBlock(data, suite.blockSize);
99
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export type {
24
24
  KYCConsentContent,
25
25
  KYCSuccessContent,
26
26
  VoiceGuidanceConfig,
27
+ ProgressStyle,
27
28
  VoiceGuidanceOption,
28
29
  // Workflow-driven blocks — normally authored in the dashboard builder and
29
30
  // delivered by `workflowId`, but settable as props too.