ciesign-react-native 0.1.0 → 0.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 CHANGED
@@ -66,10 +66,10 @@ 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?, useSavedCertificate?, beforeCard? })` | 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
- | `SAVED_PIN`, `pinStatus()`, `forgetPin()` | See [Saved PIN](#saved-pin). |
72
+ | `SAVED_PIN`, `pinStatus()`, `forgetPin()`, `savedCertificate()` | See [Saved PIN](#saved-pin). |
73
73
  | `cancel()` | Aborts the session in progress. Android has no system NFC sheet, so give users a cancel button. |
74
74
 
75
75
  iOS ends an NFC session 60 seconds after it starts, `use` included: prepare what does not depend on the certificate, such as a PDF's byte range, before calling `cieSession`.
@@ -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, or taken from the saved one: `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.
@@ -117,9 +138,27 @@ const signatures =
117
138
  - **Native only.** A saved PIN is decrypted in native code and sent to the card; it never reaches JavaScript.
118
139
  - One PIN per app: saving again replaces it. `forgetPin()` deletes it. `savePin` fails with `BIOMETRIC_UNAVAILABLE` before the card is read when `pinStatus()` is `'unavailable'`.
119
140
 
141
+ #### Saved certificate
142
+
143
+ The card certificate is saved with the PIN: on iOS in a Keychain item of its own, readable while the device is unlocked, never synced or backed up; on Android next to the encrypted PIN in `noBackupFilesDir`. It is public (it goes into every signature), so no biometric guards it. It is replaced and deleted with the PIN; a `SAVED_PIN` session that reads the certificate from the card saves it too, for PINs saved before it was.
144
+
145
+ `savedCertificate()` returns it without any UI, or `null`. Knowing it before the card is on the phone lets you prepare what to sign before the NFC session, which iOS ends after 60 seconds. Then `useSavedCertificate: true` makes the `SAVED_PIN` session skip reading it: the card is still checked by its NIS (`WRONG_CARD` otherwise) and every signature against the certificate. Without the option, or without a saved certificate, the session reads it from the card as usual.
146
+
147
+ To prepare it after the biometric prompt rather than before, pass `beforeCard`: with `SAVED_PIN`, the session calls it once the prompt has unlocked the PIN, with the saved certificate it will use (or `null`), and waits for it before the NFC session starts. A user who cancels the prompt never reaches your server, and the wait doesn't count toward iOS's 60 seconds. If it throws, the session ends with that error and the card is never read.
148
+
149
+ ```ts
150
+ let prepared: Prepared | null = null;
151
+ const signatures = await cieSession(SAVED_PIN, async (cie) => sign(prepared ?? (await prepare(cie.certificate)), cie), {
152
+ useSavedCertificate: true,
153
+ beforeCard: async (certificate) => {
154
+ if (certificate) prepared = await prepare(certificate); // e.g. your server's hashes to sign
155
+ },
156
+ });
157
+ ```
158
+
120
159
  ## How it works
121
160
 
