From e51bc85af3c2e01df53c78e1b423c927504f6f29 Mon Sep 17 00:00:00 2001 From: Raul Metsma Date: Sat, 17 Oct 2026 22:58:05 +0300 Subject: [PATCH] Fixes for utils and add tests Signed-off-by: Raul Metsma --- .github/workflows/build.yml | 4 + .gitignore | 3 + Package.swift | 10 ++- Sources/nfclib/CardActions/Idemia.swift | 24 +++--- Sources/nfclib/CardActions/Thales.swift | 28 ++----- .../Extensions/DataProtocol+Extension.swift | 30 ++++--- .../nfclib/Extensions/Logger+Extensions.swift | 20 +---- Sources/nfclib/Utilities/AES.swift | 25 +----- Tests/nfclibTests/AESTests.swift | 74 ++++++++++++++++ Tests/nfclibTests/DataProtocolTests.swift | 84 +++++++++++++++++++ Tests/nfclibTests/SHATests.swift | 45 ++++++++++ 11 files changed, 260 insertions(+), 87 deletions(-) create mode 100644 Tests/nfclibTests/AESTests.swift create mode 100644 Tests/nfclibTests/DataProtocolTests.swift create mode 100644 Tests/nfclibTests/SHATests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7280d06..aa89afa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,3 +19,7 @@ jobs: run: swift build --triple arm64-apple-ios18.0 --sdk "$(xcrun --sdk iphoneos --show-sdk-path)" - name: Build for iOS simulator run: swift build --triple arm64-apple-ios18.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" + - name: Run tests on iOS simulator + run: | + DEVICE_ID=$(xcrun simctl list devices available --json | jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | startswith("iPhone"))][0].udid') + xcodebuild test -scheme IdCardLib -destination "id=$DEVICE_ID" diff --git a/.gitignore b/.gitignore index e4c2d26..89e0687 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ timeline.xctimeline playground.xcworkspace .DS_Store + +## Swift Package Manager +.swiftpm/ diff --git a/Package.swift b/Package.swift index 9dee966..59e7cf7 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( .library(name: "IdCardLib", targets: ["nfclib"]) ], dependencies: [ - .package(url: "https://github.com/leif-ibsen/BigInt.git", from: "1.15.0"), + .package(url: "https://github.com/leif-ibsen/BigInt", from: "1.15.0"), .package(url: "https://github.com/leif-ibsen/SwiftECC", from: "5.1.0") ], targets: [ @@ -21,6 +21,14 @@ let package = Package( .product(name: "BigInt", package: "BigInt"), .product(name: "SwiftECC", package: "SwiftECC") ] + ), + .testTarget( + name: "nfclibTests", + dependencies: [ + "nfclib", + .product(name: "BigInt", package: "BigInt"), + .product(name: "SwiftECC", package: "SwiftECC") + ] ) ] ) diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index 6613459..d270474 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -3,11 +3,18 @@ import Foundation -private let ATR = Bytes(hex: "3B DB 96 00 80 B1 FE 45 1F 83 00 12 23 3F 53 65 49 44 0F 90 00 F1") -private let ATRv2 = Bytes(hex: "3B DC 96 00 80 B1 FE 45 1F 83 00 12 23 3F 54 65 49 44 32 0F 90 00 C3") -private let kAID = Bytes(hex: "A0 00 00 00 77 01 08 00 07 00 00 FE 00 00 01 00") -private let kAIDQSCD = Bytes(hex: "51 53 43 44 20 41 70 70 6C 69 63 61 74 69 6F 6E") -private let kAIDOberthur = Bytes(hex: "E8 28 BD 08 0F F2 50 4F 54 20 41 57 50") +private let kAID: Bytes = [ + 0xA0, 0x00, 0x00, 0x00, 0x77, 0x01, 0x08, 0x00, + 0x07, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x01, 0x00 +] +private let kAIDQSCD: Bytes = [ + 0x51, 0x53, 0x43, 0x44, 0x20, 0x41, 0x70, 0x70, + 0x6C, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6F, 0x6E +] +private let kAIDOberthur: Bytes = [ + 0xE8, 0x28, 0xBD, 0x08, 0x0F, 0xF2, 0x50, 0x4F, + 0x54, 0x20, 0x41, 0x57, 0x50 +] private let AUTHKEY: UInt8 = 0x81 private let SIGNKEY: UInt8 = 0x9F @@ -33,13 +40,6 @@ final class Idemia: CardCommandsInternal { let reader: CardReader let fillChar: UInt8 = 0xFF - required init?(reader: CardReader, atr: Bytes) { - guard atr == ATR || atr == ATRv2 else { - return nil - } - self.reader = reader - } - required init?(reader: CardReader, aid: Bytes) { guard aid == kAID else { return nil diff --git a/Sources/nfclib/CardActions/Thales.swift b/Sources/nfclib/CardActions/Thales.swift index 0e01e39..f2d8bf4 100644 --- a/Sources/nfclib/CardActions/Thales.swift +++ b/Sources/nfclib/CardActions/Thales.swift @@ -14,9 +14,14 @@ extension CodeType { } final class Thales: CardCommandsInternal { - static private let ATR = Bytes(hex: "3B FF 96 00 00 80 31 FE 43 80 31 B8 53 65 49 44 64 B0 85 05 10 12 23 3F 1D") - static private let kAID = Bytes(hex: "A0 00 00 00 63 50 4B 43 53 2D 31 35") - static private let kAIDGlobal = Bytes(hex: "A0 00 00 00 18 10 02 03 00 00 00 00 00 00 00 01") + static private let kAID: Bytes = [ + 0xA0, 0x00, 0x00, 0x00, 0x63, 0x50, 0x4B, 0x43, + 0x53, 0x2D, 0x31, 0x35 + ] + static private let kAIDGlobal: Bytes = [ + 0xA0, 0x00, 0x00, 0x00, 0x18, 0x10, 0x02, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 + ] static private let AUTHKEY: UInt8 = 0x01 static private let SIGNKEY: UInt8 = 0x05 @@ -24,13 +29,6 @@ final class Thales: CardCommandsInternal { let reader: CardReader let fillChar: UInt8 = 0x00 - init?(reader: any CardReader, atr: Bytes) throws { - guard atr == Thales.ATR else { - return nil - } - self.reader = reader - } - required init?(reader: CardReader, aid: Bytes) { guard aid == Thales.kAIDGlobal else { return nil @@ -139,9 +137,7 @@ final class Thales: CardCommandsInternal { p2Byte: 0xA0, data: TLV( tag: 0x90, - value: Data( - hash - ) + bytes: hash ).data ) return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x9E, p2Byte: 0x9A, leByte: 0x00) @@ -167,9 +163,3 @@ final class Thales: CardCommandsInternal { return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x80, p2Byte: 0x86, data: [0x00] + hash, leByte: 0x00) } } - -extension Bytes { - init(hex: String) { - self = hex.split(separator: " ").compactMap { UInt8($0, radix: 16) } - } -} diff --git a/Sources/nfclib/Extensions/DataProtocol+Extension.swift b/Sources/nfclib/Extensions/DataProtocol+Extension.swift index 3bbcf39..dd0be7e 100644 --- a/Sources/nfclib/Extensions/DataProtocol+Extension.swift +++ b/Sources/nfclib/Extensions/DataProtocol+Extension.swift @@ -30,13 +30,19 @@ extension DataProtocol where Self.Index == Int { extension DataProtocol where Self.Index == Int, Self: MutableDataProtocol { init?(hex: String) { - guard hex.count.isMultiple(of: 2) else { return nil } - let chars = hex.map { $0 } - let bytes = stride(from: 0, to: chars.count, by: 2) - .map { String(chars[$0]) + String(chars[$0 + 1]) } - .compactMap { UInt8($0, radix: 16) } - guard hex.count / bytes.count == 2 else { return nil } - self.init(bytes) + self.init() + reserveCapacity(hex.utf8.count / 2) + var high: UInt8? + for char in hex where char != " " { + guard let value = char.hexDigitValue.map(UInt8.init) else { return nil } + if let highValue = high { + append(highValue << 4 | value) + high = nil + } else { + high = value + } + } + guard high == nil else { return nil } } func addPadding() -> Self { @@ -45,14 +51,6 @@ extension DataProtocol where Self.Index == Int, Self: MutableDataProtocol { return self + padding } - public static func ^ (xVal: Self, yVal: Self) -> Self { - var result = xVal - for index in 0..(lhs: Self, rhs: D) -> Self where D.Element == Self.Element { precondition(lhs.count == rhs.count, "XOR operands must have equal length") var result = lhs @@ -66,7 +64,7 @@ extension DataProtocol where Self.Index == Int, Self: MutableDataProtocol { var index = endIndex while index != startIndex { formIndex(before: &index) - self[index] += 1 + self[index] &+= 1 if self[index] != 0 { break } diff --git a/Sources/nfclib/Extensions/Logger+Extensions.swift b/Sources/nfclib/Extensions/Logger+Extensions.swift index 3122a57..5d3cb70 100644 --- a/Sources/nfclib/Extensions/Logger+Extensions.swift +++ b/Sources/nfclib/Extensions/Logger+Extensions.swift @@ -1,21 +1,5 @@ -/* - * Copyright 2017 - 2025 Riigi Infosüsteemi Amet - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - * - */ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later import os diff --git a/Sources/nfclib/Utilities/AES.swift b/Sources/nfclib/Utilities/AES.swift index c10bd1b..0aaac72 100644 --- a/Sources/nfclib/Utilities/AES.swift +++ b/Sources/nfclib/Utilities/AES.swift @@ -18,11 +18,11 @@ class AES { } func encrypt(_ data: T) throws -> Bytes { - return try crypt(data: data, operation: kCCEncrypt) + try crypt(data: data, operation: kCCEncrypt) } func decrypt(_ data: T) throws -> Bytes { - return try crypt(data: data, operation: kCCDecrypt) + try crypt(data: data, operation: kCCDecrypt) } private func crypt(data: T, operation: Int) throws -> Bytes { @@ -51,24 +51,7 @@ class AES { } public class CMAC { - static let RBytes: Bytes = [ - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x87 - ] + static let RBytes: Bytes = Bytes(repeating: 0x00, count: BlockSize - 1) + [0x87] let cipher: AES.CBC let k1Bytes: Bytes let k2Bytes: Bytes @@ -90,7 +73,7 @@ class AES { mLast = Bytes(last).addPadding() ^ k2Bytes } } else { - mLast = Bytes().addPadding() ^ k1Bytes + mLast = Bytes().addPadding() ^ k2Bytes } var xVal = Bytes(repeating: 0x00, count: BlockSize) diff --git a/Tests/nfclibTests/AESTests.swift b/Tests/nfclibTests/AESTests.swift new file mode 100644 index 0000000..082e119 --- /dev/null +++ b/Tests/nfclibTests/AESTests.swift @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Testing +@testable import nfclib + +// NIST SP 800-38A / 800-38B example plaintext +private let message = Bytes(hex: """ + 6bc1bee22e409f96e93d7e117393172a ae2d8a571e03ac9c9eb76fac45af8e51 \ + 30c81c46a35ce411e5fbc1191a0a52ef f69f2445df4f9b17ad2b417be66c3710 + """)! +private let key128 = Bytes(hex: "2b7e151628aed2a6abf7158809cf4f3c")! +private let key256 = Bytes(hex: "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4")! + +struct AESCBCTests { + // NIST SP 800-38A F.2.5 / F.2.6 CBC-AES256 + let ivVal = Bytes(hex: "000102030405060708090a0b0c0d0e0f")! + let cipherText = Bytes(hex: """ + f58c4c04d6e5f1ba779eabfb5f7bfbd6 9cfc4e967edb808d679f777bc6702c7d \ + 39f23369a9d9bacfa530e26304231461 b2eb05e2c39be9fcda6c19078c6a9d1b + """)! + + @Test func encryptAES256() throws { + #expect(try AES.CBC(key: key256, ivVal: ivVal).encrypt(message) == cipherText) + } + + @Test func decryptAES256() throws { + #expect(try AES.CBC(key: key256, ivVal: ivVal).decrypt(cipherText) == message) + } + + @Test func rejectsPartialBlock() { + #expect(throws: IdCardInternalError.self) { + try AES.CBC(key: key256).encrypt(Bytes(repeating: 0, count: 15)) + } + } +} + +struct AESCMACTests { + // RFC 4493 section 2.3 + @Test func subkeysAES128() throws { + let cmac = try AES.CMAC(key: key128) + #expect(cmac.k1Bytes == Bytes(hex: "fbeed618357133667c85e08f7236a8de")!) + #expect(cmac.k2Bytes == Bytes(hex: "f7ddac306ae266ccf90bc11ee46d513b")!) + } + + // RFC 4493 section 4, examples 1-4 + @Test(arguments: [ + (0, "bb1d6929e95937287fa37d129b756746"), + (16, "070a16b46b4d4144f79bdd9dd04a287c"), + (40, "dfa66747de9ae63030ca32611497c827"), + (64, "51f0bebf7e3b9d92fc49741779363cfe") + ]) + func macAES128(length: Int, expected: String) throws { + let mac = try AES.CMAC(key: key128).authenticate(bytes: Bytes(message.prefix(length)), count: 16) + #expect(Bytes(mac) == Bytes(hex: expected)!) + } + + // NIST SP 800-38B appendix D.3 + @Test(arguments: [ + (0, "028962f61b7bf89efc6b551f4667d983"), + (16, "28a7023f452e8f82bd4bf28d8c37c35c"), + (40, "aaf3d8f1de5640c232f5b169b9c911e6"), + (64, "e1992190549f6ed5696a2c056c315410") + ]) + func macAES256(length: Int, expected: String) throws { + let mac = try AES.CMAC(key: key256).authenticate(bytes: Bytes(message.prefix(length)), count: 16) + #expect(Bytes(mac) == Bytes(hex: expected)!) + } + + @Test func defaultTruncationIs8Bytes() throws { + let mac = try AES.CMAC(key: key256).authenticate(bytes: message) + #expect(Bytes(mac) == Bytes(hex: "e1992190549f6ed5")!) + } +} diff --git a/Tests/nfclibTests/DataProtocolTests.swift b/Tests/nfclibTests/DataProtocolTests.swift new file mode 100644 index 0000000..021ffea --- /dev/null +++ b/Tests/nfclibTests/DataProtocolTests.swift @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation +import Testing +@testable import nfclib + +struct HexParsingTests { + @Test(arguments: [ + ("a668892a", [0xa6, 0x68, 0x89, 0x2a]), + ("A6 68 89 2A", [0xa6, 0x68, 0x89, 0x2a]), + ("00ff", [0x00, 0xff]), + ("", []) + ] as [(String, Bytes)]) + func parsesValidHex(hex: String, expected: Bytes) { + #expect(Bytes(hex: hex) == expected) + } + + @Test(arguments: ["0011zz22", "+f", "-1", "abc", "0x12", "12 3"]) + func rejectsInvalidHex(hex: String) { + #expect(Bytes(hex: hex) == nil) + } + + @Test func parsesIntoData() { + #expect(Data(hex: "04007f00") == Data([0x04, 0x00, 0x7f, 0x00])) + } + + @Test func roundTripsThroughToHex() { + let bytes: Bytes = [0x00, 0x7f, 0x80, 0xff] + #expect(Bytes(hex: bytes.toHex) == bytes) + } +} + +struct XORTests { + @Test func xorsEqualLengthBytes() { + let lhs: Bytes = [0x0f, 0xf0, 0xaa] + let rhs: Bytes = [0xff, 0xff, 0x55] + #expect(lhs ^ rhs == [0xf0, 0x0f, 0xff]) + } + + @Test func xorsSliceWithNonZeroStartIndex() { + let slice = Data([0x01, 0x02, 0x03, 0x04]).dropFirst() + #expect(slice ^ Data([0x02, 0x03, 0x04]) == Data([0x00, 0x00, 0x00])) + } +} + +struct SendSequenceCounterTests { + @Test func incrementsLastByte() { + var ssc = Bytes(hex: "000000000000000000000000000000fe")! + #expect(ssc.increment() == Bytes(hex: "000000000000000000000000000000ff")!) + } + + @Test func carriesIntoNextByte() { + var ssc = Bytes(hex: "000000000000000000000000000000ff")! + #expect(ssc.increment() == Bytes(hex: "00000000000000000000000000000100")!) + } + + @Test func wrapsAround() { + var ssc = Bytes(repeating: 0xff, count: 16) + #expect(ssc.increment() == AES.Zero) + } +} + +struct PaddingTests { + // ISO/IEC 9797-1 padding method 2 + @Test func padsPartialBlock() { + #expect(Bytes(hex: "0102")!.addPadding() == Bytes(hex: "01028000000000000000000000000000")!) + } + + @Test func padsFullBlockWithExtraBlock() { + let block = Bytes(repeating: 0x11, count: 16) + #expect(block.addPadding() == block + Bytes(hex: "80000000000000000000000000000000")!) + } + + @Test func removesPadding() throws { + #expect(Bytes(try Bytes(hex: "01028000000000000000000000000000")!.removePadding()) == Bytes(hex: "0102")!) + } + + @Test func rejectsMissingPaddingMarker() { + #expect(throws: IdCardInternalError.self) { + try Bytes(hex: "01020000000000000000000000000000")!.removePadding() + } + } +} diff --git a/Tests/nfclibTests/SHATests.swift b/Tests/nfclibTests/SHATests.swift new file mode 100644 index 0000000..8fb5060 --- /dev/null +++ b/Tests/nfclibTests/SHATests.swift @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Testing +@testable import nfclib + +// NIST FIPS 180-4 example messages +private let message448 = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" +private let message896 = "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmn" + + "hijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu" + +struct SHATests { + @Test(arguments: [ + ("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + ("abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), + (message448, "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1") + ]) + func sha256(message: String, expected: String) { + #expect(sha(hashLength: .bits256, data: Bytes(message.utf8)) == Bytes(hex: expected)!) + } + + @Test(arguments: [ + ("", "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da" + + "274edebfe76f65fbd51ad2f14898b95b"), + ("abc", "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed" + + "8086072ba1e7cc2358baeca134c825a7"), + (message896, "09330c33f71147e83d192fc782cd1b4753111b173b3b05d22fa08086e3b0f712" + + "fcc7c71a557e2db966c3e9fa91746039") + ]) + func sha384(message: String, expected: String) { + #expect(sha(hashLength: .bits384, data: Bytes(message.utf8)) == Bytes(hex: expected)!) + } + + @Test(arguments: [ + ("", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce" + + "47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"), + ("abc", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" + + "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"), + (message896, "8e959b75dae313da8cf4f72814fc143f8f7779c6eb9f7fa17299aeadb6889018" + + "501d289e4900f7e4331b99dec4b5433ac7d329eeb6dd26545e96e55b874be909") + ]) + func sha512(message: String, expected: String) { + #expect(sha(hashLength: .bits512, data: Bytes(message.utf8)) == Bytes(hex: expected)!) + } +}