ciesign-react-native 0.1.0 → 0.2.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 CHANGED
@@ -66,7 +66,7 @@ Inputs and outputs are `Uint8Array`. The promise stays pending until the card is
66
66
 
67
67
  | Export | |
68
68
  |---|---|
69
- | `cieSession(pin, use, { prompt?, error?, savePin?, biometricPrompt?, biometricCancel? })` | Waits for the card and verifies the PIN, then resolves to what `use({ certificate, sign })` resolves to. The NFC session ends when `use` settles: the card must stay on the phone until then. `pin` is the 8-digit PIN or `SAVED_PIN`. `prompt` and `error` are the iOS NFC sheet texts; the others are explained under [Saved PIN](#saved-pin). |
69
+ | `cieSession(pin, use, { prompt?, error?, messages?, onStep?, savePin?, biometricPrompt?, biometricCancel? })` | Waits for the card and verifies the PIN, then resolves to what `use({ certificate, sign })` resolves to. The NFC session ends when `use` settles: the card must stay on the phone until then. `pin` is the 8-digit PIN or `SAVED_PIN`. `prompt` and `error` are the iOS NFC sheet texts while waiting for the card and on failure; `messages` and `onStep` are explained under [Progress](#progress), the others under [Saved PIN](#saved-pin). |
70
70
  | `certificate` | The card's DER authentication certificate: holder name and fiscal code are in its subject. |
71
71
  | `sign(digestInfo)` | RSA PKCS#1 v1.5 signature (the card adds the padding) of a DER DigestInfo, checked against `certificate`. Call it as often as needed while `use` runs; calls run one at a time, in order. |
72
72
  | `SAVED_PIN`, `pinStatus()`, `forgetPin()` | See [Saved PIN](#saved-pin). |
@@ -93,6 +93,27 @@ A PIN that is not 8 digits throws a plain `Error`. Card errors carry a `code`:
93
93
 
94
94
  On Android the app must stay in the foreground while the card is read.
95
95
 
96
+ ### Progress
97
+
98
+ A session goes through these steps. On iOS the NFC sheet shows the text of each one; `messages` overrides any of them. `onStep(step)` is called as each one is reached, for example to show progress in your own UI on Android, which has no NFC sheet.
99
+
100
+ | Step | When | Default iOS sheet text |
101
+ |---|---|---|
102
+ | `detected` | The card is on the phone. | CIE rilevata, non allontanarla |
103
+ | `pin` | The PIN is being verified. | Verifica del PIN… |
104
+ | `ready` | PIN verified and certificate read: `use` starts. | PIN verificato |
105
+ | `signing` | A signature starts, once per `sign`. | Firma in corso… |
106
+ | `done` | `use` resolved and the session is over. The sheet shows it next to its checkmark. | Operazione completata |
107
+
108
+ ```ts
109
+ const signatures = await cieSession(pin, use, {
110
+ messages: { done: 'Verbale firmato' },
111
+ onStep: (step) => setStatus(step),
112
+ });
113
+ ```
114
+
115
+ Steps only move forward. A failed session stops at the last step it reached and rejects; on iOS the sheet shows `error`.
116
+
96
117
  ### Saved PIN
97
118
 
98
119
  Optional: apps that always pass the PIN don't need any of this. With `savePin`, the typed PIN is saved once the card accepts it. Later operations pass `SAVED_PIN`, which asks for Face ID, Touch ID or a fingerprint instead of the PIN.
@@ -33,8 +33,11 @@ class CieSignModule : Module() {
33
33
  override fun definition() = ModuleDefinition {
34
34
  Name("CieSign")
35
35
 
36
+ Events("onStep")
37
+
36
38
  // A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Both show the biometric prompt first.
37
- AsyncFunction("connect") { id: Int, pin: String?, save: Boolean, _: String, biometricPrompt: String, biometricCancel: String, promise: Promise ->
39
+ // The messages are for the iOS NFC sheet: Android has none.
40
+ AsyncFunction("connect") { id: Int, pin: String?, save: Boolean, _: Map<String, String>, biometricPrompt: String, biometricCancel: String, promise: Promise ->
38
41
  val activity = appContext.currentActivity ?: throw CodedException("NFC_UNAVAILABLE", "No foreground activity", null)
39
42
  val adapter = NfcAdapter.getDefaultAdapter(activity)
40
43
  if (adapter?.isEnabled != true) throw CodedException("NFC_UNAVAILABLE", "NFC is missing or turned off", null)
@@ -77,7 +80,7 @@ class CieSignModule : Module() {
77
80
  }
78
81
  }
79
82
 
80
- Function("close") { id: Int, _: String? ->
83
+ Function("close") { id: Int, _: String?, _: String? ->
81
84
  synchronized(lock) {
82
85
  closed = maxOf(closed, id)
83
86
  if (session in 1..id) end()
@@ -144,6 +147,7 @@ class CieSignModule : Module() {
144
147
  }
145
148
  tag = isoDep
146
149
  }
150
+ step(id, "detected")
147
151
  val c = CieCard(object : OnTransmit {
148
152
  override fun sendCommand(apdu: ByteArray, nfcEvents: NfcEvent): ApduResponse {
149
153
  val response = isoDep.transceive(apdu)
@@ -155,6 +159,7 @@ class CieSignModule : Module() {
155
159
  })
156
160
  val nis = c.serviceId()
157
161
  if (owner != null && owner != nis) throw CodedException("WRONG_CARD", "Not the card the PIN was saved for", null)
162
+ step(id, "pin")
158
163
  val certificate = c.open(pin)
159
164
  if (sealed != null) savedPin.store(nis, sealed)
160
165
  synchronized(lock) {
@@ -169,6 +174,8 @@ class CieSignModule : Module() {
169
174
  }
170
175
  }
171
176
 
177
+ private fun step(id: Int, step: String) = sendEvent("onStep", mapOf("id" to id, "step" to step))
178
+
172
179
  private fun end() {
173
180
  pending?.reject(cancelled())
174
181
  pending = null
package/build/index.d.ts CHANGED
@@ -6,13 +6,23 @@ export declare const SAVED_PIN: unique symbol;
6
6
  /** The full 8-digit PIN, or `SAVED_PIN`. */
7
7
  export type Pin = string | typeof SAVED_PIN;
8
8
  /**
9
- * `prompt`, `error`: texts of the iOS NFC sheet. `savePin`: once the card accepts the PIN, save it for `SAVED_PIN`.
10
- * `biometricPrompt`, `biometricCancel`: texts of the biometric prompt shown before the card is read, wherever the secure
11
- * hardware requires one: `SAVED_PIN` everywhere, `savePin` on Android only.
9
+ * Progress of a session, in order: `detected` the card is on the phone; `pin` the PIN is being verified; `ready` the PIN
10
+ * is verified and the certificate read, `use` starts; `signing` a signature starts, once per `sign`; `done` `use`
11
+ * resolved and the session is over.
12
+ */
13
+ export type CieStep = 'detected' | 'pin' | 'ready' | 'signing' | 'done';
14
+ /**
15
+ * `prompt`, `error`: texts of the iOS NFC sheet while it waits for the card and when the session fails; `messages`: its
16
+ * text at each step. `onStep`: called at each step, e.g. to show progress on Android, which has no NFC sheet.
17
+ * `savePin`: once the card accepts the PIN, save it for `SAVED_PIN`. `biometricPrompt`, `biometricCancel`: texts of the
18
+ * biometric prompt shown before the card is read, wherever the secure hardware requires one: `SAVED_PIN` everywhere,
19
+ * `savePin` on Android only.
12
20
  */
13
21
  export type CieOptions = {
14
22
  prompt?: string;
15
23
  error?: string;
24
+ messages?: Partial<Record<CieStep, string>>;
25
+ onStep?: (step: CieStep) => void;
16
26
  savePin?: boolean;
17
27
  biometricPrompt?: string;
18
28
  biometricCancel?: string;
package/build/index.js CHANGED
@@ -2,6 +2,14 @@ import { trimDer, verify } from './certificate';
2
2
  import Native, {} from './native';
3
3
  /** Pass instead of the PIN to use the one saved with `savePin`, unlocked with Face ID, Touch ID or a fingerprint. */
4
4
  export const SAVED_PIN = Symbol('SAVED_PIN');
5
+ const STEPS = ['detected', 'pin', 'ready', 'signing', 'done'];
6
+ const MESSAGES = {
7
+ detected: 'CIE rilevata, non allontanarla',
8
+ pin: 'Verifica del PIN…',
9
+ ready: 'PIN verificato',
10
+ signing: 'Firma in corso…',
11
+ done: 'Operazione completata',
12
+ };
5
13
  let session = 0; // id of the latest NFC session
6
14
  /**
7
15
  * Waits for the card and verifies the PIN, then runs `use`: every `sign` goes through this NFC session, which ends when
@@ -10,15 +18,29 @@ let session = 0; // id of the latest NFC session
10
18
  export async function cieSession(pin, use, options = {}) {
11
19
  if (pin !== SAVED_PIN && !/^\d{8}$/.test(pin))
12
20
  throw new Error('The CIE PIN has 8 digits');
13
- const { prompt = "Avvicina la CIE al retro dell'iPhone", error = 'Operazione non riuscita', savePin = false, biometricPrompt = pin === SAVED_PIN ? 'Sblocca il PIN della CIE' : 'Salva il PIN della CIE', biometricCancel = 'Annulla', } = options;
21
+ const { prompt = "Avvicina la CIE al retro dell'iPhone", error = 'Operazione non riuscita', savePin = false, biometricPrompt = pin === SAVED_PIN ? 'Sblocca il PIN della CIE' : 'Salva il PIN della CIE', biometricCancel = 'Annulla', onStep, } = options;
22
+ const message = (s) => options.messages?.[s] ?? MESSAGES[s];
14
23
  const id = ++session;
15
24
  let done = false;
25
+ // Native events can arrive after `ready`: steps only move forward, except one `signing` per signature.
26
+ let reached = -1;
27
+ const step = (s) => {
28
+ const i = STEPS.indexOf(s);
29
+ if (i < reached || (i === reached && s !== 'signing'))
30
+ return;
31
+ reached = i;
32
+ onStep?.(s);
33
+ };
34
+ const subscription = onStep && Native.addListener('onStep', (event) => event.id === id && step(event.step));
16
35
  try {
17
- const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, prompt, biometricPrompt, biometricCancel));
36
+ const sheet = { prompt, detected: message('detected'), pin: message('pin'), ready: message('ready'), signing: message('signing') };
37
+ const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, sheet, biometricPrompt, biometricCancel));
38
+ step('ready');
18
39
  let last = Promise.resolve();
19
40
  const sign = (digestInfo) => {
20
41
  const input = digestInfo.slice(); // signed later, once the calls before it finish
21
42
  const signature = last.then(async () => {
43
+ step('signing');
22
44
  const signature = await Native.sign(id, input);
23
45
  verify(certificate, input, signature);
24
46
  return signature;
@@ -31,11 +53,14 @@ export async function cieSession(pin, use, options = {}) {
31
53
  return result;
32
54
  }
33
55
  finally {
34
- Native.close(id, done ? null : error);
56
+ subscription?.remove();
57
+ Native.close(id, done ? null : error, done ? message('done') : null);
58
+ if (done)
59
+ step('done');
35
60
  }
36
61
  }
37
62
  /** Aborts the CIE session in progress. */
38
- export const cancel = () => Native.close(session, null);
63
+ export const cancel = () => Native.close(session, null, null);
39
64
  /** Whether a PIN is saved, without showing any UI. */
40
65
  export const pinStatus = () => Native.pinStatus();
41
66
  /** Deletes the saved PIN. */
package/build/native.d.ts CHANGED
@@ -1,18 +1,38 @@
1
1
  /** `saved`: a PIN is saved. `available`: a strong biometric is enrolled, so one can be. `unavailable`: neither. */
2
2
  export type PinStatus = 'saved' | 'available' | 'unavailable';
3
+ /** Steps only the native side sees: the card was detected; the PIN is being verified. */
4
+ export type NativeStep = 'detected' | 'pin';
5
+ /** iOS NFC sheet texts: `prompt` while waiting for the card, then one per step. */
6
+ export type NativeMessages = {
7
+ prompt: string;
8
+ detected: string;
9
+ pin: string;
10
+ ready: string;
11
+ signing: string;
12
+ };
3
13
  type CieSignModule = {
4
14
  /**
5
15
  * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
6
16
  * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
7
17
  * only where the secure hardware requires it: unlocking everywhere, saving on Android.
8
18
  */
9
- connect(id: number, pin: string | null, save: boolean, prompt: string, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
19
+ connect(id: number, pin: string | null, save: boolean, messages: NativeMessages, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
10
20
  /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
11
21
  sign(id: number, digestInfo: Uint8Array): Promise<Uint8Array>;
12
- /** Ends every session up to `id`, also one whose connect has not arrived yet; iOS shows `error` in the NFC sheet. */
13
- close(id: number, error: string | null): void;
22
+ /**
23
+ * Ends every session up to `id`, also one whose connect has not arrived yet. iOS shows `error` in the NFC sheet, or
24
+ * else `done` next to its checkmark.
25
+ */
26
+ close(id: number, error: string | null, done: string | null): void;
14
27
  pinStatus(): Promise<PinStatus>;
15
28
  forgetPin(): Promise<void>;
29
+ /** `onStep` fires as session `id` reaches a step only the native side sees. */
30
+ addListener(event: 'onStep', listener: (event: {
31
+ id: number;
32
+ step: NativeStep;
33
+ }) => void): {
34
+ remove(): void;
35
+ };
16
36
  };
17
37
  declare const _default: CieSignModule;
18
38
  export default _default;
@@ -10,15 +10,18 @@ public class CieSignModule: Module {
10
10
  public func definition() -> ModuleDefinition {
11
11
  Name("CieSign")
12
12
 
13
+ Events("onStep")
14
+
15
+ // `messages`: NFC sheet texts by step, `prompt` first.
13
16
  AsyncFunction("connect") {
14
- @MainActor (id: Int, pin: String?, save: Bool, prompt: String, biometricPrompt: String, biometricCancel: String) async throws -> Data in
17
+ @MainActor (id: Int, pin: String?, save: Bool, messages: [String: String], biometricPrompt: String, biometricCancel: String) async throws -> Data in
15
18
  guard NFCTagReaderSession.readingAvailable else { throw failure("NFC_UNAVAILABLE", "NFC tag reading is not available") }
16
19
  guard id > self.closed else { throw cancelled }
17
- self.reader?.close(nil)
18
- let reader = Reader(id: id)
20
+ self.reader?.close(nil, nil)
21
+ let reader = Reader(id: id, messages: messages, onStep: self.reporter(id))
19
22
  self.reader = reader
20
23
  do {
21
- return try await reader.connect(pin: pin, save: save, prompt: prompt, biometricPrompt: biometricPrompt, biometricCancel: biometricCancel)
24
+ return try await reader.connect(pin: pin, save: save, biometricPrompt: biometricPrompt, biometricCancel: biometricCancel)
22
25
  } catch {
23
26
  let error = coded(reader.invalidation ?? error)
24
27
  if pin == nil, let code = (error as? Exception)?.code, code.hasPrefix("WRONG_PIN") || code == "PIN_LOCKED" { SavedPin.delete() }
@@ -30,16 +33,17 @@ public class CieSignModule: Module {
30
33
  guard let reader = self.reader, reader.id == id else { throw cancelled }
31
34
  do {
32
35
  guard let card = reader.card else { throw cancelled } // the reason is in `invalidation`, e.g. a timeout
36
+ reader.show("signing")
33
37
  return try await card.sign(digestInfo)
34
38
  } catch {
35
39
  throw coded(reader.invalidation ?? error)
36
40
  }
37
41
  }
38
42
 
39
- AsyncFunction("close") { @MainActor (id: Int, error: String?) async in
43
+ AsyncFunction("close") { @MainActor (id: Int, error: String?, done: String?) async in
40
44
  self.closed = max(self.closed, id)
41
45
  guard let reader = self.reader, reader.id <= id else { return }
42
- reader.close(error)
46
+ reader.close(error, done)
43
47
  self.reader = nil
44
48
  }
45
49
 
@@ -50,14 +54,21 @@ public class CieSignModule: Module {
50
54
  AsyncFunction("forgetPin") { SavedPin.delete() }
51
55
 
52
56
  OnDestroy {
53
- DispatchQueue.main.async { self.reader?.close(nil) }
57
+ DispatchQueue.main.async { self.reader?.close(nil, nil) }
54
58
  }
55
59
  }
60
+
61
+ /// Sends session `id`'s steps to JS as `onStep` events, without keeping the module alive.
62
+ private func reporter(_ id: Int) -> (String) -> Void {
63
+ { [weak self] step in self?.sendEvent("onStep", ["id": id, "step": step]) }
64
+ }
56
65
  }
57
66
 
58
67
  /// One NFCTagReaderSession; used on the main thread only.
59
68
  private final class Reader: NSObject, NFCTagReaderSessionDelegate {
60
69
  let id: Int
70
+ private let messages: [String: String]
71
+ private let onStep: (String) -> Void
61
72
  private var session: NFCTagReaderSession?
62
73
  private var pending: CheckedContinuation<Data, Error>?
63
74
  private var pin = ""
@@ -68,13 +79,26 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
68
79
  private(set) var card: CieCard?
69
80
  private(set) var invalidation: Error?
70
81
 
71
- init(id: Int) {
82
+ init(id: Int, messages: [String: String], onStep: @escaping (String) -> Void) {
72
83
  self.id = id
84
+ self.messages = messages
85
+ self.onStep = onStep
86
+ }
87
+
88
+ /// Shows the NFC sheet text for `step`, if any.
89
+ func show(_ step: String) {
90
+ if let message = messages[step] { session?.alertMessage = message }
91
+ }
92
+
93
+ /// Shows `step` in the NFC sheet and reports it to JS.
94
+ private func reach(_ step: String) {
95
+ show(step)
96
+ onStep(step)
73
97
  }
74
98
 
75
99
  /// A nil `pin` unlocks the saved one with Face ID or Touch ID; `save` saves `pin` once the card accepts it, with no
76
100
  /// prompt, since writing the Keychain item needs no authentication.
77
- func connect(pin: String?, save: Bool, prompt: String, biometricPrompt: String, biometricCancel: String) async throws -> Data {
101
+ func connect(pin: String?, save: Bool, biometricPrompt: String, biometricCancel: String) async throws -> Data {
78
102
  if let pin {
79
103
  if save, !SavedPin.canSave { throw SavedPinError.biometryUnavailable }
80
104
  (self.pin, self.save) = (pin, save)
@@ -92,16 +116,22 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
92
116
  }
93
117
  pending = continuation
94
118
  self.session = session
95
- session.alertMessage = prompt
119
+ show("prompt")
96
120
  session.begin()
97
121
  }
98
122
  }
99
123
 
100
- func close(_ error: String?) {
124
+ /// Ends the session; the NFC sheet shows `error` if there is one, otherwise `done` next to its checkmark.
125
+ func close(_ error: String?, _ done: String?) {
101
126
  closed = true
102
127
  biometrics?.invalidate()
103
128
  finish(.failure(cancelled))
104
- if let error { session?.invalidate(errorMessage: error) } else { session?.invalidate() }
129
+ if let error {
130
+ session?.invalidate(errorMessage: error)
131
+ } else {
132
+ if let done { session?.alertMessage = done }
133
+ session?.invalidate()
134
+ }
105
135
  session = nil
106
136
  card = nil
107
137
  }
@@ -120,6 +150,7 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
120
150
  Task { @MainActor in
121
151
  do {
122
152
  try await session.connect(to: tags[0])
153
+ reach("detected")
123
154
  let card = CieCard { apdu in
124
155
  guard let command = NFCISO7816APDU(data: apdu) else { throw failure("CARD_ERROR", "Malformed APDU") }
125
156
  let (data, sw1, sw2) = try await tag.sendCommand(apdu: command)
@@ -127,10 +158,12 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
127
158
  }
128
159
  let serviceId = try await card.serviceId()
129
160
  if let owner, owner != serviceId { throw SavedPinError.otherCard }
161
+ reach("pin")
130
162
  try await card.open(pin: pin)
131
163
  if save { try SavedPin.store(pin, card: serviceId) }
132
164
  let certificate = try await card.certificate()
133
165
  self.card = card
166
+ show("ready") // JS reports `ready` once connect resolves
134
167
  finish(.success(certificate))
135
168
  } catch let error as NFCReaderError where error.code == .readerTransceiveErrorSessionInvalidated {
136
169
  // didInvalidateWithError follows with the reason (cancel, timeout).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciesign-react-native",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "RSA signatures with the Italian electronic identity card (CIE) over NFC",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
package/src/index.ts CHANGED
@@ -10,11 +10,38 @@ export const SAVED_PIN: unique symbol = Symbol('SAVED_PIN');
10
10
  export type Pin = string | typeof SAVED_PIN;
11
11
 
12
12
  /**
13
- * `prompt`, `error`: texts of the iOS NFC sheet. `savePin`: once the card accepts the PIN, save it for `SAVED_PIN`.
14
- * `biometricPrompt`, `biometricCancel`: texts of the biometric prompt shown before the card is read, wherever the secure
15
- * hardware requires one: `SAVED_PIN` everywhere, `savePin` on Android only.
13
+ * Progress of a session, in order: `detected` the card is on the phone; `pin` the PIN is being verified; `ready` the PIN
14
+ * is verified and the certificate read, `use` starts; `signing` a signature starts, once per `sign`; `done` `use`
15
+ * resolved and the session is over.
16
16
  */
17
- export type CieOptions = { prompt?: string; error?: string; savePin?: boolean; biometricPrompt?: string; biometricCancel?: string };
17
+ export type CieStep = 'detected' | 'pin' | 'ready' | 'signing' | 'done';
18
+
19
+ const STEPS: CieStep[] = ['detected', 'pin', 'ready', 'signing', 'done'];
20
+
21
+ const MESSAGES: Record<CieStep, string> = {
22
+ detected: 'CIE rilevata, non allontanarla',
23
+ pin: 'Verifica del PIN…',
24
+ ready: 'PIN verificato',
25
+ signing: 'Firma in corso…',
26
+ done: 'Operazione completata',
27
+ };
28
+
29
+ /**
30
+ * `prompt`, `error`: texts of the iOS NFC sheet while it waits for the card and when the session fails; `messages`: its
31
+ * text at each step. `onStep`: called at each step, e.g. to show progress on Android, which has no NFC sheet.
32
+ * `savePin`: once the card accepts the PIN, save it for `SAVED_PIN`. `biometricPrompt`, `biometricCancel`: texts of the
33
+ * biometric prompt shown before the card is read, wherever the secure hardware requires one: `SAVED_PIN` everywhere,
34
+ * `savePin` on Android only.
35
+ */
36
+ export type CieOptions = {
37
+ prompt?: string;
38
+ error?: string;
39
+ messages?: Partial<Record<CieStep, string>>;
40
+ onStep?: (step: CieStep) => void;
41
+ savePin?: boolean;
42
+ biometricPrompt?: string;
43
+ biometricCancel?: string;
44
+ };
18
45
 
19
46
  /** A card whose PIN has been verified, for as long as `use` runs. */
20
47
  export type CieSession = {
@@ -38,15 +65,29 @@ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Prom
38
65
  savePin = false,
39
66
  biometricPrompt = pin === SAVED_PIN ? 'Sblocca il PIN della CIE' : 'Salva il PIN della CIE',
40
67
  biometricCancel = 'Annulla',
68
+ onStep,
41
69
  } = options;
70
+ const message = (s: CieStep) => options.messages?.[s] ?? MESSAGES[s];
42
71
  const id = ++session;
43
72
  let done = false;
73
+ // Native events can arrive after `ready`: steps only move forward, except one `signing` per signature.
74
+ let reached = -1;
75
+ const step = (s: CieStep) => {
76
+ const i = STEPS.indexOf(s);
77
+ if (i < reached || (i === reached && s !== 'signing')) return;
78
+ reached = i;
79
+ onStep?.(s);
80
+ };
81
+ const subscription = onStep && Native.addListener('onStep', (event) => event.id === id && step(event.step));
44
82
  try {
45
- const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, prompt, biometricPrompt, biometricCancel));
83
+ const sheet = { prompt, detected: message('detected'), pin: message('pin'), ready: message('ready'), signing: message('signing') };
84
+ const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, sheet, biometricPrompt, biometricCancel));
85
+ step('ready');
46
86
  let last: Promise<unknown> = Promise.resolve();
47
87
  const sign = (digestInfo: Bytes) => {
48
88
  const input = digestInfo.slice(); // signed later, once the calls before it finish
49
89
  const signature = last.then(async () => {
90
+ step('signing');
50
91
  const signature = await Native.sign(id, input);
51
92
  verify(certificate, input, signature);
52
93
  return signature;
@@ -58,12 +99,14 @@ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Prom
58
99
  done = true;
59
100
  return result;
60
101
  } finally {
61
- Native.close(id, done ? null : error);
102
+ subscription?.remove();
103
+ Native.close(id, done ? null : error, done ? message('done') : null);
104
+ if (done) step('done');
62
105
  }
63
106
  }
64
107
 
65
108
  /** Aborts the CIE session in progress. */
66
- export const cancel = () => Native.close(session, null);
109
+ export const cancel = () => Native.close(session, null, null);
67
110
 
68
111
  /** Whether a PIN is saved, without showing any UI. */
69
112
  export const pinStatus = (): Promise<PinStatus> => Native.pinStatus();
package/src/native.ts CHANGED
@@ -3,19 +3,30 @@ import { requireNativeModule } from 'expo';
3
3
  /** `saved`: a PIN is saved. `available`: a strong biometric is enrolled, so one can be. `unavailable`: neither. */
4
4
  export type PinStatus = 'saved' | 'available' | 'unavailable';
5
5
 
6
+ /** Steps only the native side sees: the card was detected; the PIN is being verified. */
7
+ export type NativeStep = 'detected' | 'pin';
8
+
9
+ /** iOS NFC sheet texts: `prompt` while waiting for the card, then one per step. */
10
+ export type NativeMessages = { prompt: string; detected: string; pin: string; ready: string; signing: string };
11
+
6
12
  type CieSignModule = {
7
13
  /**
8
14
  * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
9
15
  * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
10
16
  * only where the secure hardware requires it: unlocking everywhere, saving on Android.
11
17
  */
12
- connect(id: number, pin: string | null, save: boolean, prompt: string, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
18
+ connect(id: number, pin: string | null, save: boolean, messages: NativeMessages, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
13
19
  /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
14
20
  sign(id: number, digestInfo: Uint8Array): Promise<Uint8Array>;
15
- /** Ends every session up to `id`, also one whose connect has not arrived yet; iOS shows `error` in the NFC sheet. */
16
- close(id: number, error: string | null): void;
21
+ /**
22
+ * Ends every session up to `id`, also one whose connect has not arrived yet. iOS shows `error` in the NFC sheet, or
23
+ * else `done` next to its checkmark.
24
+ */
25
+ close(id: number, error: string | null, done: string | null): void;
17
26
  pinStatus(): Promise<PinStatus>;
18
27
  forgetPin(): Promise<void>;
28
+ /** `onStep` fires as session `id` reaches a step only the native side sees. */
29
+ addListener(event: 'onStep', listener: (event: { id: number; step: NativeStep }) => void): { remove(): void };
19
30
  };
20
31
 
21
32
  export default requireNativeModule<CieSignModule>('CieSign');