122
- `cieSession` opens one NFC session: NIS read (EF 1001), secure messaging (Diffie-Hellman and device authentication), PIN verification, certificate read (EF 1003). Each `sign` then sends `MSE SET` (key `0x81`, RSA PKCS#1) and `INTERNAL AUTHENTICATE` with the DigestInfo over the same secure channel.
161
+ `cieSession` opens one NFC session: NIS read (EF 1001), secure messaging (Diffie-Hellman and device authentication), PIN verification, certificate read (EF 1003, skipped with `useSavedCertificate`). Each `sign` then sends `MSE SET` (key `0x81`, RSA PKCS#1) and `INTERNAL AUTHENTICATE` with the DigestInfo over the same secure channel.
123
162
 
124
163
  - Android drives `CieCommands` from PagoPA's [`cie-sdk-android`](https://github.com/pagopa/cie-sdk-android) through a Java shim, because the class is Kotlin-internal. The dependency is pinned to `0.1.11`: check the shim before upgrading.
125
164
  - iOS implements the same protocol in `ios/CieCard.swift`, derived from PagoPA's [`cie-sdk-ios`](https://github.com/pagopa/cie-sdk-ios), whose signing code is not public.
@@ -17,9 +17,13 @@ final class CieCard {
17
17
  return commands.getServiceID();
18
18
  }
19
19
 
20
- /** Opens secure messaging, verifies the PIN and returns the certificate file (EF 1003, zero padded). */
21
- byte[] open(String pin) throws Exception {
20
+ /** Opens secure messaging and verifies the PIN. */
21
+ void open(String pin) throws Exception {
22
22
  commands.startSecureChannel(pin);
23
+ }
24
+
25
+ /** The certificate file (EF 1003, zero padded), once {@link #open} has verified the PIN. */
26
+ byte[] certificate() throws Exception {
23
27
  return commands.readCertCie();
24
28
  }
25
29
 
@@ -28,40 +28,62 @@ class CieSignModule : Module() {
28
28
  private var tag: IsoDep? = null
29
29
  private var card: CieCard? = null
30
30
  private var prompt: BiometricPrompt? = null
31
+ private var unlocked: Unlocked? = null
31
32
  private val savedPin by lazy { SavedPin(requireNotNull(appContext.reactContext)) }
32
33
 
33
34
  override fun definition() = ModuleDefinition {
34
35
  Name("CieSign")
35
36
 
36
- // 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 ->
37
+ Events("onStep")
38
+
39
+ // Unlocks the saved PIN for `connect` behind the biometric prompt. With `useSavedCertificate`, resolves to the
40
+ // certificate saved with it when it is of the same card, which `connect` then returns instead of reading it.
41
+ AsyncFunction("unlock") { id: Int, biometricPrompt: String, biometricCancel: String, useSavedCertificate: Boolean, promise: Promise ->
42
+ val activity = appContext.currentActivity ?: throw CodedException("NFC_UNAVAILABLE", "No foreground activity", null)
43
+ if (NfcAdapter.getDefaultAdapter(activity)?.isEnabled != true) throw CodedException("NFC_UNAVAILABLE", "NFC is missing or turned off", null)
44
+ start(id, promise)
45
+ try {
46
+ val saved = savedPin.saved() ?: throw CodedException("PIN_NOT_SAVED", "No saved PIN", null)
47
+ authenticate(activity, id, saved.cipher, biometricPrompt, biometricCancel) {
48
+ val pin = try {
49
+ String(it.doFinal(saved.pin))
50
+ } catch (e: Exception) {
51
+ savedPin.delete()
52
+ throw CodedException("PIN_NOT_SAVED", "The saved PIN cannot be decrypted", e)
53
+ }
54
+ val certificate = if (useSavedCertificate) savedPin.certificate(saved.card) else null
55
+ val waiting = synchronized(lock) { take(id)?.also { unlocked = Unlocked(pin, saved.card, certificate) } }
56
+ waiting?.resolve(certificate)
57
+ }
58
+ } catch (e: Exception) {
59
+ take(id)?.reject(coded(e))
60
+ }
61
+ }
62
+
63
+ // A null `pin` continues the session `unlock` started; `save` saves `pin` once the card accepts it, after the biometric
64
+ // prompt. The messages are for the iOS NFC sheet: Android has none.
65
+ AsyncFunction("connect") { id: Int, pin: String?, save: Boolean, _: Map<String, String>, biometricPrompt: String, biometricCancel: String, promise: Promise ->
38
66
  val activity = appContext.currentActivity ?: throw CodedException("NFC_UNAVAILABLE", "No foreground activity", null)
39
67
  val adapter = NfcAdapter.getDefaultAdapter(activity)
40
68
  if (adapter?.isEnabled != true) throw CodedException("NFC_UNAVAILABLE", "NFC is missing or turned off", null)
41
- synchronized(lock) {
42
- if (id <= closed) throw cancelled()
43
- end()
44
- session = id
45
- pending = promise
69
+ val saved = if (pin == null) {
70
+ synchronized(lock) {
71
+ if (id <= closed || session != id) throw cancelled()
72
+ val found = unlocked ?: throw CodedException("PIN_NOT_SAVED", "The saved PIN was not unlocked", null)
73
+ pending = promise
74
+ found
75
+ }
76
+ } else {
77
+ start(id, promise)
78
+ null
46
79
  }
47
80
  try {
48
81
  when {
49
- pin == null -> {
50
- val saved = savedPin.saved() ?: throw CodedException("PIN_NOT_SAVED", "No saved PIN", null)
51
- authenticate(activity, id, saved.cipher, biometricPrompt, biometricCancel) {
52
- val unlocked = try {
53
- String(it.doFinal(saved.pin))
54
- } catch (e: Exception) {
55
- savedPin.delete()
56
- throw CodedException("PIN_NOT_SAVED", "The saved PIN cannot be decrypted", e)
57
- }
58
- read(activity, adapter, id, unlocked, saved.card, null)
59
- }
60
- }
82
+ saved != null -> read(activity, adapter, id, saved.pin, saved.card, null, saved.certificate)
61
83
  save -> authenticate(activity, id, savedPin.encrypter(), biometricPrompt, biometricCancel) {
62
- read(activity, adapter, id, pin, null, savedPin.seal(it, pin))
84
+ read(activity, adapter, id, requireNotNull(pin), null, savedPin.seal(it, requireNotNull(pin)), null)
63
85
  }
64
- else -> read(activity, adapter, id, pin, null, null)
86
+ else -> read(activity, adapter, id, requireNotNull(pin), null, null, null)
65
87
  }
66
88
  } catch (e: Exception) {
67
89
  take(id)?.reject(coded(e))
@@ -77,7 +99,7 @@ class CieSignModule : Module() {
77
99
  }
78
100
  }
79
101
 
80
- Function("close") { id: Int, _: String? ->
102
+ Function("close") { id: Int, _: String?, _: String? ->
81
103
  synchronized(lock) {
82
104
  closed = maxOf(closed, id)
83
105
  if (session in 1..id) end()
@@ -88,16 +110,29 @@ class CieSignModule : Module() {
88
110
 
89
111
  AsyncFunction("forgetPin") { savedPin.delete() }
90
112
 
113
+ AsyncFunction("savedCertificate") { savedPin.certificate() }
114
+
91
115
  OnDestroy { synchronized(lock) { end() } }
92
116
  }
93
117
 
118
+ /** Ends any other session and makes `id` the current one, settled by `promise`. */
119
+ private fun start(id: Int, promise: Promise) = synchronized(lock) {
120
+ if (id <= closed) throw cancelled()
121
+ end()
122
+ session = id
123
+ pending = promise
124
+ }
125
+
94
126
  private fun take(id: Int): Promise? = synchronized(lock) { pending.takeIf { session == id }.also { if (it != null) pending = null } }
95
127
 
96
- /** `owner`: NIS the saved PIN belongs to; `sealed`: encrypted PIN to save once the card accepts it. */
97
- private fun read(activity: Activity, adapter: NfcAdapter, id: Int, pin: String, owner: String?, sealed: String?) {
128
+ /**
129
+ * `owner`: NIS the saved PIN belongs to; `sealed`: encrypted PIN to save once the card accepts it; `saved`: that card's
130
+ * certificate file, returned instead of reading it.
131
+ */
132
+ private fun read(activity: Activity, adapter: NfcAdapter, id: Int, pin: String, owner: String?, sealed: String?, saved: ByteArray?) {
98
133
  val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
99
134
  synchronized(lock) {
100
- if (session == id) adapter.enableReaderMode(activity, { onTag(it, id, pin, owner, sealed) }, flags, null)
135
+ if (session == id) adapter.enableReaderMode(activity, { onTag(it, id, pin, owner, sealed, saved) }, flags, null)
101
136
  }
102
137
  }
103
138
 
@@ -130,7 +165,7 @@ class CieSignModule : Module() {
130
165
  }
131
166
 
132
167
  // Runs on the NFC thread; the tag stays connected after returning, until the session ends.
133
- private fun onTag(discovered: Tag, id: Int, pin: String, owner: String?, sealed: String?) {
168
+ private fun onTag(discovered: Tag, id: Int, pin: String, owner: String?, sealed: String?, saved: ByteArray?) {
134
169
  val promise = take(id) ?: return
135
170
  var pinStatus = 0
136
171
  try {
@@ -144,6 +179,7 @@ class CieSignModule : Module() {
144
179
  }
145
180
  tag = isoDep
146
181
  }
182
+ step(id, "detected")
147
183
  val c = CieCard(object : OnTransmit {
148
184
  override fun sendCommand(apdu: ByteArray, nfcEvents: NfcEvent): ApduResponse {
149
185
  val response = isoDep.transceive(apdu)
@@ -155,8 +191,10 @@ class CieSignModule : Module() {
155
191
  })
156
192
  val nis = c.serviceId()
157
193
  if (owner != null && owner != nis) throw CodedException("WRONG_CARD", "Not the card the PIN was saved for", null)
158
- val certificate = c.open(pin)
194
+ step(id, "pin")
195
+ c.open(pin)
159
196
  if (sealed != null) savedPin.store(nis, sealed)
197
+ val certificate = saved ?: c.certificate().also { if (sealed != null || owner != null) savedPin.storeCertificate(nis, it) }
160
198
  synchronized(lock) {
161
199
  if (session != id) throw cancelled()
162
200
  card = c
@@ -169,11 +207,14 @@ class CieSignModule : Module() {
169
207
  }
170
208
  }
171
209
 
210
+ private fun step(id: Int, step: String) = sendEvent("onStep", mapOf("id" to id, "step" to step))
211
+
172
212
  private fun end() {
173
213
  pending?.reject(cancelled())
174
214
  pending = null
175
215
  prompt?.let { appContext.currentActivity?.runOnUiThread(it::cancelAuthentication) }
176
216
  prompt = null
217
+ unlocked = null
177
218
  card = null
178
219
  runCatching { tag?.close() }
179
220
  tag = null
@@ -181,6 +222,9 @@ class CieSignModule : Module() {
181
222
  appContext.currentActivity?.let { NfcAdapter.getDefaultAdapter(it)?.disableReaderMode(it) }
182
223
  }
183
224
 
225
+ /** The saved PIN `unlock` decrypted, its card's NIS and, when asked for, that card's saved certificate. */
226
+ private class Unlocked(val pin: String, val card: String, val certificate: ByteArray?)
227
+
184
228
  private fun cancelled() = CodedException("CANCELLED", "Cancelled", null)
185
229
 
186
230
  // Under secure messaging the card's status word is in DO 99, ahead of the MAC; otherwise it ends the response.
@@ -22,12 +22,14 @@ import javax.crypto.spec.GCMParameterSpec
22
22
  /**
23
23
  * The CIE PIN encrypted with an AES-GCM Keystore key, in StrongBox when the device has it and otherwise in the TEE. The key
24
24
  * encrypts and decrypts only right after a strong biometric matches one enrolled when it was made. The file is not backed up.
25
+ * Next to it, its card's certificate: public, so stored as is, and replaced or deleted with the PIN.
25
26
  */
26
27
  internal class SavedPin(private val context: Context) {
27
28
  /** The encrypted PIN, the NIS of its card and the decrypt cipher that a biometric unlocks. */
28
29
  class Saved(val card: String, val pin: ByteArray, val cipher: Cipher)
29
30
 
30
31
  private val file = File(context.noBackupFilesDir, "ciesign-pin")
32
+ private val certificateFile = File(context.noBackupFilesDir, "ciesign-certificate")
31
33
  private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
32
34
 
33
35
  val canSave get() = BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
@@ -61,10 +63,24 @@ internal class SavedPin(private val context: Context) {
61
63
  }
62
64
 
63
65
  /** Replaces the saved PIN; `card` is the NIS of the card that accepted it. */
64
- fun store(card: String, sealed: String) = file.writeText("$card\n$sealed")
66
+ fun store(card: String, sealed: String) {
67
+ certificateFile.delete()
68
+ file.writeText("$card\n$sealed")
69
+ }
70
+
71
+ /** Saves the certificate file of the card whose PIN is saved; `card` is its NIS. */
72
+ fun storeCertificate(card: String, certificate: ByteArray) = certificateFile.writeText("$card\n${encode(certificate)}")
73
+
74
+ /** The certificate file saved with the PIN, if it is the card with NIS `card` (any when null); null when none is saved. */
75
+ fun certificate(card: String? = null): ByteArray? {
76
+ if (!file.exists()) return null
77
+ val (owner, certificate) = runCatching { certificateFile.readLines() }.getOrNull()?.takeIf { it.size == 2 } ?: return null
78
+ return if (card == null || card == owner) Base64.decode(certificate, Base64.NO_WRAP) else null
79
+ }
65
80
 
66
81
  fun delete() {
67
82
  file.delete()
83
+ certificateFile.delete()
68
84
  }
69
85
 
70
86
  private fun cipher(mode: Int, iv: ByteArray? = null): Cipher? {
package/build/index.d.ts CHANGED
@@ -6,16 +6,32 @@ 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 (or taken from the saved one), `use` starts; `signing` a signature starts, once
11
+ * per `sign`; `done` `use` 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`, with the card certificate. `biometricPrompt`,
18
+ * `biometricCancel`: texts of the biometric prompt shown before the card is read, wherever the secure hardware requires
19
+ * one: `SAVED_PIN` everywhere, `savePin` on Android only. `useSavedCertificate`: with `SAVED_PIN`, skip reading the
20
+ * certificate from the card and use the one saved with the PIN (see `savedCertificate`), when there is one.
21
+ * `beforeCard`: with `SAVED_PIN`, runs once the biometric prompt has unlocked the PIN and before the NFC session waits for
22
+ * the card, with the saved certificate the session will use (`useSavedCertificate`), or null: the session waits for it, so
23
+ * what to sign can be prepared after the prompt and outside iOS's 60-second NFC limit.
12
24
  */
13
25
  export type CieOptions = {
14
26
  prompt?: string;
15
27
  error?: string;
28
+ messages?: Partial<Record<CieStep, string>>;
29
+ onStep?: (step: CieStep) => void;
16
30
  savePin?: boolean;
17
31
  biometricPrompt?: string;
18
32
  biometricCancel?: string;
33
+ useSavedCertificate?: boolean;
34
+ beforeCard?: (savedCertificate: Bytes | null) => Promise<void>;
19
35
  };
20
36
  /** A card whose PIN has been verified, for as long as `use` runs. */
21
37
  export type CieSession = {
@@ -33,5 +49,11 @@ export declare function cieSession<T>(pin: Pin, use: (session: CieSession) => Pr
33
49
  export declare const cancel: () => void;
34
50
  /** Whether a PIN is saved, without showing any UI. */
35
51
  export declare const pinStatus: () => Promise<PinStatus>;
36
- /** Deletes the saved PIN. */
52
+ /** Deletes the saved PIN and its certificate. */
37
53
  export declare const forgetPin: () => Promise<void>;
54
+ /**
55
+ * The DER certificate of the card whose PIN is saved, without any UI: known before the card is on the phone, e.g. to
56
+ * prepare what to sign before a `SAVED_PIN` session with `useSavedCertificate`. Null when no PIN is saved, or none was
57
+ * saved with it yet: a `SAVED_PIN` session that reads the certificate from the card saves it.
58
+ */
59
+ export declare function savedCertificate(): Promise<Bytes | null>;
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,33 @@ 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', useSavedCertificate = false, onStep, beforeCard, } = 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
+ if (pin === SAVED_PIN) {
38
+ const saved = await Native.unlock(id, biometricPrompt, biometricCancel, useSavedCertificate);
39
+ await beforeCard?.(saved && trimDer(saved));
40
+ }
41
+ const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, sheet, biometricPrompt, biometricCancel));
42
+ step('ready');
18
43
  let last = Promise.resolve();
19
44
  const sign = (digestInfo) => {
20
45
  const input = digestInfo.slice(); // signed later, once the calls before it finish
21
46
  const signature = last.then(async () => {
47
+ step('signing');
22
48
  const signature = await Native.sign(id, input);
23
49
  verify(certificate, input, signature);
24
50
  return signature;
@@ -31,12 +57,24 @@ export async function cieSession(pin, use, options = {}) {
31
57
  return result;
32
58
  }
33
59
  finally {
34
- Native.close(id, done ? null : error);
60
+ subscription?.remove();
61
+ Native.close(id, done ? null : error, done ? message('done') : null);
62
+ if (done)
63
+ step('done');
35
64
  }
36
65
  }
37
66
  /** Aborts the CIE session in progress. */
38
- export const cancel = () => Native.close(session, null);
67
+ export const cancel = () => Native.close(session, null, null);
39
68
  /** Whether a PIN is saved, without showing any UI. */
40
69
  export const pinStatus = () => Native.pinStatus();
41
- /** Deletes the saved PIN. */
70
+ /** Deletes the saved PIN and its certificate. */
42
71
  export const forgetPin = () => Native.forgetPin();
72
+ /**
73
+ * The DER certificate of the card whose PIN is saved, without any UI: known before the card is on the phone, e.g. to
74
+ * prepare what to sign before a `SAVED_PIN` session with `useSavedCertificate`. Null when no PIN is saved, or none was
75
+ * saved with it yet: a `SAVED_PIN` session that reads the certificate from the card saves it.
76
+ */
77
+ export async function savedCertificate() {
78
+ const certificate = await Native.savedCertificate();
79
+ return certificate ? trimDer(certificate) : null;
80
+ }
package/build/native.d.ts CHANGED
@@ -1,18 +1,46 @@
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 = {
14
+ /**
15
+ * Shows the biometric prompt and unlocks the saved PIN for session `id`, which `connect` with a null `pin` then sends to
16
+ * the card. Returns the certificate saved with the PIN when `useSavedCertificate` and it is of the same card: that
17
+ * session's `connect` returns it instead of reading it. Null otherwise.
18
+ */
19
+ unlock(id: number, biometricPrompt: string, biometricCancel: string, useSavedCertificate: boolean): Promise<Uint8Array | null>;
4
20
  /**
5
21
  * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
6
- * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
7
- * only where the secure hardware requires it: unlocking everywhere, saving on Android.
22
+ * A null `pin` is the one `unlock` unlocked for `id`; `save` saves `pin` once the card accepts it, after the biometric
23
+ * prompt where the secure hardware requires one (Android).
8
24
  */
9
- connect(id: number, pin: string | null, save: boolean, prompt: string, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
25
+ connect(id: number, pin: string | null, save: boolean, messages: NativeMessages, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
10
26
  /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
11
27
  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;
28
+ /**
29
+ * Ends every session up to `id`, also one whose connect has not arrived yet. iOS shows `error` in the NFC sheet, or
30
+ * else `done` next to its checkmark.
31
+ */
32
+ close(id: number, error: string | null, done: string | null): void;
14
33
  pinStatus(): Promise<PinStatus>;
15
34
  forgetPin(): Promise<void>;
35
+ /** The certificate file saved with the PIN, without any UI; null when none is saved. */
36
+ savedCertificate(): Promise<Uint8Array | null>;
37
+ /** `onStep` fires as session `id` reaches a step only the native side sees. */
38
+ addListener(event: 'onStep', listener: (event: {
39
+ id: number;
40
+ step: NativeStep;
41
+ }) => void): {
42
+ remove(): void;
43
+ };
16
44
  };
17
45
  declare const _default: CieSignModule;
18
46
  export default _default;
package/ios/CieCard.swift CHANGED
@@ -52,8 +52,12 @@ final class CieCard {
52
52
  let ca = try await ok(send(0x00, 0xCB, 0x3F, 0xFF, [0x4D, 0x09, 0x70, 0x07, 0xBF, 0xA0, 0x04, 0x03, 0x7F, 0x49, 0x80]))
53
53
  let (caN, caCHR, caCHA) = try (Array(find(0x81, in: ca).drop { $0 == 0 }), find(0x5F20, in: ca), find(0x5F4C, in: ca))
54
54
 
55
+ // Some cards zero-pad q to the length of p (a 256-bit q in 256 bytes): drawn at full length, x would almost never be
56
+ // below it and this loop would spin until iOS drops the idle session. Draw it over q's significant bytes instead.
57
+ let order = Array(q.drop { $0 == 0 })
58
+ guard !order.isEmpty else { throw CieCardError.invalidResponse }
55
59
  var x: [UInt8]
56
- repeat { x = random(q.count) } while !x.lexicographicallyPrecedes(q)
60
+ repeat { x = random(order.count) } while !x.lexicographicallyPrecedes(order)
57
61
  let pubIFD = try modPow(g, x, p)
58
62
  try await ok(send(0x00, 0x22, 0x41, 0xA6, tlv(0x80, [0x9B]) + tlv(0x83, [0x81]) + tlv(0x91, pubIFD)))
59
63
  let pubICC = try await find(0x91, in: ok(send(0x00, 0xCB, 0x3F, 0xFF, [0x4D, 0x04, 0xA6, 0x02, 0x91, 0x00])))
@@ -10,15 +10,38 @@ public class CieSignModule: Module {
10
10
  public func definition() -> ModuleDefinition {
11
11
  Name("CieSign")
12
12
 
13
- AsyncFunction("connect") {
14
- @MainActor (id: Int, pin: String?, save: Bool, prompt: String, biometricPrompt: String, biometricCancel: String) async throws -> Data in
13
+ Events("onStep")
14
+
15
+ AsyncFunction("unlock") {
16
+ @MainActor (id: Int, biometricPrompt: String, biometricCancel: String, useSavedCertificate: Bool) async throws -> Data? in
15
17
  guard NFCTagReaderSession.readingAvailable else { throw failure("NFC_UNAVAILABLE", "NFC tag reading is not available") }
16
18
  guard id > self.closed else { throw cancelled }
17
- self.reader?.close(nil)
18
- let reader = Reader(id: id)
19
+ self.reader?.close(nil, nil)
20
+ let reader = Reader(id: id, onStep: self.reporter(id))
19
21
  self.reader = reader
20
22
  do {
21
- return try await reader.connect(pin: pin, save: save, prompt: prompt, biometricPrompt: biometricPrompt, biometricCancel: biometricCancel)
23
+ return try await reader.unlock(biometricPrompt: biometricPrompt, biometricCancel: biometricCancel, useSavedCertificate: useSavedCertificate)
24
+ } catch {
25
+ throw coded(error)
26
+ }
27
+ }
28
+
29
+ // `messages`: NFC sheet texts by step, `prompt` first. A nil `pin` continues the session `unlock` started.
30
+ AsyncFunction("connect") {
31
+ @MainActor (id: Int, pin: String?, save: Bool, messages: [String: String], biometricPrompt: String, biometricCancel: String) async throws -> Data in
32
+ guard NFCTagReaderSession.readingAvailable else { throw failure("NFC_UNAVAILABLE", "NFC tag reading is not available") }
33
+ guard id > self.closed else { throw cancelled }
34
+ let reader: Reader
35
+ if pin == nil {
36
+ guard let unlocked = self.reader, unlocked.id == id else { throw cancelled }
37
+ reader = unlocked
38
+ } else {
39
+ self.reader?.close(nil, nil)
40
+ reader = Reader(id: id, onStep: self.reporter(id))
41
+ self.reader = reader
42
+ }
43
+ do {
44
+ return try await reader.connect(pin: pin, save: save, messages: messages)
22
45
  } catch {
23
46
  let error = coded(reader.invalidation ?? error)
24
47
  if pin == nil, let code = (error as? Exception)?.code, code.hasPrefix("WRONG_PIN") || code == "PIN_LOCKED" { SavedPin.delete() }
@@ -30,16 +53,17 @@ public class CieSignModule: Module {
30
53
  guard let reader = self.reader, reader.id == id else { throw cancelled }
31
54
  do {
32
55
  guard let card = reader.card else { throw cancelled } // the reason is in `invalidation`, e.g. a timeout
56
+ reader.show("signing")
33
57
  return try await card.sign(digestInfo)
34
58
  } catch {
35
59
  throw coded(reader.invalidation ?? error)
36
60
  }
37
61
  }
38
62
 
39
- AsyncFunction("close") { @MainActor (id: Int, error: String?) async in
63
+ AsyncFunction("close") { @MainActor (id: Int, error: String?, done: String?) async in
40
64
  self.closed = max(self.closed, id)
41
65
  guard let reader = self.reader, reader.id <= id else { return }
42
- reader.close(error)
66
+ reader.close(error, done)
43
67
  self.reader = nil
44
68
  }
45
69
 
@@ -49,42 +73,74 @@ public class CieSignModule: Module {
49
73
 
50
74
  AsyncFunction("forgetPin") { SavedPin.delete() }
51
75
 
76
+ AsyncFunction("savedCertificate") { () -> Data? in SavedPin.certificate()?.certificate }
77
+
52
78
  OnDestroy {
53
- DispatchQueue.main.async { self.reader?.close(nil) }
79
+ DispatchQueue.main.async { self.reader?.close(nil, nil) }
54
80
  }
55
81
  }
82
+
83
+ /// Sends session `id`'s steps to JS as `onStep` events, without keeping the module alive.
84
+ private func reporter(_ id: Int) -> (String) -> Void {
85
+ { [weak self] step in self?.sendEvent("onStep", ["id": id, "step": step]) }
86
+ }
56
87
  }
57
88
 
58
89
  /// One NFCTagReaderSession; used on the main thread only.
59
90
  private final class Reader: NSObject, NFCTagReaderSessionDelegate {
60
91
  let id: Int
92
+ private var messages: [String: String] = [:]
93
+ private let onStep: (String) -> Void
61
94
  private var session: NFCTagReaderSession?
62
95
  private var pending: CheckedContinuation<Data, Error>?
63
96
  private var pin = ""
64
97
  private var save = false
65
98
  private var owner: Data? // NIS of the card the saved PIN belongs to
99
+ private var savedCertificate: Data? // that card's certificate file, when the session uses it instead of reading it
66
100
  private var biometrics: LAContext?
67
101
  private var closed = false
68
102
  private(set) var card: CieCard?
69
103
  private(set) var invalidation: Error?
70
104
 
71
- init(id: Int) {
105
+ init(id: Int, onStep: @escaping (String) -> Void) {
72
106
  self.id = id
107
+ self.onStep = onStep
108
+ }
109
+
110
+ /// Shows the NFC sheet text for `step`, if any.
111
+ func show(_ step: String) {
112
+ if let message = messages[step] { session?.alertMessage = message }
113
+ }
114
+
115
+ /// Shows `step` in the NFC sheet and reports it to JS.
116
+ private func reach(_ step: String) {
117
+ show(step)
118
+ onStep(step)
119
+ }
120
+
121
+ /// Unlocks the saved PIN with Face ID or Touch ID for `connect`. With `useSavedCertificate`, returns the certificate saved
122
+ /// with it, when it is of the same card: `connect` then returns it instead of reading it from the card.
123
+ func unlock(biometricPrompt: String, biometricCancel: String, useSavedCertificate: Bool) async throws -> Data? {
124
+ let context = LAContext()
125
+ context.localizedCancelTitle = biometricCancel
126
+ context.localizedFallbackTitle = ""
127
+ biometrics = context
128
+ (pin, owner) = try await SavedPin.unlock(context, reason: biometricPrompt)
129
+ guard !closed else { throw cancelled }
130
+ if useSavedCertificate, let saved = SavedPin.certificate(), saved.card == owner { savedCertificate = saved.certificate }
131
+ return savedCertificate
73
132
  }
74
133
 
75
- /// A nil `pin` unlocks the saved one with Face ID or Touch ID; `save` saves `pin` once the card accepts it, with no
76
- /// 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 {
134
+ /// A nil `pin` is the saved one `unlock` unlocked; `save` saves `pin` once the card accepts it, with no prompt, since
135
+ /// writing the Keychain item needs no authentication.
136
+ func connect(pin: String?, save: Bool, messages: [String: String]) async throws -> Data {
78
137
  if let pin {
79
138
  if save, !SavedPin.canSave { throw SavedPinError.biometryUnavailable }
80
139
  (self.pin, self.save) = (pin, save)
81
- } else {
82
- let context = LAContext()
83
- context.localizedCancelTitle = biometricCancel
84
- context.localizedFallbackTitle = ""
85
- biometrics = context
86
- (self.pin, owner) = try await SavedPin.unlock(context, reason: biometricPrompt)
140
+ } else if owner == nil {
141
+ throw SavedPinError.notSaved
87
142
  }
143
+ self.messages = messages
88
144
  guard !closed else { throw cancelled }
89
145
  return try await withCheckedThrowingContinuation { continuation in
90
146
  guard let session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: .main) else {
@@ -92,16 +148,22 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
92
148
  }
93
149
  pending = continuation
94
150
  self.session = session
95
- session.alertMessage = prompt
151
+ show("prompt")
96
152
  session.begin()
97
153
  }
98
154
  }
99
155
 
100
- func close(_ error: String?) {
156
+ /// Ends the session; the NFC sheet shows `error` if there is one, otherwise `done` next to its checkmark.
157
+ func close(_ error: String?, _ done: String?) {
101
158
  closed = true
102
159
  biometrics?.invalidate()
103
160
  finish(.failure(cancelled))
104
- if let error { session?.invalidate(errorMessage: error) } else { session?.invalidate() }
161
+ if let error {
162
+ session?.invalidate(errorMessage: error)
163
+ } else {
164
+ if let done { session?.alertMessage = done }
165
+ session?.invalidate()
166
+ }
105
167
  session = nil
106
168
  card = nil
107
169
  }
@@ -120,6 +182,7 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
120
182
  Task { @MainActor in
121
183
  do {
122
184
  try await session.connect(to: tags[0])
185
+ reach("detected")
123
186
  let card = CieCard { apdu in
124
187
  guard let command = NFCISO7816APDU(data: apdu) else { throw failure("CARD_ERROR", "Malformed APDU") }
125
188
  let (data, sw1, sw2) = try await tag.sendCommand(apdu: command)
@@ -127,10 +190,18 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
127
190
  }
128
191
  let serviceId = try await card.serviceId()
129
192
  if let owner, owner != serviceId { throw SavedPinError.otherCard }
193
+ reach("pin")
130
194
  try await card.open(pin: pin)
131
195
  if save { try SavedPin.store(pin, card: serviceId) }
132
- let certificate = try await card.certificate()
196
+ let certificate: Data
197
+ if let savedCertificate {
198
+ certificate = savedCertificate
199
+ } else {
200
+ certificate = try await card.certificate()
201
+ if save || owner != nil { SavedPin.storeCertificate(certificate, card: serviceId) }
202
+ }
133
203
  self.card = card
204
+ show("ready") // JS reports `ready` once connect resolves
134
205
  finish(.success(certificate))
135
206
  } catch let error as NFCReaderError where error.code == .readerTransceiveErrorSessionInvalidated {
136
207
  // didInvalidateWithError follows with the reason (cancel, timeout).
@@ -7,9 +7,11 @@ enum SavedPinError: Error {
7
7
  }
8
8
 
9
9
  /// The CIE PIN as a Keychain item that the Secure Enclave releases only after Face ID or Touch ID matches one of the
10
- /// faces or fingers enrolled when it was saved. It is never synced, backed up or restored to another device.
10
+ /// faces or fingers enrolled when it was saved. It is never synced, backed up or restored to another device. Next to it,
11
+ /// its card's certificate: public, so readable without biometrics, and replaced or deleted with the PIN.
11
12
  enum SavedPin {
12
13
  private static let item: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: "it.codingduck.ciesign.pin"]
14
+ private static let certificateItem: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: "it.codingduck.ciesign.certificate"]
13
15
 
14
16
  static var canSave: Bool { LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) }
15
17
 
@@ -63,8 +65,28 @@ enum SavedPin {
63
65
  return (String(decoding: pin, as: UTF8.self), card)
64
66
  }
65
67
 
68
+ /// Saves the certificate file of the card whose PIN is saved; `card` is its NIS. Best effort: without it, sessions read
69
+ /// the certificate from the card.
70
+ static func storeCertificate(_ certificate: Data, card: Data) {
71
+ SecItemDelete(certificateItem as CFDictionary)
72
+ let attributes: [CFString: Any] = [kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrGeneric: card, kSecValueData: certificate]
73
+ SecItemAdd(certificateItem.merging(attributes) { $1 } as CFDictionary, nil)
74
+ }
75
+
76
+ /// The certificate file saved with the PIN and its card's NIS, without any UI; nil when none is saved.
77
+ static func certificate() -> (certificate: Data, card: Data)? {
78
+ guard isSaved else { return nil }
79
+ var result: CFTypeRef?
80
+ let query = certificateItem.merging([kSecReturnData: true, kSecReturnAttributes: true]) { $1 }
81
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let found = result as? [CFString: Any],
82
+ let certificate = found[kSecValueData] as? Data, let card = found[kSecAttrGeneric] as? Data
83
+ else { return nil }
84
+ return (certificate, card)
85
+ }
86
+
66
87
  static func delete() {
67
88
  SecItemDelete(item as CFDictionary)
89
+ SecItemDelete(certificateItem as CFDictionary)
68
90
  }
69
91
 
70
92
  private static func access() -> SecAccessControl? {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciesign-react-native",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,44 @@ 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 (or taken from the saved one), `use` starts; `signing` a signature starts, once
15
+ * per `sign`; `done` `use` 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`, with the card certificate. `biometricPrompt`,
33
+ * `biometricCancel`: texts of the biometric prompt shown before the card is read, wherever the secure hardware requires
34
+ * one: `SAVED_PIN` everywhere, `savePin` on Android only. `useSavedCertificate`: with `SAVED_PIN`, skip reading the
35
+ * certificate from the card and use the one saved with the PIN (see `savedCertificate`), when there is one.
36
+ * `beforeCard`: with `SAVED_PIN`, runs once the biometric prompt has unlocked the PIN and before the NFC session waits for
37
+ * the card, with the saved certificate the session will use (`useSavedCertificate`), or null: the session waits for it, so
38
+ * what to sign can be prepared after the prompt and outside iOS's 60-second NFC limit.
39
+ */
40
+ export type CieOptions = {
41
+ prompt?: string;
42
+ error?: string;
43
+ messages?: Partial<Record<CieStep, string>>;
44
+ onStep?: (step: CieStep) => void;
45
+ savePin?: boolean;
46
+ biometricPrompt?: string;
47
+ biometricCancel?: string;
48
+ useSavedCertificate?: boolean;
49
+ beforeCard?: (savedCertificate: Bytes | null) => Promise<void>;
50
+ };
18
51
 
19
52
  /** A card whose PIN has been verified, for as long as `use` runs. */
20
53
  export type CieSession = {
@@ -38,15 +71,35 @@ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Prom
38
71
  savePin = false,
39
72
  biometricPrompt = pin === SAVED_PIN ? 'Sblocca il PIN della CIE' : 'Salva il PIN della CIE',
40
73
  biometricCancel = 'Annulla',
74
+ useSavedCertificate = false,
75
+ onStep,
76
+ beforeCard,
41
77
  } = options;
78
+ const message = (s: CieStep) => options.messages?.[s] ?? MESSAGES[s];
42
79
  const id = ++session;
43
80
  let done = false;
81
+ // Native events can arrive after `ready`: steps only move forward, except one `signing` per signature.
82
+ let reached = -1;
83
+ const step = (s: CieStep) => {
84
+ const i = STEPS.indexOf(s);
85
+ if (i < reached || (i === reached && s !== 'signing')) return;
86
+ reached = i;
87
+ onStep?.(s);
88
+ };
89
+ const subscription = onStep && Native.addListener('onStep', (event) => event.id === id && step(event.step));
44
90
  try {
45
- const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, prompt, biometricPrompt, biometricCancel));
91
+ const sheet = { prompt, detected: message('detected'), pin: message('pin'), ready: message('ready'), signing: message('signing') };
92
+ if (pin === SAVED_PIN) {
93
+ const saved = await Native.unlock(id, biometricPrompt, biometricCancel, useSavedCertificate);
94
+ await beforeCard?.(saved && trimDer(saved));
95
+ }
96
+ const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, sheet, biometricPrompt, biometricCancel));
97
+ step('ready');
46
98
  let last: Promise<unknown> = Promise.resolve();
47
99
  const sign = (digestInfo: Bytes) => {
48
100
  const input = digestInfo.slice(); // signed later, once the calls before it finish
49
101
  const signature = last.then(async () => {
102
+ step('signing');
50
103
  const signature = await Native.sign(id, input);
51
104
  verify(certificate, input, signature);
52
105
  return signature;
@@ -58,15 +111,27 @@ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Prom
58
111
  done = true;
59
112
  return result;
60
113
  } finally {
61
- Native.close(id, done ? null : error);
114
+ subscription?.remove();
115
+ Native.close(id, done ? null : error, done ? message('done') : null);
116
+ if (done) step('done');
62
117
  }
63
118
  }
64
119
 
65
120
  /** Aborts the CIE session in progress. */
66
- export const cancel = () => Native.close(session, null);
121
+ export const cancel = () => Native.close(session, null, null);
67
122
 
68
123
  /** Whether a PIN is saved, without showing any UI. */
69
124
  export const pinStatus = (): Promise<PinStatus> => Native.pinStatus();
70
125
 
71
- /** Deletes the saved PIN. */
126
+ /** Deletes the saved PIN and its certificate. */
72
127
  export const forgetPin = (): Promise<void> => Native.forgetPin();
128
+
129
+ /**
130
+ * The DER certificate of the card whose PIN is saved, without any UI: known before the card is on the phone, e.g. to
131
+ * prepare what to sign before a `SAVED_PIN` session with `useSavedCertificate`. Null when no PIN is saved, or none was
132
+ * saved with it yet: a `SAVED_PIN` session that reads the certificate from the card saves it.
133
+ */
134
+ export async function savedCertificate(): Promise<Bytes | null> {
135
+ const certificate = await Native.savedCertificate();
136
+ return certificate ? trimDer(certificate) : null;
137
+ }
package/src/native.ts CHANGED
@@ -3,19 +3,38 @@ 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 = {
13
+ /**
14
+ * Shows the biometric prompt and unlocks the saved PIN for session `id`, which `connect` with a null `pin` then sends to
15
+ * the card. Returns the certificate saved with the PIN when `useSavedCertificate` and it is of the same card: that
16
+ * session's `connect` returns it instead of reading it. Null otherwise.
17
+ */
18
+ unlock(id: number, biometricPrompt: string, biometricCancel: string, useSavedCertificate: boolean): Promise<Uint8Array | null>;
7
19
  /**
8
20
  * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
9
- * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
10
- * only where the secure hardware requires it: unlocking everywhere, saving on Android.
21
+ * A null `pin` is the one `unlock` unlocked for `id`; `save` saves `pin` once the card accepts it, after the biometric
22
+ * prompt where the secure hardware requires one (Android).
11
23
  */
12
- connect(id: number, pin: string | null, save: boolean, prompt: string, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
24
+ connect(id: number, pin: string | null, save: boolean, messages: NativeMessages, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
13
25
  /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
14
26
  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;
27
+ /**
28
+ * Ends every session up to `id`, also one whose connect has not arrived yet. iOS shows `error` in the NFC sheet, or
29
+ * else `done` next to its checkmark.
30
+ */
31
+ close(id: number, error: string | null, done: string | null): void;
17
32
  pinStatus(): Promise<PinStatus>;
18
33
  forgetPin(): Promise<void>;
34
+ /** The certificate file saved with the PIN, without any UI; null when none is saved. */
35
+ savedCertificate(): Promise<Uint8Array | null>;
36
+ /** `onStep` fires as session `id` reaches a step only the native side sees. */
37
+ addListener(event: 'onStep', listener: (event: { id: number; step: NativeStep }) => void): { remove(): void };
19
38
  };
20
39
 
21
40
  export default requireNativeModule<CieSignModule>('CieSign');