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 +42 -3
- package/android/src/main/java/it/codingduck/ciesign/CieCard.java +6 -2
- package/android/src/main/java/it/codingduck/ciesign/CieSignModule.kt +71 -27
- package/android/src/main/java/it/codingduck/ciesign/SavedPin.kt +17 -1
- package/build/index.d.ts +26 -4
- package/build/index.js +43 -5
- package/build/native.d.ts +33 -5
- package/ios/CieCard.swift +5 -1
- package/ios/CieSignModule.swift +93 -22
- package/ios/SavedPin.swift +23 -1
- package/package.json +1 -1
- package/src/index.ts +73 -8
- package/src/native.ts +24 -5
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
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
97
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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`
|
|
7
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
13
|
-
|
|
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(
|
|
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])))
|
package/ios/CieSignModule.swift
CHANGED
|
@@ -10,15 +10,38 @@ public class CieSignModule: Module {
|
|
|
10
10
|
public func definition() -> ModuleDefinition {
|
|
11
11
|
Name("CieSign")
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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`
|
|
76
|
-
///
|
|
77
|
-
func connect(pin: String?, save: Bool,
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
show("prompt")
|
|
96
152
|
session.begin()
|
|
97
153
|
}
|
|
98
154
|
}
|
|
99
155
|
|
|
100
|
-
|
|
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 {
|
|
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
|
|
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).
|
package/ios/SavedPin.swift
CHANGED
|
@@ -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
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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`
|
|
10
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
16
|
-
|
|
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');
|