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/build/index.d.ts
ADDED
|
@@ -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;
|
package/build/native.js
ADDED
|
@@ -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
|
+
}
|