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,37 @@
1
+ import { type Bytes } from './certificate';
2
+ import { type PinStatus } from './native';
3
+ export type { PinStatus } from './native';
4
+ /** Pass instead of the PIN to use the one saved with `savePin`, unlocked with Face ID, Touch ID or a fingerprint. */
5
+ export declare const SAVED_PIN: unique symbol;
6
+ /** The full 8-digit PIN, or `SAVED_PIN`. */
7
+ export type Pin = string | typeof SAVED_PIN;
8
+ /**
9
+ * `prompt`, `error`: texts of the iOS NFC sheet. `savePin`: once the card accepts the PIN, save it for `SAVED_PIN`.
10
+ * `biometricPrompt`, `biometricCancel`: texts of the biometric prompt shown before the card is read, wherever the secure
11
+ * hardware requires one: `SAVED_PIN` everywhere, `savePin` on Android only.
12
+ */
13
+ export type CieOptions = {
14
+ prompt?: string;
15
+ error?: string;
16
+ savePin?: boolean;
17
+ biometricPrompt?: string;
18
+ biometricCancel?: string;
19
+ };
20
+ /** A card whose PIN has been verified, for as long as `use` runs. */
21
+ export type CieSession = {
22
+ /** DER authentication certificate; its subject holds the holder's name and fiscal code. */
23
+ certificate: Bytes;
24
+ /** RSA PKCS#1 v1.5 signature of a DER DigestInfo, checked against `certificate`. Calls run one at a time. */
25
+ sign(digestInfo: Bytes): Promise<Bytes>;
26
+ };
27
+ /**
28
+ * Waits for the card and verifies the PIN, then runs `use`: every `sign` goes through this NFC session, which ends when
29
+ * `use` settles.
30
+ */
31
+ export declare function cieSession<T>(pin: Pin, use: (session: CieSession) => Promise<T>, options?: CieOptions): Promise<T>;
32
+ /** Aborts the CIE session in progress. */
33
+ export declare const cancel: () => void;
34
+ /** Whether a PIN is saved, without showing any UI. */
35
+ export declare const pinStatus: () => Promise<PinStatus>;
36
+ /** Deletes the saved PIN. */
37
+ export declare const forgetPin: () => Promise<void>;
package/build/index.js ADDED
@@ -0,0 +1,42 @@
1
+ import { trimDer, verify } from './certificate';
2
+ import Native, {} from './native';
3
+ /** Pass instead of the PIN to use the one saved with `savePin`, unlocked with Face ID, Touch ID or a fingerprint. */
4
+ export const SAVED_PIN = Symbol('SAVED_PIN');
5
+ let session = 0; // id of the latest NFC session
6
+ /**
7
+ * Waits for the card and verifies the PIN, then runs `use`: every `sign` goes through this NFC session, which ends when
8
+ * `use` settles.
9
+ */
10
+ export async function cieSession(pin, use, options = {}) {
11
+ if (pin !== SAVED_PIN && !/^\d{8}$/.test(pin))
12
+ 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;
14
+ const id = ++session;
15
+ let done = false;
16
+ try {
17
+ const certificate = trimDer(await Native.connect(id, pin === SAVED_PIN ? null : pin, savePin, prompt, biometricPrompt, biometricCancel));
18
+ let last = Promise.resolve();
19
+ const sign = (digestInfo) => {
20
+ const input = digestInfo.slice(); // signed later, once the calls before it finish
21
+ const signature = last.then(async () => {
22
+ const signature = await Native.sign(id, input);
23
+ verify(certificate, input, signature);
24
+ return signature;
25
+ });
26
+ last = signature.catch(() => { });
27
+ return signature;
28
+ };
29
+ const result = await use({ certificate, sign });
30
+ done = true;
31
+ return result;
32
+ }
33
+ finally {
34
+ Native.close(id, done ? null : error);
35
+ }
36
+ }
37
+ /** Aborts the CIE session in progress. */
38
+ export const cancel = () => Native.close(session, null);
39
+ /** Whether a PIN is saved, without showing any UI. */
40
+ export const pinStatus = () => Native.pinStatus();
41
+ /** Deletes the saved PIN. */
42
+ export const forgetPin = () => Native.forgetPin();
@@ -0,0 +1,18 @@
1
+ /** `saved`: a PIN is saved. `available`: a strong biometric is enrolled, so one can be. `unavailable`: neither. */
2
+ export type PinStatus = 'saved' | 'available' | 'unavailable';
3
+ type CieSignModule = {
4
+ /**
5
+ * Waits for the card, verifies the PIN and returns its certificate; NFC session `id` stays open.
6
+ * A null `pin` unlocks the saved one; `save` saves `pin` once the card accepts it. Each shows the biometric prompt first
7
+ * only where the secure hardware requires it: unlocking everywhere, saving on Android.
8
+ */
9
+ connect(id: number, pin: string | null, save: boolean, prompt: string, biometricPrompt: string, biometricCancel: string): Promise<Uint8Array>;
10
+ /** Signs a DigestInfo in session `id`, as many times as needed until it closes. */
11
+ sign(id: number, digestInfo: Uint8Array): Promise<Uint8Array>;
12
+ /** Ends every session up to `id`, also one whose connect has not arrived yet; iOS shows `error` in the NFC sheet. */
13
+ close(id: number, error: string | null): void;
14
+ pinStatus(): Promise<PinStatus>;
15
+ forgetPin(): Promise<void>;
16
+ };
17
+ declare const _default: CieSignModule;
18
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { requireNativeModule } from 'expo';
2
+ export default requireNativeModule('CieSign');
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["CieSignModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["it.codingduck.ciesign.CieSignModule"]
8
+ }
9
+ }
@@ -0,0 +1,330 @@
1
+ // Derived from PagoPA cie-sdk-ios (https://github.com/pagopa/cie-sdk-ios), MIT License, Copyright (c) 2025 PagoPA S.p.A.
2
+
3
+ import CommonCrypto
4
+ import CryptoKit
5
+ import Foundation
6
+ import Security
7
+
8
+ enum CieCardError: Error {
9
+ case wrongPin(attemptsLeft: Int), pinLocked, notCie, status(UInt16), secureMessaging, invalidResponse
10
+ }
11
+
12
+ /// CIE operations over a raw APDU transport (CoreNFC is wrapped by the caller).
13
+ final class CieCard {
14
+ private let transmit: (Data) async throws -> Data
15
+ private var sm: (enc: [UInt8], mac: [UInt8], ssc: [UInt8])?
16
+ /// Source of the IFD randoms; replaceable for deterministic tests.
17
+ var random: (Int) -> [UInt8] = { count in
18
+ var bytes = [UInt8](repeating: 0, count: count)
19
+ precondition(SecRandomCopyBytes(kSecRandomDefault, count, &bytes) == errSecSuccess)
20
+ return bytes
21
+ }
22
+
23
+ /// `transmit` sends a command APDU and returns response data followed by SW1 SW2.
24
+ init(transmit: @escaping (Data) async throws -> Data) {
25
+ self.transmit = transmit
26
+ }
27
+
28
+ /// NIS from EF.ID_Servizi (EF 1001): identifies the card and is readable without the PIN.
29
+ func serviceId() async throws -> Data {
30
+ sm = nil
31
+ try await selectApplications()
32
+ try await send(0x00, 0xA4, 0x02, 0x04, [0x10, 0x01])
33
+ let r = try await send(0x00, 0xB0, 0x00, 0x00, le: 0x0C)
34
+ guard r.sw == 0x9000 else { throw CieCardError.notCie }
35
+ return Data(r.data)
36
+ }
37
+
38
+ /// Selects IAS + CIE apps, opens secure messaging (DH key exchange + DAPP device/chip authentication), verifies the 8-digit PIN (key ref 0x81).
39
+ func open(pin: String) async throws {
40
+ sm = nil
41
+ try await selectApplications()
42
+
43
+ var gpq = [[UInt8]]()
44
+ for tag: UInt8 in [0x97, 0x98, 0x99] {
45
+ let r = try await ok(send(0x00, 0xCB, 0x3F, 0xFF, [0x4D, 0x0A, 0x70, 0x08, 0xBF, 0xA1, 0x01, 0x04, 0xA3, 0x02, tag, 0x00]))
46
+ gpq.append(try find(UInt(tag), in: r))
47
+ }
48
+ let (g, p, q) = (gpq[0], gpq[1], gpq[2])
49
+ let chip = try await readFile(0x1004, chunk: 0xFF)
50
+ let iccKey = try children(of: chip, in: children(of: chip, in: 0..<1)[0].value).map { Array(chip[$0.value]) }
51
+ guard iccKey.count >= 2 else { throw CieCardError.invalidResponse }
52
+ let ca = try await ok(send(0x00, 0xCB, 0x3F, 0xFF, [0x4D, 0x09, 0x70, 0x07, 0xBF, 0xA0, 0x04, 0x03, 0x7F, 0x49, 0x80]))
53
+ let (caN, caCHR, caCHA) = try (Array(find(0x81, in: ca).drop { $0 == 0 }), find(0x5F20, in: ca), find(0x5F4C, in: ca))
54
+
55
+ var x: [UInt8]
56
+ repeat { x = random(q.count) } while !x.lexicographicallyPrecedes(q)
57
+ let pubIFD = try modPow(g, x, p)
58
+ try await ok(send(0x00, 0x22, 0x41, 0xA6, tlv(0x80, [0x9B]) + tlv(0x83, [0x81]) + tlv(0x91, pubIFD)))
59
+ let pubICC = try await find(0x91, in: ok(send(0x00, 0xCB, 0x3F, 0xFF, [0x4D, 0x04, 0xA6, 0x02, 0x91, 0x00])))
60
+ let secret = try modPow(pubICC, x, p)
61
+ sm = (Array(sha256(secret + [0, 0, 0, 1]).prefix(16)), Array(sha256(secret + [0, 0, 0, 2]).prefix(16)), [0, 0, 0, 0, 0, 0, 0, 1])
62
+
63
+ // DAPP (IAS-ECC 5.2.3): the IFD certificate is signed with the CA key, recoverable part per ISO 9796-2 scheme 1
64
+ let serialIFD = bytes("2000000000000001")
65
+ let chr = [0, 0, 0, 0] + serialIFD
66
+ let car = Array(caCHR.dropFirst(4))
67
+ let cert = [0x8A] + car + chr + caCHA.prefix(6) + [0x01] + bytes("2A8122F42A02040104") + Self.dappModulus + [0x00, 0x01, 0x00, 0x01]
68
+ let split = caN.count - 34
69
+ guard split > 0, split < cert.count else { throw CieCardError.invalidResponse }
70
+ let certSignature = try modPow([0x6A] + cert.prefix(split) + sha256(cert) + [0xBC], Self.caPrivateExponent, caN)
71
+ try await ok(send(0x00, 0x22, 0x81, 0xB6, tlv(0x80, [0x41]) + tlv(0x83, [0x84])))
72
+ try await ok(send(0x00, 0x2A, 0x00, 0xAE, tlv(0x7F21, tlv(0x5F37, certSignature) + tlv(0x5F38, Array(cert[split...])) + tlv(0x42, car))))
73
+ try await ok(send(0x00, 0x22, 0x81, 0xA4, tlv(0x83, chr)))
74
+ let rndICC = try await ok(send(0x00, 0x84, 0x00, 0x00, le: 0x08))
75
+ let prnd = random(Self.dappModulus.count - 34)
76
+ let digest = sha256(prnd + pubIFD + serialIFD + rndICC + pubICC + g + p + q)
77
+ let extAuth = try modPow([0x6A] + prnd + digest + [0xBC], Self.dappPrivateExponent, Self.dappModulus)
78
+ try await ok(send(0x00, 0x82, 0x00, 0x00, serialIFD + extAuth))
79
+
80
+ try await ok(send(0x00, 0x22, 0x41, 0xA4, tlv(0x84, [0x82]) + tlv(0x80, [0x9B])))
81
+ let rndIFD = random(8)
82
+ let intAuth = try await ok(send(0x00, 0x88, 0x00, 0x00, rndIFD))
83
+ guard intAuth.count > 8 else { throw CieCardError.secureMessaging }
84
+ let m = try modPow(Array(intAuth[8...]), iccKey[1], iccKey[0])
85
+ guard m.count > 34, m.first == 0x6A, m.last == 0xBC,
86
+ Array(m[(m.count - 33)..<(m.count - 1)]) == sha256(m[1..<(m.count - 33)] + pubICC + intAuth.prefix(8) + rndIFD + pubIFD + g + p + q)
87
+ else { throw CieCardError.secureMessaging }
88
+ sm?.ssc = Array(rndICC.suffix(4) + rndIFD.suffix(4))
89
+
90
+ switch try await send(0x00, 0x20, 0x00, 0x81, Array(pin.utf8)).sw {
91
+ case 0x9000: return
92
+ case 0x6983, 0x63C0: throw CieCardError.pinLocked
93
+ case let sw where sw & 0xFFF0 == 0x63C0: throw CieCardError.wrongPin(attemptsLeft: Int(sw & 0xF))
94
+ case let sw: throw CieCardError.status(sw)
95
+ }
96
+ }
97
+
98
+ /// DER authentication certificate from EF 1003, trimmed to its outer ASN.1 length.
99
+ func certificate() async throws -> Data {
100
+ guard sm != nil else { throw CieCardError.secureMessaging }
101
+ return Data(try await readFile(0x1003, chunk: 0x80))
102
+ }
103
+
104
+ /// Card applies PKCS#1 v1.5 type-1 padding to `digestInfo`; returns the raw RSA signature.
105
+ func sign(_ digestInfo: Data) async throws -> Data {
106
+ guard sm != nil else { throw CieCardError.secureMessaging }
107
+ try await ok(send(0x00, 0x22, 0x41, 0xA4, tlv(0x80, [0x02]) + tlv(0x84, [0x81])))
108
+ return Data(try await ok(send(0x00, 0x88, 0x00, 0x00, [UInt8](digestInfo))))
109
+ }
110
+
111
+ private func selectApplications() async throws {
112
+ var sw = try await send(0x00, 0xA4, 0x04, 0x0C, bytes("A0000000308000000009816001")).sw
113
+ if sw == 0x6A82 { sw = try await send(0x00, 0xA4, 0x00, 0x00).sw } // NXP chips have no IAS AID: select MF
114
+ guard sw == 0x9000, try await send(0x00, 0xA4, 0x04, 0x0C, bytes("A00000000039")).sw == 0x9000 else {
115
+ throw CieCardError.notCie
116
+ }
117
+ }
118
+
119
+ /// Reads a transparent EF up to the end of its first TLV (files are zero-padded).
120
+ private func readFile(_ id: UInt16, chunk: UInt8) async throws -> [UInt8] {
121
+ try await ok(send(0x00, 0xA4, 0x02, 0x04, [UInt8(id >> 8), UInt8(id & 0xFF)]))
122
+ var out = [UInt8](), le = chunk
123
+ func end() -> Int? { (try? children(of: out, in: 0..<1))?.first?.value.upperBound }
124
+ while end().map({ out.count < $0 }) ?? true {
125
+ let r = try await send(0x00, 0xB0, UInt8(out.count >> 8), UInt8(out.count & 0xFF), le: le)
126
+ if r.sw >> 8 == 0x6C { le = UInt8(r.sw & 0xFF); continue }
127
+ guard [0x9000, 0x6282, 0x6B00].contains(r.sw) else { throw CieCardError.status(r.sw) }
128
+ out += r.data
129
+ guard r.sw == 0x9000, !r.data.isEmpty else { break }
130
+ le = chunk
131
+ }
132
+ guard let end = end(), out.count >= end else { throw CieCardError.invalidResponse }
133
+ return Array(out[..<end])
134
+ }
135
+
136
+ @discardableResult
137
+ private func ok(_ r: (data: [UInt8], sw: UInt16)) throws -> [UInt8] {
138
+ guard r.sw == 0x9000 else { throw CieCardError.status(r.sw) }
139
+ return r.data
140
+ }
141
+
142
+ /// Sends a command with chaining, GET RESPONSE and (once established) secure messaging.
143
+ @discardableResult
144
+ private func send(_ cla: UInt8, _ ins: UInt8, _ p1: UInt8, _ p2: UInt8, _ data: [UInt8] = [], le: UInt8? = nil) async throws -> (data: [UInt8], sw: UInt16) {
145
+ let size = sm == nil ? 0xFF : 0xE7
146
+ var offset = 0
147
+ while true {
148
+ let chunk = Array(data[offset..<min(offset + size, data.count)])
149
+ offset += chunk.count
150
+ let last = offset == data.count
151
+ let header = [last ? cla : cla | 0x10, ins, p1, p2]
152
+ let leField: [UInt8] = last ? le.map { [$0] } ?? [] : []
153
+ var r = try await exchange(sm == nil ? header + (chunk.isEmpty ? [] : [UInt8(chunk.count)] + chunk) + leField : wrap(header, chunk, leField))
154
+ while r.sw >> 8 == 0x61 {
155
+ let next = try await exchange([0x00, 0xC0, 0x00, 0x00, UInt8(r.sw & 0xFF)])
156
+ r = (r.data + next.data, next.sw)
157
+ }
158
+ if sm != nil { r = try unwrap(r.data, r.sw) }
159
+ if last || r.sw != 0x9000 { return r }
160
+ }
161
+ }
162
+
163
+ private func exchange(_ apdu: [UInt8]) async throws -> (data: [UInt8], sw: UInt16) {
164
+ let r = [UInt8](try await transmit(Data(apdu)))
165
+ guard r.count >= 2 else { throw CieCardError.invalidResponse }
166
+ return (Array(r.dropLast(2)), UInt16(r[r.count - 2]) << 8 | UInt16(r[r.count - 1]))
167
+ }
168
+
169
+ /// IAS-ECC 7.1.8: 3DES-CBC (zero IV) cryptogram, retail MAC over pad(SSC || header) || DOs.
170
+ private func wrap(_ header: [UInt8], _ data: [UInt8], _ le: [UInt8]) throws -> [UInt8] {
171
+ guard var s = sm else { throw CieCardError.secureMessaging }
172
+ increment(&s.ssc)
173
+ sm = s
174
+ let header = [header[0] | 0x0C] + header[1...]
175
+ var dos = [UInt8]()
176
+ if !data.isEmpty {
177
+ let c = try cipher(kCCEncrypt, kCCAlgorithm3DES, s.enc + s.enc.prefix(8), pad(data))
178
+ dos += header[1] & 1 == 0 ? tlv(0x87, [0x01] + c) : tlv(0x85, c)
179
+ }
180
+ if !le.isEmpty { dos += tlv(0x97, le) }
181
+ dos += try tlv(0x8E, mac(s.mac, pad(s.ssc + header) + dos))
182
+ return header + [UInt8(dos.count)] + dos + [0x00]
183
+ }
184
+
185
+ private func unwrap(_ body: [UInt8], _ sw: UInt16) throws -> (data: [UInt8], sw: UInt16) {
186
+ guard var s = sm else { throw CieCardError.secureMessaging }
187
+ if body.isEmpty {
188
+ guard sw != 0x9000 else { throw CieCardError.secureMessaging }
189
+ return ([], sw)
190
+ }
191
+ increment(&s.ssc)
192
+ sm = s
193
+ var macInput = s.ssc, cc: [UInt8]?, inner: UInt16?, cryptogram: [UInt8]?
194
+ for item in try children(of: body, in: body.indices) {
195
+ let v = Array(body[item.value])
196
+ switch item.tag {
197
+ case 0x8E: cc = v; continue
198
+ case 0x99 where v.count == 2: inner = UInt16(v[0]) << 8 | UInt16(v[1])
199
+ case 0x87 where v.first == 0x01: cryptogram = Array(v.dropFirst())
200
+ case 0x85: cryptogram = v
201
+ default: throw CieCardError.secureMessaging
202
+ }
203
+ macInput += body[item.raw]
204
+ }
205
+ guard let cc, let inner, try cc == mac(s.mac, macInput) else { throw CieCardError.secureMessaging }
206
+ guard let cryptogram else { return ([], inner) }
207
+ let plain = try cipher(kCCDecrypt, kCCAlgorithm3DES, s.enc + s.enc.prefix(8), cryptogram)
208
+ guard let end = plain.lastIndex(where: { $0 != 0 }), plain[end] == 0x80 else { throw CieCardError.secureMessaging }
209
+ return (Array(plain[..<end]), inner)
210
+ }
211
+
212
+ private static let dappModulus = bytes(
213
+ "BA2837AB4C6BB827577BFF4EB7B1E49CDDE0F16614D1EF24C1B75CF70FB12CD18F4D14E2814BA4877EA800E175906076B562BA535973C5D8B378051D8AFC7407"
214
+ + "A1D919529E03C106CDA18D699AFB0D8AB4FDDD9DC719159A50DE9468F02AB103E282A50E716EC23CDA5BFC4A232B09A4B2C70745939549099B4483CBAE62D009"
215
+ + "9674DBF6F39B7223A99D88E33F1A0CDEDEEBBDC35517ABE9880AAB240E1EA166283A274A9AD93B4B1D19F3679F3E8B5FF6A1E0ED736E84D5ABE03C59E7346B42"
216
+ + "18755D75366CBF4136F0A26C3DC70A69ABAAF66E13A1B2FAAD052CA6EC9C51E2AED14D16E090254DC3F64EA2BD8A836BBA99DEFACBA3A613AEEDD93A9615273D")
217
+ private static let dappPrivateExponent = bytes(
218
+ "4716C2A38CCC7A07B415EB1A6175F2AAA0E49CEAF1BA75CBA09A684B04D8111879D3E2CCD8B94D3C5CF6C55753F0ED9587910B3C77258A01460FE84C2EDE5764"
219
+ + "EEBE9C37FB95CD69CEAF09F4B1357C276314AB43EC5B3CEFB0403F868F688E2EC09A4973E987756F8DA7A101A2CA75A54A8C4CCF9A1B6147E4DE56423AF70B20"
220
+ + "67179C5EEB646867863478D752C7F412DB277541575AA0619D30BCCC8D87E6170B33439A2C93F2D97E18C0A82343A6012A5BB1822808F01B5CFD85673AC0964C"
221
+ + "5F3CFD2DAF8142359764A9ADB9E3F76DB613461C1BC913DC9AC0AB50D365F77CB93194C98AA966D89CDD555125A5E59ECF4FA3F0C3FD610CD3D056439338FD81")
222
+ /// Private exponent matching the card's external-authentication CA public key (GET DATA 7F49).
223
+ private static let caPrivateExponent = bytes(
224
+ "186B31488C25DCF85D953D3630C0D073BA1C6AA24581AD254F3B6719C5D72CCA3E5CDC5A1E5316578D75954FF73B237B532C9F8DE4A2C4C911385A23E63E33E4"
225
+ + "7EE45E66EFD49B18E02CFF87598C39109E8F86A66BC330249CE3FCAD655DCDBF98C9C5E479321AF53B517D041061880A647BBE0FF813683470E6C50094CE81D0"
226
+ + "64E204E351BD3AE0A7947D8E91C3FD5C0A15233C349A5215A4E66E21C5D33498E71991EA24473B29F1475F6FD9BE3996E19BD474FAD1B41EA0DCD2FC16C9BFFA"
227
+ + "071BFEC1B22415184811C1985FBFE3E7B4F44A4B3C8DFAB4D90CECFC5E608D673E6762C62CB798341271149BA688162EC7D0E3468F65A94AB4AD1AB67E37BFC1")
228
+ }
229
+
230
+ private func bytes(_ hex: String) -> [UInt8] {
231
+ let c = Array(hex.utf8)
232
+ return stride(from: 0, to: c.count, by: 2).map { UInt8(String(decoding: c[$0...$0 + 1], as: UTF8.self), radix: 16)! }
233
+ }
234
+
235
+ private func sha256<D: DataProtocol>(_ data: D) -> [UInt8] { Array(SHA256.hash(data: data)) }
236
+
237
+ private func pad(_ data: [UInt8]) -> [UInt8] {
238
+ data + [0x80] + [UInt8](repeating: 0, count: 7 - data.count % 8)
239
+ }
240
+
241
+ private func increment(_ counter: inout [UInt8]) {
242
+ for i in counter.indices.reversed() {
243
+ counter[i] &+= 1
244
+ if counter[i] != 0 { return }
245
+ }
246
+ }
247
+
248
+ /// CBC with zero IV, no padding.
249
+ private func cipher(_ op: Int, _ alg: Int, _ key: [UInt8], _ data: [UInt8]) throws -> [UInt8] {
250
+ var out = [UInt8](repeating: 0, count: data.count)
251
+ var moved = 0
252
+ let (count, iv) = (data.count, [UInt8](repeating: 0, count: 8))
253
+ guard CCCrypt(CCOperation(op), CCAlgorithm(alg), 0, key, key.count, iv, data, count, &out, count, &moved) == kCCSuccess else {
254
+ throw CieCardError.secureMessaging
255
+ }
256
+ return out
257
+ }
258
+
259
+ /// ISO 9797-1 MAC algorithm 3 (retail MAC) with ISO padding.
260
+ private func mac(_ key: [UInt8], _ data: [UInt8]) throws -> [UInt8] {
261
+ let (k1, k2) = (Array(key[0..<8]), Array(key[8..<16]))
262
+ let last = Array(try cipher(kCCEncrypt, kCCAlgorithmDES, k1, pad(data)).suffix(8))
263
+ return try cipher(kCCEncrypt, kCCAlgorithmDES, k1, cipher(kCCDecrypt, kCCAlgorithmDES, k2, last))
264
+ }
265
+
266
+ /// base^exp mod modulus, computed as a raw RSA public-key operation (corecrypto accepts any exponent, even ones).
267
+ private func modPow(_ base: [UInt8], _ exp: [UInt8], _ modulus: [UInt8]) throws -> [UInt8] {
268
+ func integer(_ v: [UInt8]) -> [UInt8] {
269
+ let v = Array(v.drop { $0 == 0 })
270
+ return tlv(0x02, (v.first ?? 0x80) < 0x80 ? v : [0] + v)
271
+ }
272
+ let (n, b) = (Array(modulus.drop { $0 == 0 }), Array(base.drop { $0 == 0 }))
273
+ let attributes = [kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPublic] as CFDictionary
274
+ guard b.count <= n.count, let key = SecKeyCreateWithData(Data(tlv(0x30, integer(n) + integer(exp))) as CFData, attributes, nil),
275
+ let r = SecKeyCreateEncryptedData(key, .rsaEncryptionRaw, Data(count: n.count - b.count) + b as CFData, nil)
276
+ else { throw CieCardError.secureMessaging }
277
+ return [UInt8](r as Data)
278
+ }
279
+
280
+ private func tlv(_ tag: UInt, _ value: [UInt8]) -> [UInt8] {
281
+ let n = value.count
282
+ let length: [UInt8] = n < 0x80 ? [UInt8(n)] : n < 0x100 ? [0x81, UInt8(n)] : [0x82, UInt8(n >> 8), UInt8(n & 0xFF)]
283
+ return (tag > 0xFF ? [UInt8(tag >> 8), UInt8(tag & 0xFF)] : [UInt8(tag)]) + length + value
284
+ }
285
+
286
+ private typealias TLV = (tag: UInt, constructed: Bool, raw: Range<Int>, value: Range<Int>)
287
+
288
+ /// BER-TLV items starting within `range` of `bytes`; 00/FF bytes between items are skipped as padding.
289
+ private func children(of bytes: [UInt8], in range: Range<Int>) throws -> [TLV] {
290
+ var items = [TLV]()
291
+ var i = range.lowerBound
292
+ func next() throws -> UInt8 {
293
+ guard i < bytes.count else { throw CieCardError.invalidResponse }
294
+ defer { i += 1 }
295
+ return bytes[i]
296
+ }
297
+ while i < range.upperBound {
298
+ let start = i, first = try next()
299
+ if first == 0x00 || first == 0xFF { continue }
300
+ var tag = UInt(first)
301
+ if first & 0x1F == 0x1F {
302
+ var b: UInt8
303
+ repeat { b = try next(); tag = tag << 8 | UInt(b) } while b & 0x80 != 0
304
+ }
305
+ var length = try Int(next())
306
+ if length > 0x80 {
307
+ guard length <= 0x83 else { throw CieCardError.invalidResponse }
308
+ length = try (0..<(length & 0x7F)).reduce(0) { acc, _ in try acc << 8 | Int(next()) }
309
+ } else if length == 0x80 {
310
+ throw CieCardError.invalidResponse
311
+ }
312
+ guard i + length <= bytes.count else { throw CieCardError.invalidResponse }
313
+ items.append((tag, first & 0x20 != 0, start..<(i + length), i..<(i + length)))
314
+ i += length
315
+ }
316
+ return items
317
+ }
318
+
319
+ /// Depth-first search for the value of `tag`.
320
+ private func find(_ tag: UInt, in bytes: [UInt8]) throws -> [UInt8] {
321
+ func search(_ range: Range<Int>) throws -> [UInt8]? {
322
+ for item in try children(of: bytes, in: range) {
323
+ if item.tag == tag { return Array(bytes[item.value]) }
324
+ if item.constructed, let v = try search(item.value) { return v }
325
+ }
326
+ return nil
327
+ }
328
+ guard let v = try search(bytes.indices), v.count > 0 else { throw CieCardError.invalidResponse }
329
+ return v
330
+ }
@@ -0,0 +1,18 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'CieSign'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license']
10
+ s.author = package['author']
11
+ s.homepage = package['homepage']
12
+ s.platforms = { :ios => '16.4' }
13
+ s.swift_version = '5.9'
14
+ s.source = { git: 'https://github.com/codingducksrl/ciesign-react-native.git' }
15
+ s.static_framework = true
16
+ s.dependency 'ExpoModulesCore'
17
+ s.source_files = '*.swift'
18
+ end
@@ -0,0 +1,176 @@
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
+ AsyncFunction("connect") {
14
+ @MainActor (id: Int, pin: String?, save: Bool, prompt: String, biometricPrompt: String, biometricCancel: String) async throws -> Data in
15
+ guard NFCTagReaderSession.readingAvailable else { throw failure("NFC_UNAVAILABLE", "NFC tag reading is not available") }
16
+ guard id > self.closed else { throw cancelled }
17
+ self.reader?.close(nil)
18
+ let reader = Reader(id: id)
19
+ self.reader = reader
20
+ do {
21
+ return try await reader.connect(pin: pin, save: save, prompt: prompt, biometricPrompt: biometricPrompt, biometricCancel: biometricCancel)
22
+ } catch {
23
+ let error = coded(reader.invalidation ?? error)
24
+ if pin == nil, let code = (error as? Exception)?.code, code.hasPrefix("WRONG_PIN") || code == "PIN_LOCKED" { SavedPin.delete() }
25
+ throw error
26
+ }
27
+ }
28
+
29
+ AsyncFunction("sign") { @MainActor (id: Int, digestInfo: Data) async throws -> Data in
30
+ guard let reader = self.reader, reader.id == id else { throw cancelled }
31
+ do {
32
+ guard let card = reader.card else { throw cancelled } // the reason is in `invalidation`, e.g. a timeout
33
+ return try await card.sign(digestInfo)
34
+ } catch {
35
+ throw coded(reader.invalidation ?? error)
36
+ }
37
+ }
38
+
39
+ AsyncFunction("close") { @MainActor (id: Int, error: String?) async in
40
+ self.closed = max(self.closed, id)
41
+ guard let reader = self.reader, reader.id <= id else { return }
42
+ reader.close(error)
43
+ self.reader = nil
44
+ }
45
+
46
+ AsyncFunction("pinStatus") { () -> String in
47
+ SavedPin.isSaved ? "saved" : SavedPin.canSave ? "available" : "unavailable"
48
+ }
49
+
50
+ AsyncFunction("forgetPin") { SavedPin.delete() }
51
+
52
+ OnDestroy {
53
+ DispatchQueue.main.async { self.reader?.close(nil) }
54
+ }
55
+ }
56
+ }
57
+
58
+ /// One NFCTagReaderSession; used on the main thread only.
59
+ private final class Reader: NSObject, NFCTagReaderSessionDelegate {
60
+ let id: Int
61
+ private var session: NFCTagReaderSession?
62
+ private var pending: CheckedContinuation<Data, Error>?
63
+ private var pin = ""
64
+ private var save = false
65
+ private var owner: Data? // NIS of the card the saved PIN belongs to
66
+ private var biometrics: LAContext?
67
+ private var closed = false
68
+ private(set) var card: CieCard?
69
+ private(set) var invalidation: Error?
70
+
71
+ init(id: Int) {
72
+ self.id = id
73
+ }
74
+
75
+ /// A nil `pin` unlocks the saved one with Face ID or Touch ID; `save` saves `pin` once the card accepts it, with no
76
+ /// prompt, since writing the Keychain item needs no authentication.
77
+ func connect(pin: String?, save: Bool, prompt: String, biometricPrompt: String, biometricCancel: String) async throws -> Data {
78
+ if let pin {
79
+ if save, !SavedPin.canSave { throw SavedPinError.biometryUnavailable }
80
+ (self.pin, self.save) = (pin, save)
81
+ } else {
82
+ let context = LAContext()
83
+ context.localizedCancelTitle = biometricCancel
84
+ context.localizedFallbackTitle = ""
85
+ biometrics = context
86
+ (self.pin, owner) = try await SavedPin.unlock(context, reason: biometricPrompt)
87
+ }
88
+ guard !closed else { throw cancelled }
89
+ return try await withCheckedThrowingContinuation { continuation in
90
+ guard let session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: .main) else {
91
+ return continuation.resume(throwing: failure("NFC_UNAVAILABLE", "NFC tag reading is not available"))
92
+ }
93
+ pending = continuation
94
+ self.session = session
95
+ session.alertMessage = prompt
96
+ session.begin()
97
+ }
98
+ }
99
+
100
+ func close(_ error: String?) {
101
+ closed = true
102
+ biometrics?.invalidate()
103
+ finish(.failure(cancelled))
104
+ if let error { session?.invalidate(errorMessage: error) } else { session?.invalidate() }
105
+ session = nil
106
+ card = nil
107
+ }
108
+
109
+ func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}
110
+
111
+ func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
112
+ invalidation = error
113
+ card = nil
114
+ finish(.failure(error))
115
+ }
116
+
117
+ func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
118
+ guard tags.count == 1 else { return session.restartPolling() }
119
+ guard case let .iso7816(tag) = tags[0] else { return finish(.failure(failure("NOT_A_CIE", "Not an ISO 7816 card"))) }
120
+ Task { @MainActor in
121
+ do {
122
+ try await session.connect(to: tags[0])
123
+ let card = CieCard { apdu in
124
+ guard let command = NFCISO7816APDU(data: apdu) else { throw failure("CARD_ERROR", "Malformed APDU") }
125
+ let (data, sw1, sw2) = try await tag.sendCommand(apdu: command)
126
+ return data + [sw1, sw2]
127
+ }
128
+ let serviceId = try await card.serviceId()
129
+ if let owner, owner != serviceId { throw SavedPinError.otherCard }
130
+ try await card.open(pin: pin)
131
+ if save { try SavedPin.store(pin, card: serviceId) }
132
+ let certificate = try await card.certificate()
133
+ self.card = card
134
+ finish(.success(certificate))
135
+ } catch let error as NFCReaderError where error.code == .readerTransceiveErrorSessionInvalidated {
136
+ // didInvalidateWithError follows with the reason (cancel, timeout).
137
+ } catch {
138
+ finish(.failure(error))
139
+ }
140
+ }
141
+ }
142
+
143
+ private func finish(_ result: Result<Data, Error>) {
144
+ pending?.resume(with: result)
145
+ pending = nil
146
+ }
147
+ }
148
+
149
+ private func failure(_ code: String, _ description: String) -> Exception {
150
+ Exception(name: code, description: description, code: code)
151
+ }
152
+
153
+ private var cancelled: Exception { failure("CANCELLED", "Cancelled") }
154
+
155
+ private func coded(_ error: Error) -> Error {
156
+ switch error {
157
+ case let error as Exception: return error
158
+ case CieCardError.wrongPin(let attemptsLeft): return failure("WRONG_PIN_\(attemptsLeft)", "Wrong PIN")
159
+ case CieCardError.pinLocked: return failure("PIN_LOCKED", "PIN locked: unlock it with the PUK")
160
+ case CieCardError.notCie: return failure("NOT_A_CIE", "Not a CIE")
161
+ case SavedPinError.notSaved: return failure("PIN_NOT_SAVED", "No saved PIN")
162
+ case SavedPinError.otherCard: return failure("WRONG_CARD", "Not the card the PIN was saved for")
163
+ case SavedPinError.biometryUnavailable: return failure("BIOMETRIC_UNAVAILABLE", "Face ID or Touch ID is not available")
164
+ case SavedPinError.cancelled: return cancelled
165
+ case let error as NFCReaderError:
166
+ switch error.code {
167
+ case .readerSessionInvalidationErrorUserCanceled, .readerTransceiveErrorSessionInvalidated: return cancelled
168
+ case .readerSessionInvalidationErrorSessionTimeout: return failure("TIMEOUT", "NFC session timed out")
169
+ case .readerSessionInvalidationErrorSystemIsBusy: return failure("BUSY", "NFC is busy, retry")
170
+ case .readerTransceiveErrorTagConnectionLost, .readerTransceiveErrorTagResponseError, .readerTransceiveErrorTagNotConnected:
171
+ return failure("TAG_LOST", "Card connection lost")
172
+ default: return failure("CARD_ERROR", error.localizedDescription)
173
+ }
174
+ default: return failure("CARD_ERROR", "\(error)")
175
+ }
176
+ }