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.
@@ -0,0 +1,73 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import Security
4
+
5
+ enum SavedPinError: Error {
6
+ case notSaved, otherCard, biometryUnavailable, cancelled
7
+ }
8
+
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.
11
+ enum SavedPin {
12
+ private static let item: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: "it.codingduck.ciesign.pin"]
13
+
14
+ static var canSave: Bool { LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) }
15
+
16
+ /// Checks for the item without showing any UI.
17
+ static var isSaved: Bool {
18
+ let context = LAContext()
19
+ context.interactionNotAllowed = true
20
+ let status = SecItemCopyMatching(item.merging([kSecUseAuthenticationContext: context]) { $1 } as CFDictionary, nil)
21
+ return status == errSecSuccess || status == errSecInteractionNotAllowed
22
+ }
23
+
24
+ /// Replaces the saved PIN; `card` is the NIS of the card that accepted it.
25
+ static func store(_ pin: String, card: Data) throws {
26
+ delete()
27
+ guard let access = access(),
28
+ SecItemAdd(item.merging([kSecAttrAccessControl: access, kSecAttrGeneric: card, kSecValueData: Data(pin.utf8)]) { $1 } as CFDictionary, nil)
29
+ == errSecSuccess
30
+ else { throw SavedPinError.biometryUnavailable }
31
+ }
32
+
33
+ /// Evaluates the item's access control, which the Keychain read then requires; invalidating `context` cancels it.
34
+ private static func authenticate(_ context: LAContext, reason: String) async throws {
35
+ do {
36
+ guard let access = access(), try await context.evaluateAccessControl(access, operation: .useItem, localizedReason: reason) else {
37
+ throw SavedPinError.biometryUnavailable
38
+ }
39
+ } catch let error as LAError {
40
+ switch error.code {
41
+ case .userCancel, .appCancel, .systemCancel, .userFallback: throw SavedPinError.cancelled
42
+ case .biometryNotEnrolled: // removing every face or finger invalidated the item
43
+ delete()
44
+ throw SavedPinError.notSaved
45
+ default: throw SavedPinError.biometryUnavailable
46
+ }
47
+ }
48
+ }
49
+
50
+ /// Authenticates with `context` and returns the PIN with its card's NIS.
51
+ static func unlock(_ context: LAContext, reason: String) async throws -> (pin: String, card: Data) {
52
+ guard isSaved else { throw SavedPinError.notSaved }
53
+ try await authenticate(context, reason: reason)
54
+ context.interactionNotAllowed = true
55
+ var result: CFTypeRef?
56
+ let query = item.merging([kSecUseAuthenticationContext: context, kSecReturnData: true, kSecReturnAttributes: true]) { $1 }
57
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let found = result as? [CFString: Any],
58
+ let pin = found[kSecValueData] as? Data, let card = found[kSecAttrGeneric] as? Data
59
+ else {
60
+ delete() // the enrolled faces or fingers changed since the PIN was saved
61
+ throw SavedPinError.notSaved
62
+ }
63
+ return (String(decoding: pin, as: UTF8.self), card)
64
+ }
65
+
66
+ static func delete() {
67
+ SecItemDelete(item as CFDictionary)
68
+ }
69
+
70
+ private static func access() -> SecAccessControl? {
71
+ SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, nil)
72
+ }
73
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "ciesign-react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "RSA signatures with the Italian electronic identity card (CIE) over NFC",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "files": [
8
+ "build",
9
+ "src",
10
+ "android/build.gradle",
11
+ "android/src",
12
+ "ios",
13
+ "app.plugin.js",
14
+ "expo-module.config.json"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "prepare": "tsc",
19
+ "test": "node --import ./test/hooks.mjs --test test/*.test.ts"
20
+ },
21
+ "keywords": [
22
+ "react-native",
23
+ "expo",
24
+ "cie",
25
+ "nfc",
26
+ "electronic-signature",
27
+ "firma-elettronica",
28
+ "carta-identita-elettronica"
29
+ ],
30
+ "author": "Coding Duck S.r.l.",
31
+ "license": "MIT",
32
+ "devDependencies": {
33
+ "@types/node": "^24.19.2",
34
+ "expo": "~58.0.23",
35
+ "typescript": "~6.0.3"
36
+ },
37
+ "peerDependencies": {
38
+ "expo": "*",
39
+ "react-native": "*"
40
+ },
41
+ "homepage": "https://github.com/codingducksrl/ciesign-react-native#readme",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/codingducksrl/ciesign-react-native.git"
45
+ },
46
+ "bugs": "https://github.com/codingducksrl/ciesign-react-native/issues"
47
+ }
@@ -0,0 +1,47 @@
1
+ export type Bytes = Uint8Array;
2
+
3
+ export const fail = (code: string, message: string) => Object.assign(new Error(message), { code });
4
+
5
+ const hex = (bytes: Bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
6
+
7
+ /** Value start and end offsets of the DER TLV at `i`. */
8
+ const bounds = (der: Bytes, i = 0) => {
9
+ let length = der[i + 1];
10
+ let start = i + 2;
11
+ if (length & 0x80) {
12
+ const n = length & 0x7f;
13
+ length = 0;
14
+ for (let k = 0; k < n; k++) length = length * 256 + der[start++];
15
+ }
16
+ return { start, end: start + length };
17
+ };
18
+
19
+ /** The TLVs inside a constructed DER value. */
20
+ const children = (der: Bytes): Bytes[] => {
21
+ const out: Bytes[] = [];
22
+ for (let i = bounds(der).start; i < bounds(der).end; i = bounds(der, i).end) out.push(der.subarray(i, bounds(der, i).end));
23
+ return out;
24
+ };
25
+
26
+ const value = (der: Bytes) => der.subarray(bounds(der).start, bounds(der).end);
27
+
28
+ /** Drops the padding after the certificate (the CIE stores it in a zero-filled file). */
29
+ export const trimDer = (der: Bytes) => {
30
+ const { end } = bounds(der);
31
+ if (der[0] !== 0x30 || !(end <= der.length)) throw fail('CARD_ERROR', 'Invalid certificate');
32
+ return der.subarray(0, end);
33
+ };
34
+
35
+ /** Checks an RSA PKCS#1 v1.5 signature of `digestInfo` against the certificate's public key. */
36
+ export const verify = (certificate: Bytes, digestInfo: Bytes, signature: Bytes) => {
37
+ const fields = children(children(certificate)[0]); // TBSCertificate, with or without [0] version
38
+ const spki = children(fields[fields[0][0] === 0xa0 ? 6 : 5]);
39
+ const [n, e] = children(value(spki[1]).subarray(1)).map((i) => BigInt('0x' + hex(value(i))));
40
+ let result = 1n;
41
+ for (let base = BigInt('0x' + (hex(signature) || '0')) % n, x = e; x > 0n; x >>= 1n, base = (base * base) % n) {
42
+ if (x & 1n) result = (result * base) % n;
43
+ }
44
+ const k = (n.toString(16).length + 1) >> 1;
45
+ const encoded = '01' + 'ff'.repeat(k - digestInfo.length - 3) + '00' + hex(digestInfo);
46
+ if (signature.length !== k || result !== BigInt('0x' + encoded)) throw fail('CARD_ERROR', 'The signature does not match the certificate');
47
+ };
package/src/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { type Bytes, trimDer, verify } from './certificate';
2
+ import Native, { type PinStatus } from './native';
3
+
4
+ export type { PinStatus } from './native';
5
+
6
+ /** Pass instead of the PIN to use the one saved with `savePin`, unlocked with Face ID, Touch ID or a fingerprint. */
7
+ export const SAVED_PIN: unique symbol = Symbol('SAVED_PIN');
8
+
9
+ /** The full 8-digit PIN, or `SAVED_PIN`. */
10
+ export type Pin = string | typeof SAVED_PIN;
11
+
12
+ /**
13
+ * `prompt`, `error`: texts of the iOS NFC sheet. `savePin`: once the card accepts the PIN, save it for `SAVED_PIN`.
14
+ * `biometricPrompt`, `biometricCancel`: texts of the biometric prompt shown before the card is read, wherever the secure
15
+ * hardware requires one: `SAVED_PIN` everywhere, `savePin` on Android only.
16
+ */
17
+ export type CieOptions = { prompt?: string; error?: string; savePin?: boolean; biometricPrompt?: string; biometricCancel?: string };
18
+
19
+ /** A card whose PIN has been verified, for as long as `use` runs. */
20
+ export type CieSession = {
21
+ /** DER authentication certificate; its subject holds the holder's name and fiscal code. */
22
+ certificate: Bytes;
23
+ /** RSA PKCS#1 v1.5 signature of a DER DigestInfo, checked against `certificate`. Calls run one at a time. */
24
+ sign(digestInfo: Bytes): Promise<Bytes>;
25
+ };
26
+
27
+ let session = 0; // id of the latest NFC session
28
+
29
+ /**
30
+ * Waits for the card and verifies the PIN, then runs `use`: every `sign` goes through this NFC session, which ends when
31
+ * `use` settles.
32
+ */
33
+ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Promise<T>, options: CieOptions = {}): Promise<T> {
34
+ if (pin !== SAVED_PIN && !/^\d{8}$/.test(pin)) throw new Error('The CIE PIN has 8 digits');
35
+ const {
36
+ prompt = "Avvicina la CIE al retro dell'iPhone",
37
+ error = 'Operazione non riuscita',
38
+ savePin = false,
39
+ biometricPrompt = pin === SAVED_PIN ? 'Sblocca il PIN della CIE' : 'Salva il PIN della CIE',
40
+ biometricCancel = 'Annulla',
41
+ } = options;
42
+ const id = ++session;
43
+ let done = false;
44
+ try {
45
+ const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, prompt, biometricPrompt, biometricCancel));
46
+ let last: Promise<unknown> = Promise.resolve();
47
+ const sign = (digestInfo: Bytes) => {
48
+ const input = digestInfo.slice(); // signed later, once the calls before it finish
49
+ const signature = last.then(async () => {
50
+ const signature = await Native.sign(id, input);
51
+ verify(certificate, input, signature);
52
+ return signature;
53
+ });
54
+ last = signature.catch(() => {});
55
+ return signature;
56
+ };
57
+ const result = await use({ certificate, sign });
58
+ done = true;
59
+ return result;
60
+ } finally {
61
+ Native.close(id, done ? null : error);
62
+ }
63
+ }
64
+
65
+ /** Aborts the CIE session in progress. */
66
+ export const cancel = () => Native.close(session, null);
67
+
68
+ /** Whether a PIN is saved, without showing any UI. */
69
+ export const pinStatus = (): Promise<PinStatus> => Native.pinStatus();
70
+
71
+ /** Deletes the saved PIN. */
72
+ export const forgetPin = (): Promise<void> => Native.forgetPin();
package/src/native.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { requireNativeModule } from 'expo';
2
+
3
+ /** `saved`: a PIN is saved. `available`: a strong biometric is enrolled, so one can be. `unavailable`: neither. */
4
+ export type PinStatus = 'saved' | 'available' | 'unavailable';
5
+
6
+ type CieSignModule = {
7
+ /**
8
+ * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
9
+ * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
10
+ * only where the secure hardware requires it: unlocking everywhere, saving on Android.
11
+ */
12
+ connect(id: number, pin: string | null, save: boolean, prompt: string, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
13
+ /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
14
+ sign(id: number, digestInfo: Uint8Array): Promise<Uint8Array>;
15
+ /** Ends every session up to `id`, also one whose connect has not arrived yet; iOS shows `error` in the NFC sheet. */
16
+ close(id: number, error: string | null): void;
17
+ pinStatus(): Promise<PinStatus>;
18
+ forgetPin(): Promise<void>;
19
+ };
20
+
21
+ export default requireNativeModule<CieSignModule>('CieSign');