ciesign-react-native 0.0.1 → 0.2.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,209 @@
1
+ import CoreNFC
2
+ import ExpoModulesCore
3
+ import LocalAuthentication
4
+
5
+ public class CieSignModule: Module {
6
+ // Main-actor state. Ids come from JS; sessions up to `closed` are over, even if their connect arrives later.
7
+ private var reader: Reader?
8
+ private var closed = 0
9
+
10
+ public func definition() -> ModuleDefinition {
11
+ Name("CieSign")
12
+
13
+ Events("onStep")
14
+
15
+ // `messages`: NFC sheet texts by step, `prompt` first.
16
+ AsyncFunction("connect") {
17
+ @MainActor (id: Int, pin: String?, save: Bool, messages: [String: String], biometricPrompt: String, biometricCancel: String) async throws -> Data in
18
+ guard NFCTagReaderSession.readingAvailable else { throw failure("NFC_UNAVAILABLE", "NFC tag reading is not available") }
19
+ guard id > self.closed else { throw cancelled }
20
+ self.reader?.close(nil, nil)
21
+ let reader = Reader(id: id, messages: messages, onStep: self.reporter(id))
22
+ self.reader = reader
23
+ do {
24
+ return try await reader.connect(pin: pin, save: save, biometricPrompt: biometricPrompt, biometricCancel: biometricCancel)
25
+ } catch {
26
+ let error = coded(reader.invalidation ?? error)
27
+ if pin == nil, let code = (error as? Exception)?.code, code.hasPrefix("WRONG_PIN") || code == "PIN_LOCKED" { SavedPin.delete() }
28
+ throw error
29
+ }
30
+ }
31
+
32
+ AsyncFunction("sign") { @MainActor (id: Int, digestInfo: Data) async throws -> Data in
33
+ guard let reader = self.reader, reader.id == id else { throw cancelled }
34
+ do {
35
+ guard let card = reader.card else { throw cancelled } // the reason is in `invalidation`, e.g. a timeout
36
+ reader.show("signing")
37
+ return try await card.sign(digestInfo)
38
+ } catch {
39
+ throw coded(reader.invalidation ?? error)
40
+ }
41
+ }
42
+
43
+ AsyncFunction("close") { @MainActor (id: Int, error: String?, done: String?) async in
44
+ self.closed = max(self.closed, id)
45
+ guard let reader = self.reader, reader.id <= id else { return }
46
+ reader.close(error, done)
47
+ self.reader = nil
48
+ }
49
+
50
+ AsyncFunction("pinStatus") { () -> String in
51
+ SavedPin.isSaved ? "saved" : SavedPin.canSave ? "available" : "unavailable"
52
+ }
53
+
54
+ AsyncFunction("forgetPin") { SavedPin.delete() }
55
+
56
+ OnDestroy {
57
+ DispatchQueue.main.async { self.reader?.close(nil, nil) }
58
+ }
59
+ }
60
+
61
+ /// Sends session `id`'s steps to JS as `onStep` events, without keeping the module alive.
62
+ private func reporter(_ id: Int) -> (String) -> Void {
63
+ { [weak self] step in self?.sendEvent("onStep", ["id": id, "step": step]) }
64
+ }
65
+ }
66
+
67
+ /// One NFCTagReaderSession; used on the main thread only.
68
+ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
69
+ let id: Int
70
+ private let messages: [String: String]
71
+ private let onStep: (String) -> Void
72
+ private var session: NFCTagReaderSession?
73
+ private var pending: CheckedContinuation<Data, Error>?
74
+ private var pin = ""
75
+ private var save = false
76
+ private var owner: Data? // NIS of the card the saved PIN belongs to
77
+ private var biometrics: LAContext?
78
+ private var closed = false
79
+ private(set) var card: CieCard?
80
+ private(set) var invalidation: Error?
81
+
82
+ init(id: Int, messages: [String: String], onStep: @escaping (String) -> Void) {
83
+ self.id = id
84
+ self.messages = messages
85
+ self.onStep = onStep
86
+ }
87
+
88
+ /// Shows the NFC sheet text for `step`, if any.
89
+ func show(_ step: String) {
90
+ if let message = messages[step] { session?.alertMessage = message }
91
+ }
92
+
93
+ /// Shows `step` in the NFC sheet and reports it to JS.
94
+ private func reach(_ step: String) {
95
+ show(step)
96
+ onStep(step)
97
+ }
98
+
99
+ /// A nil `pin` unlocks the saved one with Face ID or Touch ID; `save` saves `pin` once the card accepts it, with no
100
+ /// prompt, since writing the Keychain item needs no authentication.
101
+ func connect(pin: String?, save: Bool, biometricPrompt: String, biometricCancel: String) async throws -> Data {
102
+ if let pin {
103
+ if save, !SavedPin.canSave { throw SavedPinError.biometryUnavailable }
104
+ (self.pin, self.save) = (pin, save)
105
+ } else {
106
+ let context = LAContext()
107
+ context.localizedCancelTitle = biometricCancel
108
+ context.localizedFallbackTitle = ""
109
+ biometrics = context
110
+ (self.pin, owner) = try await SavedPin.unlock(context, reason: biometricPrompt)
111
+ }
112
+ guard !closed else { throw cancelled }
113
+ return try await withCheckedThrowingContinuation { continuation in
114
+ guard let session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: .main) else {
115
+ return continuation.resume(throwing: failure("NFC_UNAVAILABLE", "NFC tag reading is not available"))
116
+ }
117
+ pending = continuation
118
+ self.session = session
119
+ show("prompt")
120
+ session.begin()
121
+ }
122
+ }
123
+
124
+ /// Ends the session; the NFC sheet shows `error` if there is one, otherwise `done` next to its checkmark.
125
+ func close(_ error: String?, _ done: String?) {
126
+ closed = true
127
+ biometrics?.invalidate()
128
+ finish(.failure(cancelled))
129
+ if let error {
130
+ session?.invalidate(errorMessage: error)
131
+ } else {
132
+ if let done { session?.alertMessage = done }
133
+ session?.invalidate()
134
+ }
135
+ session = nil
136
+ card = nil
137
+ }
138
+
139
+ func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}
140
+
141
+ func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
142
+ invalidation = error
143
+ card = nil
144
+ finish(.failure(error))
145
+ }
146
+
147
+ func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
148
+ guard tags.count == 1 else { return session.restartPolling() }
149
+ guard case let .iso7816(tag) = tags[0] else { return finish(.failure(failure("NOT_A_CIE", "Not an ISO 7816 card"))) }
150
+ Task { @MainActor in
151
+ do {
152
+ try await session.connect(to: tags[0])
153
+ reach("detected")
154
+ let card = CieCard { apdu in
155
+ guard let command = NFCISO7816APDU(data: apdu) else { throw failure("CARD_ERROR", "Malformed APDU") }
156
+ let (data, sw1, sw2) = try await tag.sendCommand(apdu: command)
157
+ return data + [sw1, sw2]
158
+ }
159
+ let serviceId = try await card.serviceId()
160
+ if let owner, owner != serviceId { throw SavedPinError.otherCard }
161
+ reach("pin")
162
+ try await card.open(pin: pin)
163
+ if save { try SavedPin.store(pin, card: serviceId) }
164
+ let certificate = try await card.certificate()
165
+ self.card = card
166
+ show("ready") // JS reports `ready` once connect resolves
167
+ finish(.success(certificate))
168
+ } catch let error as NFCReaderError where error.code == .readerTransceiveErrorSessionInvalidated {
169
+ // didInvalidateWithError follows with the reason (cancel, timeout).
170
+ } catch {
171
+ finish(.failure(error))
172
+ }
173
+ }
174
+ }
175
+
176
+ private func finish(_ result: Result<Data, Error>) {
177
+ pending?.resume(with: result)
178
+ pending = nil
179
+ }
180
+ }
181
+
182
+ private func failure(_ code: String, _ description: String) -> Exception {
183
+ Exception(name: code, description: description, code: code)
184
+ }
185
+
186
+ private var cancelled: Exception { failure("CANCELLED", "Cancelled") }
187
+
188
+ private func coded(_ error: Error) -> Error {
189
+ switch error {
190
+ case let error as Exception: return error
191
+ case CieCardError.wrongPin(let attemptsLeft): return failure("WRONG_PIN_\(attemptsLeft)", "Wrong PIN")
192
+ case CieCardError.pinLocked: return failure("PIN_LOCKED", "PIN locked: unlock it with the PUK")
193
+ case CieCardError.notCie: return failure("NOT_A_CIE", "Not a CIE")
194
+ case SavedPinError.notSaved: return failure("PIN_NOT_SAVED", "No saved PIN")
195
+ case SavedPinError.otherCard: return failure("WRONG_CARD", "Not the card the PIN was saved for")
196
+ case SavedPinError.biometryUnavailable: return failure("BIOMETRIC_UNAVAILABLE", "Face ID or Touch ID is not available")
197
+ case SavedPinError.cancelled: return cancelled
198
+ case let error as NFCReaderError:
199
+ switch error.code {
200
+ case .readerSessionInvalidationErrorUserCanceled, .readerTransceiveErrorSessionInvalidated: return cancelled
201
+ case .readerSessionInvalidationErrorSessionTimeout: return failure("TIMEOUT", "NFC session timed out")
202
+ case .readerSessionInvalidationErrorSystemIsBusy: return failure("BUSY", "NFC is busy, retry")
203
+ case .readerTransceiveErrorTagConnectionLost, .readerTransceiveErrorTagResponseError, .readerTransceiveErrorTagNotConnected:
204
+ return failure("TAG_LOST", "Card connection lost")
205
+ default: return failure("CARD_ERROR", error.localizedDescription)
206
+ }
207
+ default: return failure("CARD_ERROR", "\(error)")
208
+ }
209
+ }
@@ -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
- {"name":"ciesign-react-native","version":"0.0.1","description":"Name reservation, use >=0.1.0"}
1
+ {
2
+ "name": "ciesign-react-native",
3
+ "version": "0.2.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,115 @@
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
+ * Progress of a session, in order: `detected` the card is on the phone; `pin` the PIN is being verified; `ready` the PIN
14
+ * is verified and the certificate read, `use` starts; `signing` a signature starts, once per `sign`; `done` `use`
15
+ * resolved and the session is over.
16
+ */
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`. `biometricPrompt`, `biometricCancel`: texts of the
33
+ * biometric prompt shown before the card is read, wherever the secure hardware requires one: `SAVED_PIN` everywhere,
34
+ * `savePin` on Android only.
35
+ */
36
+ export type CieOptions = {
37
+ prompt?: string;
38
+ error?: string;
39
+ messages?: Partial<Record<CieStep, string>>;
40
+ onStep?: (step: CieStep) => void;
41
+ savePin?: boolean;
42
+ biometricPrompt?: string;
43
+ biometricCancel?: string;
44
+ };
45
+
46
+ /** A card whose PIN has been verified, for as long as `use` runs. */
47
+ export type CieSession = {
48
+ /** DER authentication certificate; its subject holds the holder's name and fiscal code. */
49
+ certificate: Bytes;
50
+ /** RSA PKCS#1 v1.5 signature of a DER DigestInfo, checked against `certificate`. Calls run one at a time. */
51
+ sign(digestInfo: Bytes): Promise<Bytes>;
52
+ };
53
+
54
+ let session = 0; // id of the latest NFC session
55
+
56
+ /**
57
+ * Waits for the card and verifies the PIN, then runs `use`: every `sign` goes through this NFC session, which ends when
58
+ * `use` settles.
59
+ */
60
+ export async function cieSession<T>(pin: Pin, use: (session: CieSession) => Promise<T>, options: CieOptions = {}): Promise<T> {
61
+ if (pin !== SAVED_PIN && !/^\d{8}$/.test(pin)) throw new Error('The CIE PIN has 8 digits');
62
+ const {
63
+ prompt = "Avvicina la CIE al retro dell'iPhone",
64
+ error = 'Operazione non riuscita',
65
+ savePin = false,
66
+ biometricPrompt = pin === SAVED_PIN ? 'Sblocca il PIN della CIE' : 'Salva il PIN della CIE',
67
+ biometricCancel = 'Annulla',
68
+ onStep,
69
+ } = options;
70
+ const message = (s: CieStep) => options.messages?.[s] ?? MESSAGES[s];
71
+ const id = ++session;
72
+ let done = false;
73
+ // Native events can arrive after `ready`: steps only move forward, except one `signing` per signature.
74
+ let reached = -1;
75
+ const step = (s: CieStep) => {
76
+ const i = STEPS.indexOf(s);
77
+ if (i < reached || (i === reached && s !== 'signing')) return;
78
+ reached = i;
79
+ onStep?.(s);
80
+ };
81
+ const subscription = onStep && Native.addListener('onStep', (event) => event.id === id && step(event.step));
82
+ try {
83
+ const sheet = { prompt, detected: message('detected'), pin: message('pin'), ready: message('ready'), signing: message('signing') };
84
+ const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, sheet, biometricPrompt, biometricCancel));
85
+ step('ready');
86
+ let last: Promise<unknown> = Promise.resolve();
87
+ const sign = (digestInfo: Bytes) => {
88
+ const input = digestInfo.slice(); // signed later, once the calls before it finish
89
+ const signature = last.then(async () => {
90
+ step('signing');
91
+ const signature = await Native.sign(id, input);
92
+ verify(certificate, input, signature);
93
+ return signature;
94
+ });
95
+ last = signature.catch(() => {});
96
+ return signature;
97
+ };
98
+ const result = await use({ certificate, sign });
99
+ done = true;
100
+ return result;
101
+ } finally {
102
+ subscription?.remove();
103
+ Native.close(id, done ? null : error, done ? message('done') : null);
104
+ if (done) step('done');
105
+ }
106
+ }
107
+
108
+ /** Aborts the CIE session in progress. */
109
+ export const cancel = () => Native.close(session, null, null);
110
+
111
+ /** Whether a PIN is saved, without showing any UI. */
112
+ export const pinStatus = (): Promise<PinStatus> => Native.pinStatus();
113
+
114
+ /** Deletes the saved PIN. */
115
+ export const forgetPin = (): Promise<void> => Native.forgetPin();
package/src/native.ts ADDED
@@ -0,0 +1,32 @@
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
+ /** 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
+
12
+ type CieSignModule = {
13
+ /**
14
+ * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
15
+ * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
16
+ * only where the secure hardware requires it: unlocking everywhere, saving on Android.
17
+ */
18
+ connect(id: number, pin: string | null, save: boolean, messages: NativeMessages, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
19
+ /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
20
+ sign(id: number, digestInfo: Uint8Array): Promise<Uint8Array>;
21
+ /**
22
+ * Ends every session up to `id`, also one whose connect has not arrived yet. iOS shows `error` in the NFC sheet, or
23
+ * else `done` next to its checkmark.
24
+ */
25
+ close(id: number, error: string | null, done: string | null): void;
26
+ pinStatus(): Promise<PinStatus>;
27
+ forgetPin(): Promise<void>;
28
+ /** `onStep` fires as session `id` reaches a step only the native side sees. */
29
+ addListener(event: 'onStep', listener: (event: { id: number; step: NativeStep }) => void): { remove(): void };
30
+ };
31
+
32
+ export default requireNativeModule<CieSignModule>('CieSign');