ciesign-react-native 0.0.1 → 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 +146 -0
- 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 +47 -1
- package/src/certificate.ts +47 -0
- package/src/index.ts +72 -0
- package/src/native.ts +21 -0
|
@@ -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 +1,47 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "ciesign-react-native",
|
|
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');
|