ciesign-react-native 0.0.0-stage → 0.1.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/LICENSE +22 -0
- package/README.md +145 -2
- package/android/build.gradle +21 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/it/codingduck/ciesign/CieCard.java +30 -0
- package/android/src/main/java/it/codingduck/ciesign/CieSignModule.kt +207 -0
- package/android/src/main/java/it/codingduck/ciesign/SavedPin.kt +119 -0
- package/app.plugin.js +25 -0
- package/build/certificate.d.ts +8 -0
- package/build/certificate.js +44 -0
- package/build/index.d.ts +37 -0
- package/build/index.js +42 -0
- package/build/native.d.ts +18 -0
- package/build/native.js +2 -0
- package/expo-module.config.json +9 -0
- package/ios/CieCard.swift +330 -0
- package/ios/CieSign.podspec +18 -0
- package/ios/CieSignModule.swift +176 -0
- package/ios/SavedPin.swift +73 -0
- package/package.json +45 -4
- package/src/certificate.ts +47 -0
- package/src/index.ts +72 -0
- package/src/native.ts +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Coding Duck S.r.l.
|
|
4
|
+
Copyright (c) 2025 PagoPA S.p.A. (ios/CieCard.swift is derived from pagopa/cie-sdk-ios)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ciesign-react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
RSA signatures with the Italian electronic identity card (CIE 3.0) over NFC, for React Native. The library reads the card certificate and returns raw signatures; packaging them (PAdES, CAdES, XAdES…) is up to your code or your server.
|
|
4
|
+
|
|
5
|
+
- One NFC tap and one PIN check, then as many signatures as you need. The card verifies the 8-digit PIN and signs with its PIN-protected key (RSA PKCS#1 v1.5); each signature is checked against the card certificate before it is returned.
|
|
6
|
+
- Optionally, the PIN is saved behind Face ID, Touch ID or a fingerprint, protected by the Secure Enclave or the Android Keystore's secure hardware.
|
|
7
|
+
|
|
8
|
+
A CIE signature is an advanced electronic signature (FEA), not a qualified one (FEQ/firma digitale). Validators don't trust the CIE CA by default, so recipients see an unknown signer until they trust it.
|
|
9
|
+
|
|
10
|
+
> **Status:** not yet tested with a physical CIE, nor several signatures in one session. The iOS card protocol was compared byte by byte with PagoPA's Android SDK on a simulated card.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Requires iOS 16.4 or later, or an Android device with NFC. Expo module: it needs a development build (not Expo Go). Bare React Native apps need [Expo modules](https://docs.expo.dev/bare/installing-expo-modules/).
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx expo install ciesign-react-native
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{ "expo": { "plugins": ["ciesign-react-native"] } }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The config plugin sets up iOS: the NFC tag reading entitlement, `NFCReaderUsageDescription` and `NSFaceIDUsageDescription` (override them with `["ciesign-react-native", { "nfcUsageDescription": "…", "faceIdUsageDescription": "…" }]`; existing values are kept) and the ISO 7816 application IDs. Without the plugin, add them by hand:
|
|
25
|
+
|
|
26
|
+
```xml
|
|
27
|
+
<!-- *.entitlements -->
|
|
28
|
+
<key>com.apple.developer.nfc.readersession.formats</key>
|
|
29
|
+
<array><string>TAG</string></array>
|
|
30
|
+
|
|
31
|
+
<!-- Info.plist -->
|
|
32
|
+
<key>NFCReaderUsageDescription</key>
|
|
33
|
+
<string>…</string>
|
|
34
|
+
<key>NSFaceIDUsageDescription</key> <!-- saved PIN only -->
|
|
35
|
+
<string>…</string>
|
|
36
|
+
<!-- keep this order -->
|
|
37
|
+
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
|
|
38
|
+
<array>
|
|
39
|
+
<string>A0000002471001</string>
|
|
40
|
+
<string>A0000000308000000009816001</string>
|
|
41
|
+
<string>A00000000039</string>
|
|
42
|
+
<string>A0000002472001</string>
|
|
43
|
+
<string>00000000000000</string>
|
|
44
|
+
</array>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Android needs nothing: the `NFC` and biometric permissions come with the library.
|
|
48
|
+
|
|
49
|
+
## Use
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { cieSession } from 'ciesign-react-native';
|
|
53
|
+
|
|
54
|
+
// DER DigestInfo of a SHA-256 hash: the algorithm prefix, then the 32-byte hash
|
|
55
|
+
const SHA256 = [0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20];
|
|
56
|
+
const digestInfo = (hash: Uint8Array) => Uint8Array.from([...SHA256, ...hash]);
|
|
57
|
+
|
|
58
|
+
const signatures = await cieSession(pin, async ({ certificate, sign }) => {
|
|
59
|
+
// e.g. CMS signed attributes, which reference the certificate
|
|
60
|
+
const hashes = await Promise.all(documents.map((document) => hashToSign(document, certificate)));
|
|
61
|
+
return Promise.all(hashes.map((hash) => sign(digestInfo(hash))));
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Inputs and outputs are `Uint8Array`. The promise stays pending until the card is tapped.
|
|
66
|
+
|
|
67
|
+
| Export | |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `cieSession(pin, use, { prompt?, error?, savePin?, biometricPrompt?, biometricCancel? })` | Waits for the card and verifies the PIN, then resolves to what `use({ certificate, sign })` resolves to. The NFC session ends when `use` settles: the card must stay on the phone until then. `pin` is the 8-digit PIN or `SAVED_PIN`. `prompt` and `error` are the iOS NFC sheet texts; the others are explained under [Saved PIN](#saved-pin). |
|
|
70
|
+
| `certificate` | The card's DER authentication certificate: holder name and fiscal code are in its subject. |
|
|
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). |
|
|
73
|
+
| `cancel()` | Aborts the session in progress. Android has no system NFC sheet, so give users a cancel button. |
|
|
74
|
+
|
|
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`.
|
|
76
|
+
|
|
77
|
+
A PIN that is not 8 digits throws a plain `Error`. Card errors carry a `code`:
|
|
78
|
+
|
|
79
|
+
| Code | Meaning |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `WRONG_PIN_2`, `WRONG_PIN_1` | Wrong PIN, attempts left. Don't retry automatically. |
|
|
82
|
+
| `PIN_LOCKED` | Three wrong PINs: the card needs the PUK. |
|
|
83
|
+
| `NOT_A_CIE` | The tag is not a CIE. |
|
|
84
|
+
| `TAG_LOST` | The card moved away during the operation. |
|
|
85
|
+
| `CANCELLED` | `cancel()`, a newer session started, `sign` called after `use` settled, or the user closed the iOS sheet or the biometric prompt. |
|
|
86
|
+
| `TIMEOUT` | iOS ends NFC sessions after 60 seconds. |
|
|
87
|
+
| `BUSY` | iOS is still closing the previous NFC session: retry. |
|
|
88
|
+
| `NFC_UNAVAILABLE` | No NFC, or, on Android, NFC turned off or no foreground activity. |
|
|
89
|
+
| `CARD_ERROR` | Any other card or protocol failure, including a signature that does not verify against the card certificate. |
|
|
90
|
+
| `PIN_NOT_SAVED` | `SAVED_PIN`, but no PIN is saved, or it was deleted because the enrolled biometrics changed or the device passcode was removed. Ask for the PIN. |
|
|
91
|
+
| `WRONG_CARD` | `SAVED_PIN`, but the card is not the one the PIN was saved for. The PIN was not sent. |
|
|
92
|
+
| `BIOMETRIC_UNAVAILABLE` | `SAVED_PIN` or `savePin` without a usable strong biometric: locked out after failed attempts, Face ID denied to the app, no secure hardware, or, for `savePin`, none enrolled. On Android, also when the activity is not a `FragmentActivity`. |
|
|
93
|
+
|
|
94
|
+
On Android the app must stay in the foreground while the card is read.
|
|
95
|
+
|
|
96
|
+
### Saved PIN
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
A biometric prompt appears only where the secure hardware requires one: to unlock on both platforms, and to save on Android, where the Keystore key encrypts only after a biometric. iOS saves with no prompt, because writing a Keychain item needs no authentication. Prompts come before the card is read; `biometricPrompt` and `biometricCancel` set their texts.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { SAVED_PIN, cieSession, pinStatus } from 'ciesign-react-native';
|
|
104
|
+
|
|
105
|
+
const status = await pinStatus(); // 'saved' | 'available' | 'unavailable'
|
|
106
|
+
const signatures =
|
|
107
|
+
status === 'saved'
|
|
108
|
+
? await cieSession(SAVED_PIN, use, { biometricPrompt: 'Firma i verbali' })
|
|
109
|
+
: await cieSession(pin, use, { savePin: status === 'available' && userWantsBiometrics });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **Biometrics only.** iOS: a Keychain item with `.biometryCurrentSet`, available only while a passcode is set, never synced, backed up or moved to another device. Android: an AES-256-GCM Keystore key, in StrongBox when the device has it, otherwise in the TEE (software keys are refused). It encrypts and decrypts only right after a Class 3 biometric, through `BiometricPrompt` with a `CryptoObject`; the encrypted PIN is in `noBackupFilesDir`. The device passcode never unlocks it, and on Android face unlock only counts where it is Class 3.
|
|
113
|
+
- **Cancelling the prompt cancels the operation** (`CANCELLED`): nothing is read or signed. After `CANCELLED` or `BIOMETRIC_UNAVAILABLE`, ask for the PIN, or retry without `savePin` to sign without saving.
|
|
114
|
+
- **Enrolling a new face or finger deletes it**, and so does removing one on iOS, or all of them on Android, or turning off the device passcode (`PIN_NOT_SAVED` on the next use).
|
|
115
|
+
- **Saved only after the card accepts it**, and deleted on `WRONG_PIN_*` or `PIN_LOCKED`: a PIN changed elsewhere costs one attempt, not three.
|
|
116
|
+
- **Bound to its card.** The card's NIS (EF 1001), readable without the PIN, is checked first: another CIE gets `WRONG_CARD` and never sees the PIN.
|
|
117
|
+
- **Native only.** A saved PIN is decrypted in native code and sent to the card; it never reaches JavaScript.
|
|
118
|
+
- 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
|
+
|
|
120
|
+
## How it works
|
|
121
|
+
|
|
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.
|
|
123
|
+
|
|
124
|
+
- 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
|
+
- 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.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
Needs Node.js 24 and OpenSSL.
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
npm install
|
|
133
|
+
npm test # simulates the card with a throwaway RSA key made by OpenSSL
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`npm test` covers the JavaScript side only: try changes to `ios/` and `android/` in a development build with a real CIE.
|
|
137
|
+
|
|
138
|
+
Issues and pull requests are welcome, especially reports from physical cards. Report vulnerabilities privately, as described in [SECURITY.md](SECURITY.md).
|
|
139
|
+
|
|
140
|
+
## Legal
|
|
141
|
+
|
|
142
|
+
If your customers sign with the CIE in their dealings with you, you are an FEA provider under DPCM 22/02/2013 art. 55 c.2 lett. a, with the obligations of art. 57 (identification, information, insurance, 20-year retention). Get legal advice before offering CIE signing to third parties.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
[MIT](LICENSE). `ios/CieCard.swift` is derived from PagoPA's [`cie-sdk-ios`](https://github.com/pagopa/cie-sdk-ios), also MIT.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'expo-module-gradle-plugin'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = 'it.codingduck.ciesign'
|
|
7
|
+
version = '0.1.0'
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace 'it.codingduck.ciesign'
|
|
11
|
+
defaultConfig {
|
|
12
|
+
versionCode 1
|
|
13
|
+
versionName '0.1.0'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
dependencies {
|
|
18
|
+
// Exact version: CieCard.java calls classes that are Kotlin-internal in this release.
|
|
19
|
+
implementation 'it.pagopa.io.app.cie:cie:0.1.11'
|
|
20
|
+
implementation 'androidx.biometric:biometric:1.1.0'
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package it.codingduck.ciesign;
|
|
2
|
+
|
|
3
|
+
import it.pagopa.io.app.cie.cie.NfcEvent;
|
|
4
|
+
import it.pagopa.io.app.cie.cie.OnTransmit;
|
|
5
|
+
import it.pagopa.io.app.cie.cie.commands.CieCommands;
|
|
6
|
+
|
|
7
|
+
/** PagoPA's CieCommands is Kotlin-internal: public in bytecode, so Java can call it and Kotlin cannot. */
|
|
8
|
+
final class CieCard {
|
|
9
|
+
private final CieCommands commands;
|
|
10
|
+
|
|
11
|
+
CieCard(OnTransmit transmit) {
|
|
12
|
+
commands = new CieCommands(transmit);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** NIS from EF.ID_Servizi, as hex: identifies the card and is readable without the PIN. */
|
|
16
|
+
String serviceId() throws Exception {
|
|
17
|
+
return commands.getServiceID();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Opens secure messaging, verifies the PIN and returns the certificate file (EF 1003, zero padded). */
|
|
21
|
+
byte[] open(String pin) throws Exception {
|
|
22
|
+
commands.startSecureChannel(pin);
|
|
23
|
+
return commands.readCertCie();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** RSA signature of a DigestInfo with the PIN-protected key; the card adds the PKCS#1 v1.5 padding. */
|
|
27
|
+
byte[] sign(byte[] digestInfo) throws Exception {
|
|
28
|
+
return commands.sign(digestInfo, NfcEvent.SIGN);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
package it.codingduck.ciesign
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.nfc.NfcAdapter
|
|
5
|
+
import android.nfc.Tag
|
|
6
|
+
import android.nfc.tech.IsoDep
|
|
7
|
+
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
|
8
|
+
import androidx.biometric.BiometricPrompt
|
|
9
|
+
import androidx.fragment.app.FragmentActivity
|
|
10
|
+
import expo.modules.kotlin.Promise
|
|
11
|
+
import expo.modules.kotlin.exception.CodedException
|
|
12
|
+
import expo.modules.kotlin.modules.Module
|
|
13
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
14
|
+
import it.pagopa.io.app.cie.cie.ApduResponse
|
|
15
|
+
import it.pagopa.io.app.cie.cie.CieSdkException
|
|
16
|
+
import it.pagopa.io.app.cie.cie.NfcError
|
|
17
|
+
import it.pagopa.io.app.cie.cie.NfcEvent
|
|
18
|
+
import it.pagopa.io.app.cie.cie.OnTransmit
|
|
19
|
+
import java.io.IOException
|
|
20
|
+
import javax.crypto.Cipher
|
|
21
|
+
|
|
22
|
+
class CieSignModule : Module() {
|
|
23
|
+
// Session state, guarded by `lock`. Ids come from JS; sessions up to `closed` are over, even if their connect arrives later.
|
|
24
|
+
private val lock = Any()
|
|
25
|
+
private var session = 0
|
|
26
|
+
private var closed = 0
|
|
27
|
+
private var pending: Promise? = null
|
|
28
|
+
private var tag: IsoDep? = null
|
|
29
|
+
private var card: CieCard? = null
|
|
30
|
+
private var prompt: BiometricPrompt? = null
|
|
31
|
+
private val savedPin by lazy { SavedPin(requireNotNull(appContext.reactContext)) }
|
|
32
|
+
|
|
33
|
+
override fun definition() = ModuleDefinition {
|
|
34
|
+
Name("CieSign")
|
|
35
|
+
|
|
36
|
+
// A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Both show the biometric prompt first.
|
|
37
|
+
AsyncFunction("connect") { id: Int, pin: String?, save: Boolean, _: String, biometricPrompt: String, biometricCancel: String, promise: Promise ->
|
|
38
|
+
val activity = appContext.currentActivity ?: throw CodedException("NFC_UNAVAILABLE", "No foreground activity", null)
|
|
39
|
+
val adapter = NfcAdapter.getDefaultAdapter(activity)
|
|
40
|
+
if (adapter?.isEnabled != true) throw CodedException("NFC_UNAVAILABLE", "NFC is missing or turned off", null)
|
|
41
|
+
synchronized(lock) {
|
|
42
|
+
if (id <= closed) throw cancelled()
|
|
43
|
+
end()
|
|
44
|
+
session = id
|
|
45
|
+
pending = promise
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
when {
|
|
49
|
+
pin == null -> {
|
|
50
|
+
val saved = savedPin.saved() ?: throw CodedException("PIN_NOT_SAVED", "No saved PIN", null)
|
|
51
|
+
authenticate(activity, id, saved.cipher, biometricPrompt, biometricCancel) {
|
|
52
|
+
val unlocked = try {
|
|
53
|
+
String(it.doFinal(saved.pin))
|
|
54
|
+
} catch (e: Exception) {
|
|
55
|
+
savedPin.delete()
|
|
56
|
+
throw CodedException("PIN_NOT_SAVED", "The saved PIN cannot be decrypted", e)
|
|
57
|
+
}
|
|
58
|
+
read(activity, adapter, id, unlocked, saved.card, null)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
save -> authenticate(activity, id, savedPin.encrypter(), biometricPrompt, biometricCancel) {
|
|
62
|
+
read(activity, adapter, id, pin, null, savedPin.seal(it, pin))
|
|
63
|
+
}
|
|
64
|
+
else -> read(activity, adapter, id, pin, null, null)
|
|
65
|
+
}
|
|
66
|
+
} catch (e: Exception) {
|
|
67
|
+
take(id)?.reject(coded(e))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
AsyncFunction("sign") { id: Int, digestInfo: ByteArray ->
|
|
72
|
+
val card = synchronized(lock) { card.takeIf { session == id } } ?: throw cancelled()
|
|
73
|
+
try {
|
|
74
|
+
card.sign(digestInfo)
|
|
75
|
+
} catch (e: Exception) {
|
|
76
|
+
throw if (synchronized(lock) { session != id }) cancelled() else coded(e)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Function("close") { id: Int, _: String? ->
|
|
81
|
+
synchronized(lock) {
|
|
82
|
+
closed = maxOf(closed, id)
|
|
83
|
+
if (session in 1..id) end()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
AsyncFunction("pinStatus") { savedPin.status() }
|
|
88
|
+
|
|
89
|
+
AsyncFunction("forgetPin") { savedPin.delete() }
|
|
90
|
+
|
|
91
|
+
OnDestroy { synchronized(lock) { end() } }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun take(id: Int): Promise? = synchronized(lock) { pending.takeIf { session == id }.also { if (it != null) pending = null } }
|
|
95
|
+
|
|
96
|
+
/** `owner`: NIS the saved PIN belongs to; `sealed`: encrypted PIN to save once the card accepts it. */
|
|
97
|
+
private fun read(activity: Activity, adapter: NfcAdapter, id: Int, pin: String, owner: String?, sealed: String?) {
|
|
98
|
+
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
|
99
|
+
synchronized(lock) {
|
|
100
|
+
if (session == id) adapter.enableReaderMode(activity, { onTag(it, id, pin, owner, sealed) }, flags, null)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Shows the biometric prompt for the Keystore `cipher`; once a strong biometric unlocks it, passes it to [then]. */
|
|
105
|
+
private fun authenticate(activity: Activity, id: Int, cipher: Cipher, title: String, cancel: String, then: (Cipher) -> Unit) {
|
|
106
|
+
val host = activity as? FragmentActivity ?: throw CodedException("BIOMETRIC_UNAVAILABLE", "The activity is not a FragmentActivity", null)
|
|
107
|
+
val info = BiometricPrompt.PromptInfo.Builder().setTitle(title).setNegativeButtonText(cancel).setAllowedAuthenticators(BIOMETRIC_STRONG).build()
|
|
108
|
+
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
|
109
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
110
|
+
try {
|
|
111
|
+
then(requireNotNull(result.cryptoObject?.cipher))
|
|
112
|
+
} catch (e: Exception) {
|
|
113
|
+
take(id)?.reject(coded(e))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override fun onAuthenticationError(code: Int, message: CharSequence) {
|
|
118
|
+
take(id)?.reject(
|
|
119
|
+
when (code) {
|
|
120
|
+
BiometricPrompt.ERROR_NEGATIVE_BUTTON, BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_TIMEOUT -> cancelled()
|
|
121
|
+
else -> CodedException("BIOMETRIC_UNAVAILABLE", message.toString(), null)
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
activity.runOnUiThread {
|
|
127
|
+
val prompt = BiometricPrompt(host, { activity.runOnUiThread(it) }, callback)
|
|
128
|
+
if (synchronized(lock) { (session == id).also { if (it) this.prompt = prompt } }) prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 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?) {
|
|
134
|
+
val promise = take(id) ?: return
|
|
135
|
+
var pinStatus = 0
|
|
136
|
+
try {
|
|
137
|
+
val isoDep = IsoDep.get(discovered) ?: throw CodedException("NOT_A_CIE", "Not an ISO 14443-4 card", null)
|
|
138
|
+
isoDep.timeout = 10_000
|
|
139
|
+
isoDep.connect()
|
|
140
|
+
synchronized(lock) {
|
|
141
|
+
if (session != id) {
|
|
142
|
+
isoDep.close()
|
|
143
|
+
throw cancelled()
|
|
144
|
+
}
|
|
145
|
+
tag = isoDep
|
|
146
|
+
}
|
|
147
|
+
val c = CieCard(object : OnTransmit {
|
|
148
|
+
override fun sendCommand(apdu: ByteArray, nfcEvents: NfcEvent): ApduResponse {
|
|
149
|
+
val response = isoDep.transceive(apdu)
|
|
150
|
+
if (nfcEvents == NfcEvent.VERIFY_PIN) pinStatus = status(response)
|
|
151
|
+
return ApduResponse(response)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override fun error(error: NfcError) = Unit
|
|
155
|
+
})
|
|
156
|
+
val nis = c.serviceId()
|
|
157
|
+
if (owner != null && owner != nis) throw CodedException("WRONG_CARD", "Not the card the PIN was saved for", null)
|
|
158
|
+
val certificate = c.open(pin)
|
|
159
|
+
if (sealed != null) savedPin.store(nis, sealed)
|
|
160
|
+
synchronized(lock) {
|
|
161
|
+
if (session != id) throw cancelled()
|
|
162
|
+
card = c
|
|
163
|
+
}
|
|
164
|
+
promise.resolve(certificate)
|
|
165
|
+
} catch (e: Exception) {
|
|
166
|
+
val error = coded(e, pinStatus)
|
|
167
|
+
if (owner != null && (error.code.startsWith("WRONG_PIN") || error.code == "PIN_LOCKED")) savedPin.delete()
|
|
168
|
+
promise.reject(if (synchronized(lock) { session != id }) cancelled() else error)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun end() {
|
|
173
|
+
pending?.reject(cancelled())
|
|
174
|
+
pending = null
|
|
175
|
+
prompt?.let { appContext.currentActivity?.runOnUiThread(it::cancelAuthentication) }
|
|
176
|
+
prompt = null
|
|
177
|
+
card = null
|
|
178
|
+
runCatching { tag?.close() }
|
|
179
|
+
tag = null
|
|
180
|
+
session = 0
|
|
181
|
+
appContext.currentActivity?.let { NfcAdapter.getDefaultAdapter(it)?.disableReaderMode(it) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private fun cancelled() = CodedException("CANCELLED", "Cancelled", null)
|
|
185
|
+
|
|
186
|
+
// Under secure messaging the card's status word is in DO 99, ahead of the MAC; otherwise it ends the response.
|
|
187
|
+
private fun status(response: ByteArray): Int {
|
|
188
|
+
val i = if (response.size >= 4 && response[0] == 0x99.toByte() && response[1] == 0x02.toByte()) 2 else response.size - 2
|
|
189
|
+
return ((response[i].toInt() and 0xFF) shl 8) or (response[i + 1].toInt() and 0xFF)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// PagoPA's SDK reports any unexpected VERIFY status as PIN_BLOCKED, so the status word decides.
|
|
193
|
+
private fun coded(e: Exception, pinStatus: Int = 0): CodedException {
|
|
194
|
+
val error = (e as? CieSdkException)?.getError()
|
|
195
|
+
return when {
|
|
196
|
+
e is CodedException -> e
|
|
197
|
+
error == NfcError.WRONG_PIN || error == NfcError.PIN_BLOCKED -> when {
|
|
198
|
+
pinStatus == 0x6983 || pinStatus == 0x63C0 -> CodedException("PIN_LOCKED", "PIN locked: unlock it with the PUK", e)
|
|
199
|
+
(pinStatus and 0xFFF0) == 0x63C0 -> CodedException("WRONG_PIN_${pinStatus and 0xF}", "Wrong PIN", e)
|
|
200
|
+
else -> CodedException("CARD_ERROR", "PIN verification failed: %04X".format(pinStatus), e)
|
|
201
|
+
}
|
|
202
|
+
error == NfcError.NOT_A_CIE -> CodedException("NOT_A_CIE", "Not a CIE", e)
|
|
203
|
+
e is IOException || e is SecurityException -> CodedException("TAG_LOST", "Card connection lost", e)
|
|
204
|
+
else -> CodedException("CARD_ERROR", error?.name ?: e.message ?: e.toString(), e)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
package it.codingduck.ciesign
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
7
|
+
import android.security.keystore.KeyInfo
|
|
8
|
+
import android.security.keystore.KeyPermanentlyInvalidatedException
|
|
9
|
+
import android.security.keystore.KeyProperties
|
|
10
|
+
import android.util.Base64
|
|
11
|
+
import androidx.biometric.BiometricManager
|
|
12
|
+
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
|
13
|
+
import expo.modules.kotlin.exception.CodedException
|
|
14
|
+
import java.io.File
|
|
15
|
+
import java.security.KeyStore
|
|
16
|
+
import javax.crypto.Cipher
|
|
17
|
+
import javax.crypto.KeyGenerator
|
|
18
|
+
import javax.crypto.SecretKey
|
|
19
|
+
import javax.crypto.SecretKeyFactory
|
|
20
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
21
|
+
|
|
22
|
+
/**
|
|
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
|
+
* encrypts and decrypts only right after a strong biometric matches one enrolled when it was made. The file is not backed up.
|
|
25
|
+
*/
|
|
26
|
+
internal class SavedPin(private val context: Context) {
|
|
27
|
+
/** The encrypted PIN, the NIS of its card and the decrypt cipher that a biometric unlocks. */
|
|
28
|
+
class Saved(val card: String, val pin: ByteArray, val cipher: Cipher)
|
|
29
|
+
|
|
30
|
+
private val file = File(context.noBackupFilesDir, "ciesign-pin")
|
|
31
|
+
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
|
32
|
+
|
|
33
|
+
val canSave get() = BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
|
|
34
|
+
|
|
35
|
+
fun status() = when {
|
|
36
|
+
saved() != null -> "saved"
|
|
37
|
+
canSave -> "available"
|
|
38
|
+
else -> "unavailable"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Null when nothing is saved, or when a biometric enrolled since then invalidated the key. */
|
|
42
|
+
fun saved(): Saved? {
|
|
43
|
+
val (card, iv, pin) = runCatching { file.readLines() }.getOrNull()?.takeIf { it.size == 3 } ?: return null
|
|
44
|
+
val cipher = cipher(Cipher.DECRYPT_MODE, Base64.decode(iv, Base64.NO_WRAP)) ?: return null.also { delete() }
|
|
45
|
+
return Saved(card, Base64.decode(pin, Base64.NO_WRAP), cipher)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Encrypt cipher that a biometric unlocks; makes the key first when there is no valid one. */
|
|
49
|
+
fun encrypter(): Cipher {
|
|
50
|
+
if (!canSave) throw unavailable()
|
|
51
|
+
return cipher(Cipher.ENCRYPT_MODE) ?: generate().let { cipher(Cipher.ENCRYPT_MODE) } ?: throw unavailable()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Encrypts `pin` with an unlocked [encrypter]; [store] saves the result once the card accepts the PIN. */
|
|
55
|
+
fun seal(cipher: Cipher, pin: String): String =
|
|
56
|
+
try {
|
|
57
|
+
val iv = cipher.iv
|
|
58
|
+
"${encode(iv)}\n${encode(cipher.doFinal(pin.toByteArray()))}"
|
|
59
|
+
} catch (e: Exception) {
|
|
60
|
+
throw unavailable(e)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 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")
|
|
65
|
+
|
|
66
|
+
fun delete() {
|
|
67
|
+
file.delete()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private fun cipher(mode: Int, iv: ByteArray? = null): Cipher? {
|
|
71
|
+
val key = keyStore.getKey(ALIAS, null) as? SecretKey ?: return null
|
|
72
|
+
return try {
|
|
73
|
+
Cipher.getInstance("AES/GCM/NoPadding").apply { if (iv == null) init(mode, key) else init(mode, key, GCMParameterSpec(128, iv)) }
|
|
74
|
+
} catch (e: KeyPermanentlyInvalidatedException) {
|
|
75
|
+
null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun generate() {
|
|
80
|
+
val strongBox = Build.VERSION.SDK_INT >= 28 && context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
|
81
|
+
try {
|
|
82
|
+
val key = if (strongBox) runCatching { key(true) }.getOrElse { key(false) } else key(false)
|
|
83
|
+
val info = SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore").getKeySpec(key, KeyInfo::class.java) as KeyInfo
|
|
84
|
+
val hardware = if (Build.VERSION.SDK_INT >= 31) {
|
|
85
|
+
info.securityLevel != KeyProperties.SECURITY_LEVEL_SOFTWARE && info.securityLevel != KeyProperties.SECURITY_LEVEL_UNKNOWN
|
|
86
|
+
} else {
|
|
87
|
+
@Suppress("DEPRECATION") info.isInsideSecureHardware
|
|
88
|
+
}
|
|
89
|
+
check(hardware && info.isUserAuthenticationRequirementEnforcedBySecureHardware) { "The Keystore key is not in secure hardware" }
|
|
90
|
+
} catch (e: Exception) {
|
|
91
|
+
keyStore.deleteEntry(ALIAS)
|
|
92
|
+
throw unavailable(e)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private fun key(strongBox: Boolean): SecretKey {
|
|
97
|
+
val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
|
98
|
+
.setKeySize(256)
|
|
99
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
100
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
101
|
+
.setUserAuthenticationRequired(true)
|
|
102
|
+
.setInvalidatedByBiometricEnrollment(true)
|
|
103
|
+
.apply {
|
|
104
|
+
if (Build.VERSION.SDK_INT >= 30) setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
|
|
105
|
+
else @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(-1)
|
|
106
|
+
if (strongBox && Build.VERSION.SDK_INT >= 28) setIsStrongBoxBacked(true)
|
|
107
|
+
}
|
|
108
|
+
.build()
|
|
109
|
+
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { init(spec) }.generateKey()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private companion object {
|
|
113
|
+
const val ALIAS = "it.codingduck.ciesign.pin"
|
|
114
|
+
|
|
115
|
+
fun encode(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
|
116
|
+
|
|
117
|
+
fun unavailable(cause: Throwable? = null) = CodedException("BIOMETRIC_UNAVAILABLE", "No strong biometric or secure hardware", cause)
|
|
118
|
+
}
|
|
119
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { withEntitlementsPlist, withInfoPlist } = require('expo/config-plugins');
|
|
2
|
+
|
|
3
|
+
// iOS tries these AIDs in order when a card is detected; this order is the one known to work with the CIE.
|
|
4
|
+
const AIDS = ['A0000002471001', 'A0000000308000000009816001', 'A00000000039', 'A0000002472001', '00000000000000'];
|
|
5
|
+
const SELECT_IDENTIFIERS = 'com.apple.developer.nfc.readersession.iso7816.select-identifiers';
|
|
6
|
+
const FORMATS = 'com.apple.developer.nfc.readersession.formats';
|
|
7
|
+
|
|
8
|
+
module.exports = (
|
|
9
|
+
config,
|
|
10
|
+
{
|
|
11
|
+
nfcUsageDescription = 'La CIE viene letta via NFC per firmare documenti.',
|
|
12
|
+
faceIdUsageDescription = 'Face ID sblocca il PIN della CIE salvato sul dispositivo.',
|
|
13
|
+
} = {},
|
|
14
|
+
) => {
|
|
15
|
+
config = withEntitlementsPlist(config, (c) => {
|
|
16
|
+
c.modResults[FORMATS] = [...new Set([...(c.modResults[FORMATS] ?? []), 'TAG'])];
|
|
17
|
+
return c;
|
|
18
|
+
});
|
|
19
|
+
return withInfoPlist(config, (c) => {
|
|
20
|
+
c.modResults.NFCReaderUsageDescription ??= nfcUsageDescription;
|
|
21
|
+
c.modResults.NSFaceIDUsageDescription ??= faceIdUsageDescription;
|
|
22
|
+
c.modResults[SELECT_IDENTIFIERS] = [...new Set([...(c.modResults[SELECT_IDENTIFIERS] ?? []), ...AIDS])];
|
|
23
|
+
return c;
|
|
24
|
+
});
|
|
25
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Bytes = Uint8Array;
|
|
2
|
+
export declare const fail: (code: string, message: string) => Error & {
|
|
3
|
+
code: string;
|
|
4
|
+
};
|
|
5
|
+
/** Drops the padding after the certificate (the CIE stores it in a zero-filled file). */
|
|
6
|
+
export declare const trimDer: (der: Bytes) => Uint8Array<ArrayBufferLike>;
|
|
7
|
+
/** Checks an RSA PKCS#1 v1.5 signature of `digestInfo` against the certificate's public key. */
|
|
8
|
+
export declare const verify: (certificate: Bytes, digestInfo: Bytes, signature: Bytes) => void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
2
|
+
const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
3
|
+
/** Value start and end offsets of the DER TLV at `i`. */
|
|
4
|
+
const bounds = (der, i = 0) => {
|
|
5
|
+
let length = der[i + 1];
|
|
6
|
+
let start = i + 2;
|
|
7
|
+
if (length & 0x80) {
|
|
8
|
+
const n = length & 0x7f;
|
|
9
|
+
length = 0;
|
|
10
|
+
for (let k = 0; k < n; k++)
|
|
11
|
+
length = length * 256 + der[start++];
|
|
12
|
+
}
|
|
13
|
+
return { start, end: start + length };
|
|
14
|
+
};
|
|
15
|
+
/** The TLVs inside a constructed DER value. */
|
|
16
|
+
const children = (der) => {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = bounds(der).start; i < bounds(der).end; i = bounds(der, i).end)
|
|
19
|
+
out.push(der.subarray(i, bounds(der, i).end));
|
|
20
|
+
return out;
|
|
21
|
+
};
|
|
22
|
+
const value = (der) => der.subarray(bounds(der).start, bounds(der).end);
|
|
23
|
+
/** Drops the padding after the certificate (the CIE stores it in a zero-filled file). */
|
|
24
|
+
export const trimDer = (der) => {
|
|
25
|
+
const { end } = bounds(der);
|
|
26
|
+
if (der[0] !== 0x30 || !(end <= der.length))
|
|
27
|
+
throw fail('CARD_ERROR', 'Invalid certificate');
|
|
28
|
+
return der.subarray(0, end);
|
|
29
|
+
};
|
|
30
|
+
/** Checks an RSA PKCS#1 v1.5 signature of `digestInfo` against the certificate's public key. */
|
|
31
|
+
export const verify = (certificate, digestInfo, signature) => {
|
|
32
|
+
const fields = children(children(certificate)[0]); // TBSCertificate, with or without [0] version
|
|
33
|
+
const spki = children(fields[fields[0][0] === 0xa0 ? 6 : 5]);
|
|
34
|
+
const [n, e] = children(value(spki[1]).subarray(1)).map((i) => BigInt('0x' + hex(value(i))));
|
|
35
|
+
let result = 1n;
|
|
36
|
+
for (let base = BigInt('0x' + (hex(signature) || '0')) % n, x = e; x > 0n; x >>= 1n, base = (base * base) % n) {
|
|
37
|
+
if (x & 1n)
|
|
38
|
+
result = (result * base) % n;
|
|
39
|
+
}
|
|
40
|
+
const k = (n.toString(16).length + 1) >> 1;
|
|
41
|
+
const encoded = '01' + 'ff'.repeat(k - digestInfo.length - 3) + '00' + hex(digestInfo);
|
|
42
|
+
if (signature.length !== k || result !== BigInt('0x' + encoded))
|
|
43
|
+
throw fail('CARD_ERROR', 'The signature does not match the certificate');
|
|
44
|
+
};
|