ciesign-react-native 0.2.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?, 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). |
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`.
@@ -101,7 +101,7 @@ A session goes through these steps. On iOS the NFC sheet shows the text of each
101
101
  |---|---|---|
102
102
  | `detected` | The card is on the phone. | CIE rilevata, non allontanarla |
103
103
  | `pin` | The PIN is being verified. | Verifica del PIN… |
104
- | `ready` | PIN verified and certificate read: `use` starts. | PIN verificato |
104
+ | `ready` | PIN verified and certificate read, or taken from the saved one: `use` starts. | PIN verificato |
105
105
  | `signing` | A signature starts, once per `sign`. | Firma in corso… |
106
106
  | `done` | `use` resolved and the session is over. The sheet shows it next to its checkmark. | Operazione completata |
107
107
 
@@ -138,9 +138,27 @@ const signatures =
138
138
  - **Native only.** A saved PIN is decrypted in native code and sent to the card; it never reaches JavaScript.
139
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'`.
140
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
+
141
159
  ## How it works
142
160
 
143
- `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.
144
162
 
145
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.
146
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,6 +28,7 @@ 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 {
@@ -35,36 +36,54 @@ class CieSignModule : Module() {
35
36
 
36
37
  Events("onStep")
37
38
 
38
- // A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Both show the biometric prompt first.
39
- // The messages are for the iOS NFC sheet: Android has none.
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.
40
65
  AsyncFunction("connect") { id: Int, pin: String?, save: Boolean, _: Map<String, String>, biometricPrompt: String, biometricCancel: String, promise: Promise ->
41
66
  val activity = appContext.currentActivity ?: throw CodedException("NFC_UNAVAILABLE", "No foreground activity", null)
42
67
  val adapter = NfcAdapter.getDefaultAdapter(activity)
43
68
  if (adapter?.isEnabled != true) throw CodedException("NFC_UNAVAILABLE", "NFC is missing or turned off", null)
44
- synchronized(lock) {
45
- if (id <= closed) throw cancelled()
46
- end()
47
- session = id
48
- 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
49
79
  }
50
80
  try {
51
81
  when {
52
- pin == null -> {
53
- val saved = savedPin.saved() ?: throw CodedException("PIN_NOT_SAVED", "No saved PIN", null)
54
- authenticate(activity, id, saved.cipher, biometricPrompt, biometricCancel) {
55
- val unlocked = try {
56
- String(it.doFinal(saved.pin))
57
- } catch (e: Exception) {
58
- savedPin.delete()
59
- throw CodedException("PIN_NOT_SAVED", "The saved PIN cannot be decrypted", e)
60
- }
61
- read(activity, adapter, id, unlocked, saved.card, null)
62
- }
63
- }
82
+ saved != null -> read(activity, adapter, id, saved.pin, saved.card, null, saved.certificate)
64
83
  save -> authenticate(activity, id, savedPin.encrypter(), biometricPrompt, biometricCancel) {
65
- read(activity, adapter, id, pin, null, savedPin.seal(it, pin))
84
+ read(activity, adapter, id, requireNotNull(pin), null, savedPin.seal(it, requireNotNull(pin)), null)
66
85
  }
67
- else -> read(activity, adapter, id, pin, null, null)
86
+ else -> read(activity, adapter, id, requireNotNull(pin), null, null, null)
68
87
  }
69
88
  } catch (e: Exception) {
70
89
  take(id)?.reject(coded(e))
@@ -91,16 +110,29 @@ class CieSignModule : Module() {
91
110
 
92
111
  AsyncFunction("forgetPin") { savedPin.delete() }
93
112
 
113
+ AsyncFunction("savedCertificate") { savedPin.certificate() }
114
+
94
115
  OnDestroy { synchronized(lock) { end() } }
95
116
  }
96
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
+
97
126
  private fun take(id: Int): Promise? = synchronized(lock) { pending.takeIf { session == id }.also { if (it != null) pending = null } }
98
127
 
99
- /** `owner`: NIS the saved PIN belongs to; `sealed`: encrypted PIN to save once the card accepts it. */
100
- 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?) {
101
133
  val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
102
134
  synchronized(lock) {
103
- 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)
104
136
  }
105
137
  }
106
138
 
@@ -133,7 +165,7 @@ class CieSignModule : Module() {
133
165
  }
134
166
 
135
167
  // Runs on the NFC thread; the tag stays connected after returning, until the session ends.
136
- 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?) {
137
169
  val promise = take(id) ?: return
138
170
  var pinStatus = 0
139
171
  try {
@@ -160,8 +192,9 @@ class CieSignModule : Module() {
160
192
  val nis = c.serviceId()
161
193
  if (owner != null && owner != nis) throw CodedException("WRONG_CARD", "Not the card the PIN was saved for", null)
162
194
  step(id, "pin")
163
- val certificate = c.open(pin)
195
+ c.open(pin)
164
196
  if (sealed != null) savedPin.store(nis, sealed)
197
+ val certificate = saved ?: c.certificate().also { if (sealed != null || owner != null) savedPin.storeCertificate(nis, it) }
165
198
  synchronized(lock) {
166
199
  if (session != id) throw cancelled()
167
200
  card = c
@@ -181,6 +214,7 @@ class CieSignModule : Module() {
181
214
  pending = null
182
215
  prompt?.let { appContext.currentActivity?.runOnUiThread(it::cancelAuthentication) }
183
216
  prompt = null
217
+ unlocked = null
184
218
  card = null
185
219
  runCatching { tag?.close() }
186
220
  tag = null
@@ -188,6 +222,9 @@ class CieSignModule : Module() {
188
222
  appContext.currentActivity?.let { NfcAdapter.getDefaultAdapter(it)?.disableReaderMode(it) }
189
223
  }
190
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
+
191
228
  private fun cancelled() = CodedException("CANCELLED", "Cancelled", null)
192
229
 
193
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
@@ -7,16 +7,20 @@ export declare const SAVED_PIN: unique symbol;
7
7
  export type Pin = string | typeof SAVED_PIN;
8
8
  /**
9
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.
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
12
  */
13
13
  export type CieStep = 'detected' | 'pin' | 'ready' | 'signing' | 'done';
14
14
  /**
15
15
  * `prompt`, `error`: texts of the iOS NFC sheet while it waits for the card and when the session fails; `messages`: its
16
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.
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.
20
24
  */
21
25
  export type CieOptions = {
22
26
  prompt?: string;
@@ -26,6 +30,8 @@ export type CieOptions = {
26
30
  savePin?: boolean;
27
31
  biometricPrompt?: string;
28
32
  biometricCancel?: string;
33
+ useSavedCertificate?: boolean;
34
+ beforeCard?: (savedCertificate: Bytes | null) => Promise<void>;
29
35
  };
30
36
  /** A card whose PIN has been verified, for as long as `use` runs. */
31
37
  export type CieSession = {
@@ -43,5 +49,11 @@ export declare function cieSession<T>(pin: Pin, use: (session: CieSession) => Pr
43
49
  export declare const cancel: () => void;
44
50
  /** Whether a PIN is saved, without showing any UI. */
45
51
  export declare const pinStatus: () => Promise<PinStatus>;
46
- /** Deletes the saved PIN. */
52
+ /** Deletes the saved PIN and its certificate. */
47
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
@@ -18,7 +18,7 @@ let session = 0; // id of the latest NFC session
18
18
  export async function cieSession(pin, use, options = {}) {
19
19
  if (pin !== SAVED_PIN && !/^\d{8}$/.test(pin))
20
20
  throw new Error('The CIE PIN has 8 digits');
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;
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
22
  const message = (s) => options.messages?.[s] ?? MESSAGES[s];
23
23
  const id = ++session;
24
24
  let done = false;
@@ -34,6 +34,10 @@ export async function cieSession(pin, use, options = {}) {
34
34
  const subscription = onStep && Native.addListener('onStep', (event) => event.id === id && step(event.step));
35
35
  try {
36
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
+ }
37
41
  const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, sheet, biometricPrompt, biometricCancel));
38
42
  step('ready');
39
43
  let last = Promise.resolve();
@@ -63,5 +67,14 @@ export async function cieSession(pin, use, options = {}) {
63
67
  export const cancel = () => Native.close(session, null, null);
64
68
  /** Whether a PIN is saved, without showing any UI. */
65
69
  export const pinStatus = () => Native.pinStatus();
66
- /** Deletes the saved PIN. */
70
+ /** Deletes the saved PIN and its certificate. */
67
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
@@ -11,10 +11,16 @@ export type NativeMessages = {
11
11
  signing: string;
12
12
  };
13
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>;
14
20
  /**
15
21
  * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
16
- * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
17
- * 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).
18
24
  */
19
25
  connect(id: number, pin: string | null, save: boolean, messages: NativeMessages, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
20
26
  /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
@@ -26,6 +32,8 @@ type CieSignModule = {
26
32
  close(id: number, error: string | null, done: string | null): void;
27
33
  pinStatus(): Promise<PinStatus>;
28
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>;
29
37
  /** `onStep` fires as session `id` reaches a step only the native side sees. */
30
38
  addListener(event: 'onStep', listener: (event: {
31
39
  id: number;
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])))
@@ -12,16 +12,36 @@ public class CieSignModule: Module {
12
12
 
13
13
  Events("onStep")
14
14
 
15
- // `messages`: NFC sheet texts by step, `prompt` first.
16
- AsyncFunction("connect") {
17
- @MainActor (id: Int, pin: String?, save: Bool, messages: [String: String], biometricPrompt: String, biometricCancel: String) async throws -> Data in
15
+ AsyncFunction("unlock") {
16
+ @MainActor (id: Int, biometricPrompt: String, biometricCancel: String, useSavedCertificate: Bool) async throws -> Data? in
18
17
  guard NFCTagReaderSession.readingAvailable else { throw failure("NFC_UNAVAILABLE", "NFC tag reading is not available") }
19
18
  guard id > self.closed else { throw cancelled }
20
19
  self.reader?.close(nil, nil)
21
- let reader = Reader(id: id, messages: messages, onStep: self.reporter(id))
20
+ let reader = Reader(id: id, onStep: self.reporter(id))
22
21
  self.reader = reader
23
22
  do {
24
- return try await reader.connect(pin: pin, save: save, 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)
25
45
  } catch {
26
46
  let error = coded(reader.invalidation ?? error)
27
47
  if pin == nil, let code = (error as? Exception)?.code, code.hasPrefix("WRONG_PIN") || code == "PIN_LOCKED" { SavedPin.delete() }
@@ -53,6 +73,8 @@ public class CieSignModule: Module {
53
73
 
54
74
  AsyncFunction("forgetPin") { SavedPin.delete() }
55
75
 
76
+ AsyncFunction("savedCertificate") { () -> Data? in SavedPin.certificate()?.certificate }
77
+
56
78
  OnDestroy {
57
79
  DispatchQueue.main.async { self.reader?.close(nil, nil) }
58
80
  }
@@ -67,21 +89,21 @@ public class CieSignModule: Module {
67
89
  /// One NFCTagReaderSession; used on the main thread only.
68
90
  private final class Reader: NSObject, NFCTagReaderSessionDelegate {
69
91
  let id: Int
70
- private let messages: [String: String]
92
+ private var messages: [String: String] = [:]
71
93
  private let onStep: (String) -> Void
72
94
  private var session: NFCTagReaderSession?
73
95
  private var pending: CheckedContinuation<Data, Error>?
74
96
  private var pin = ""
75
97
  private var save = false
76
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
77
100
  private var biometrics: LAContext?
78
101
  private var closed = false
79
102
  private(set) var card: CieCard?
80
103
  private(set) var invalidation: Error?
81
104
 
82
- init(id: Int, messages: [String: String], onStep: @escaping (String) -> Void) {
105
+ init(id: Int, onStep: @escaping (String) -> Void) {
83
106
  self.id = id
84
- self.messages = messages
85
107
  self.onStep = onStep
86
108
  }
87
109
 
@@ -96,19 +118,29 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
96
118
  onStep(step)
97
119
  }
98
120
 
99
- /// A nil `pin` unlocks the saved one with Face ID or Touch ID; `save` saves `pin` once the card accepts it, with no
100
- /// prompt, since writing the Keychain item needs no authentication.
101
- func connect(pin: String?, save: Bool, biometricPrompt: String, biometricCancel: String) async throws -> Data {
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
132
+ }
133
+
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 {
102
137
  if let pin {
103
138
  if save, !SavedPin.canSave { throw SavedPinError.biometryUnavailable }
104
139
  (self.pin, self.save) = (pin, save)
105
- } else {
106
- let context = LAContext()
107
- context.localizedCancelTitle = biometricCancel
108
- context.localizedFallbackTitle = ""
109
- biometrics = context
110
- (self.pin, owner) = try await SavedPin.unlock(context, reason: biometricPrompt)
140
+ } else if owner == nil {
141
+ throw SavedPinError.notSaved
111
142
  }
143
+ self.messages = messages
112
144
  guard !closed else { throw cancelled }
113
145
  return try await withCheckedThrowingContinuation { continuation in
114
146
  guard let session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: .main) else {
@@ -161,7 +193,13 @@ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
161
193
  reach("pin")
162
194
  try await card.open(pin: pin)
163
195
  if save { try SavedPin.store(pin, card: serviceId) }
164
- 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
+ }
165
203
  self.card = card
166
204
  show("ready") // JS reports `ready` once connect resolves
167
205
  finish(.success(certificate))
@@ -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.2.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
@@ -11,8 +11,8 @@ export type Pin = string | typeof SAVED_PIN;
11
11
 
12
12
  /**
13
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.
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
17
  export type CieStep = 'detected' | 'pin' | 'ready' | 'signing' | 'done';
18
18
 
@@ -29,9 +29,13 @@ const MESSAGES: Record<CieStep, string> = {
29
29
  /**
30
30
  * `prompt`, `error`: texts of the iOS NFC sheet while it waits for the card and when the session fails; `messages`: its
31
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.
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.
35
39
  */
36
40
  export type CieOptions = {
37
41
  prompt?: string;
@@ -41,6 +45,8 @@ export type CieOptions = {
41
45
  savePin?: boolean;
42
46
  biometricPrompt?: string;
43
47
  biometricCancel?: string;
48
+ useSavedCertificate?: boolean;
49
+ beforeCard?: (savedCertificate: Bytes | null) => Promise<void>;
44
50
  };
45
51
 
46
52
  /** A card whose PIN has been verified, for as long as `use` runs. */
@@ -65,7 +71,9 @@ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Prom
65
71
  savePin = false,
66
72
  biometricPrompt = pin === SAVED_PIN ? 'Sblocca il PIN della CIE' : 'Salva il PIN della CIE',
67
73
  biometricCancel = 'Annulla',
74
+ useSavedCertificate = false,
68
75
  onStep,
76
+ beforeCard,
69
77
  } = options;
70
78
  const message = (s: CieStep) => options.messages?.[s] ?? MESSAGES[s];
71
79
  const id = ++session;
@@ -81,6 +89,10 @@ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Prom
81
89
  const subscription = onStep && Native.addListener('onStep', (event) => event.id === id && step(event.step));
82
90
  try {
83
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
+ }
84
96
  const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, sheet, biometricPrompt, biometricCancel));
85
97
  step('ready');
86
98
  let last: Promise<unknown> = Promise.resolve();
@@ -111,5 +123,15 @@ export const cancel = () => Native.close(session, null, null);
111
123
  /** Whether a PIN is saved, without showing any UI. */
112
124
  export const pinStatus = (): Promise<PinStatus> => Native.pinStatus();
113
125
 
114
- /** Deletes the saved PIN. */
126
+ /** Deletes the saved PIN and its certificate. */
115
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
@@ -10,10 +10,16 @@ export type NativeStep = 'detected' | 'pin';
10
10
  export type NativeMessages = { prompt: string; detected: string; pin: string; ready: string; signing: string };
11
11
 
12
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>;
13
19
  /**
14
20
  * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
15
- * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
16
- * 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).
17
23
  */
18
24
  connect(id: number, pin: string | null, save: boolean, messages: NativeMessages, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
19
25
  /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
@@ -25,6 +31,8 @@ type CieSignModule = {
25
31
  close(id: number, error: string | null, done: string | null): void;
26
32
  pinStatus(): Promise<PinStatus>;
27
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>;
28
36
  /** `onStep` fires as session `id` reaches a step only the native side sees. */
29
37
  addListener(event: 'onStep', listener: (event: { id: number; step: NativeStep }) => void): { remove(): void };
30
38
  };