From f99bf344fe39961efb447401ab9dbdd8d3021d41 Mon Sep 17 00:00:00 2001 From: Zhang Lishan <133956789+zls233@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:45:15 +0800 Subject: [PATCH] feat(mac): persist device identity in keychain --- apps/mac-app/Package.swift | 11 +- .../DeviceIdentityStore.swift | 201 ++++++++++++++++++ .../DeviceIdentityStoreTests.swift | 195 +++++++++++++++++ 3 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 apps/mac-app/Sources/TouchCodeIdentity/DeviceIdentityStore.swift create mode 100644 apps/mac-app/Tests/TouchCodeIdentityTests/DeviceIdentityStoreTests.swift diff --git a/apps/mac-app/Package.swift b/apps/mac-app/Package.swift index 4f41359..bcac529 100644 --- a/apps/mac-app/Package.swift +++ b/apps/mac-app/Package.swift @@ -8,10 +8,19 @@ let package = Package( .executable(name: "TouchCodeMac", targets: ["TouchCodeMac"]) ], targets: [ + .target( + name: "TouchCodeIdentity", + path: "Sources/TouchCodeIdentity" + ), .executableTarget( name: "TouchCodeMac", + dependencies: ["TouchCodeIdentity"], path: "Sources/TouchCodeMac" + ), + .testTarget( + name: "TouchCodeIdentityTests", + dependencies: ["TouchCodeIdentity"], + path: "Tests/TouchCodeIdentityTests" ) ] ) - diff --git a/apps/mac-app/Sources/TouchCodeIdentity/DeviceIdentityStore.swift b/apps/mac-app/Sources/TouchCodeIdentity/DeviceIdentityStore.swift new file mode 100644 index 0000000..1e2338e --- /dev/null +++ b/apps/mac-app/Sources/TouchCodeIdentity/DeviceIdentityStore.swift @@ -0,0 +1,201 @@ +import CryptoKit +import Foundation +import Security + +public struct DeviceIdentity: Encodable, Equatable, Sendable { + public let version: Int + public let deviceId: String + public let keyAlgorithm: String + public let signatureAlgorithm: String + public let signatureEncoding: String + public let publicKeyX963: String + public let displayName: String + + init(publicKeyX963: Data, displayName: String) throws { + self.version = 1 + self.deviceId = try DeviceIdentityDerivation.deviceID(publicKeyX963: publicKeyX963) + self.keyAlgorithm = "p256" + self.signatureAlgorithm = "ecdsa-sha256" + self.signatureEncoding = "asn1-der" + self.publicKeyX963 = DeviceIdentityDerivation.base64URL(publicKeyX963) + self.displayName = displayName + } +} + +public enum DeviceIdentityError: Error, Equatable, Sendable { + case invalidDisplayName + case keychain(OSStatus) + case keyGeneration(String) + case invalidStoredKey + case publicKeyExtraction(String) + case signingUnsupported + case signingFailed(String) +} + +public enum DeviceIdentityDerivation { + public static func deviceID(publicKeyX963: Data) throws -> String { + guard publicKeyX963.count == 65, publicKeyX963.first == 0x04 else { + throw DeviceIdentityError.invalidStoredKey + } + + var material = Data("TouchCode device id v1".utf8) + material.append(0) + material.append(Data("p256".utf8)) + material.append(0) + material.append(publicKeyX963) + return "tcid1_\(base64URL(Data(SHA256.hash(data: material))))" + } + + public static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .trimmingCharacters(in: CharacterSet(charactersIn: "=")) + } +} + +protocol P256KeyProviding: Sendable { + func loadPrivateKey() throws -> SecKey? + func createPrivateKey() throws -> SecKey +} + +final class KeychainP256KeyProvider: P256KeyProviding, @unchecked Sendable { + private let applicationTag: Data + + init(applicationTag: String) { + self.applicationTag = Data(applicationTag.utf8) + } + + func loadPrivateKey() throws -> SecKey? { + var result: CFTypeRef? + let status = SecItemCopyMatching([ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: applicationTag, + kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw DeviceIdentityError.keychain(status) + } + guard let result, CFGetTypeID(result) == SecKeyGetTypeID() else { + throw DeviceIdentityError.invalidStoredKey + } + return (result as! SecKey) + } + + func createPrivateKey() throws -> SecKey { + var error: Unmanaged? + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: applicationTag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ], + ] + + if let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) { + return key + } + + if let error { + let retainedError = error.takeRetainedValue() + let status = OSStatus(CFErrorGetCode(retainedError)) + if status == errSecDuplicateItem, + let existing = try loadPrivateKey() { + return existing + } + if CFErrorGetDomain(retainedError) as String == NSOSStatusErrorDomain { + throw DeviceIdentityError.keychain(status) + } + throw DeviceIdentityError.keyGeneration(retainedError.localizedDescription) + } + throw DeviceIdentityError.keyGeneration("Security.framework did not return an error") + } +} + +public final class DeviceIdentityStore: @unchecked Sendable { + public static let defaultApplicationTag = "com.touchcode.mac.device-identity.v1" + + private let provider: any P256KeyProviding + private let lock = NSLock() + private var cachedPrivateKey: SecKey? + + public convenience init(applicationTag: String = defaultApplicationTag) { + self.init(provider: KeychainP256KeyProvider(applicationTag: applicationTag)) + } + + init(provider: any P256KeyProviding) { + self.provider = provider + } + + public func loadOrCreate(displayName: String) throws -> DeviceIdentity { + let normalizedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty, normalizedName.count <= 128 else { + throw DeviceIdentityError.invalidDisplayName + } + + lock.lock() + defer { lock.unlock() } + let privateKey = try privateKeyLocked() + let publicKey = try publicKeyX963(privateKey: privateKey) + return try DeviceIdentity(publicKeyX963: publicKey, displayName: normalizedName) + } + + public func sign(transcript: Data) throws -> Data { + lock.lock() + defer { lock.unlock() } + let privateKey = try privateKeyLocked() + let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256 + guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else { + throw DeviceIdentityError.signingUnsupported + } + + var error: Unmanaged? + guard let signature = SecKeyCreateSignature(privateKey, algorithm, transcript as CFData, &error) else { + let description = error?.takeRetainedValue().localizedDescription + ?? "Security.framework did not return an error" + throw DeviceIdentityError.signingFailed(description) + } + return signature as Data + } + + private func privateKeyLocked() throws -> SecKey { + if let cachedPrivateKey { + return cachedPrivateKey + } + + let privateKey = try provider.loadPrivateKey() ?? provider.createPrivateKey() + _ = try publicKeyX963(privateKey: privateKey) + cachedPrivateKey = privateKey + return privateKey + } + + private func publicKeyX963(privateKey: SecKey) throws -> Data { + guard let attributes = SecKeyCopyAttributes(privateKey) as? [CFString: Any], + attributes[kSecAttrKeyType] as? String == kSecAttrKeyTypeECSECPrimeRandom as String, + attributes[kSecAttrKeyClass] as? String == kSecAttrKeyClassPrivate as String, + attributes[kSecAttrKeySizeInBits] as? Int == 256, + let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw DeviceIdentityError.invalidStoredKey + } + + var error: Unmanaged? + guard let representation = SecKeyCopyExternalRepresentation(publicKey, &error) else { + let description = error?.takeRetainedValue().localizedDescription + ?? "Security.framework did not return an error" + throw DeviceIdentityError.publicKeyExtraction(description) + } + let data = representation as Data + guard data.count == 65, data.first == 0x04 else { + throw DeviceIdentityError.invalidStoredKey + } + return data + } +} diff --git a/apps/mac-app/Tests/TouchCodeIdentityTests/DeviceIdentityStoreTests.swift b/apps/mac-app/Tests/TouchCodeIdentityTests/DeviceIdentityStoreTests.swift new file mode 100644 index 0000000..d6d42e3 --- /dev/null +++ b/apps/mac-app/Tests/TouchCodeIdentityTests/DeviceIdentityStoreTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Security +import XCTest +@testable import TouchCodeIdentity + +final class DeviceIdentityStoreTests: XCTestCase, @unchecked Sendable { + func testDeviceIDMatchesSharedProtocolGoldenVector() throws { + let publicKey = try XCTUnwrap(Data(base64URL: "BMFNoWM3YHGMzbG97Zqo6wWc-PE0O6k6P719hkzzq3uJwEUYgyR9miVjotMrt-mWy1Mpi2PJ5Icu4SmpFJ7hyvk")) + XCTAssertEqual( + try DeviceIdentityDerivation.deviceID(publicKeyX963: publicKey), + "tcid1_-s9yHQa79RESg-U5J-5mWMBi0wuM3v-kclPZExMG2v0" + ) + } + + func testLoadOrCreateIsStableAndDisplayNameIsSeparateMetadata() throws { + let provider = InMemoryP256KeyProvider() + let firstStore = DeviceIdentityStore(provider: provider) + let first = try firstStore.loadOrCreate(displayName: " TouchCode Mac ") + let renamed = try firstStore.loadOrCreate(displayName: "Studio Mac") + let relaunched = try DeviceIdentityStore(provider: provider).loadOrCreate(displayName: "Studio Mac") + + XCTAssertEqual(first.displayName, "TouchCode Mac") + XCTAssertEqual(first.deviceId, renamed.deviceId) + XCTAssertEqual(first.publicKeyX963, renamed.publicKeyX963) + XCTAssertEqual(renamed, relaunched) + XCTAssertEqual(provider.createCount, 1) + XCTAssertEqual(first.version, 1) + XCTAssertEqual(first.keyAlgorithm, "p256") + XCTAssertEqual(first.signatureAlgorithm, "ecdsa-sha256") + XCTAssertEqual(first.signatureEncoding, "asn1-der") + } + + func testConcurrentLoadsCreateOneIdentity() async throws { + let provider = InMemoryP256KeyProvider() + let store = DeviceIdentityStore(provider: provider) + + let identities = try await withThrowingTaskGroup(of: DeviceIdentity.self) { group in + for _ in 0..<20 { + group.addTask { + try store.loadOrCreate(displayName: "TouchCode Mac") + } + } + var results: [DeviceIdentity] = [] + for try await identity in group { + results.append(identity) + } + return results + } + + XCTAssertEqual(Set(identities.map(\.deviceId)).count, 1) + XCTAssertEqual(provider.createCount, 1) + } + + func testSignatureVerifiesAndTamperingFails() throws { + let provider = InMemoryP256KeyProvider() + let store = DeviceIdentityStore(provider: provider) + let identity = try store.loadOrCreate(displayName: "TouchCode Mac") + let transcript = Data("canonical transcript".utf8) + let signature = try store.sign(transcript: transcript) + let publicKeyData = try XCTUnwrap(Data(base64URL: identity.publicKeyX963)) + let publicKey = try XCTUnwrap(makePublicKey(x963: publicKeyData)) + + XCTAssertTrue(SecKeyVerifySignature( + publicKey, + .ecdsaSignatureMessageX962SHA256, + transcript as CFData, + signature as CFData, + nil + )) + XCTAssertFalse(SecKeyVerifySignature( + publicKey, + .ecdsaSignatureMessageX962SHA256, + Data("tampered transcript".utf8) as CFData, + signature as CFData, + nil + )) + } + + func testInvalidStoredKeyFailsClosedWithoutCreatingReplacement() throws { + let provider = InMemoryP256KeyProvider(storedKey: try makePrivateKey(type: kSecAttrKeyTypeRSA, size: 2048)) + let store = DeviceIdentityStore(provider: provider) + + XCTAssertThrowsError(try store.loadOrCreate(displayName: "TouchCode Mac")) { error in + XCTAssertEqual(error as? DeviceIdentityError, .invalidStoredKey) + } + XCTAssertEqual(provider.createCount, 0) + } + + func testInvalidDisplayNameDoesNotAccessKeyStorage() throws { + let provider = InMemoryP256KeyProvider() + let store = DeviceIdentityStore(provider: provider) + + XCTAssertThrowsError(try store.loadOrCreate(displayName: " ")) { error in + XCTAssertEqual(error as? DeviceIdentityError, .invalidDisplayName) + } + XCTAssertEqual(provider.loadCount, 0) + XCTAssertEqual(provider.createCount, 0) + } + + func testCreationFailureCanRetryWithoutCachingPartialIdentity() throws { + let provider = RetryableP256KeyProvider() + let store = DeviceIdentityStore(provider: provider) + + XCTAssertThrowsError(try store.loadOrCreate(displayName: "TouchCode Mac")) { error in + XCTAssertEqual(error as? DeviceIdentityError, .keychain(errSecInteractionNotAllowed)) + } + + let identity = try store.loadOrCreate(displayName: "TouchCode Mac") + XCTAssertTrue(identity.deviceId.hasPrefix("tcid1_")) + XCTAssertEqual(provider.createCount, 2) + } +} + +private final class InMemoryP256KeyProvider: P256KeyProviding, @unchecked Sendable { + private let lock = NSLock() + private var storedKey: SecKey? + private(set) var loadCount = 0 + private(set) var createCount = 0 + + init(storedKey: SecKey? = nil) { + self.storedKey = storedKey + } + + func loadPrivateKey() throws -> SecKey? { + lock.lock() + defer { lock.unlock() } + loadCount += 1 + return storedKey + } + + func createPrivateKey() throws -> SecKey { + lock.lock() + defer { lock.unlock() } + createCount += 1 + let key = try makePrivateKey(type: kSecAttrKeyTypeECSECPrimeRandom, size: 256) + storedKey = key + return key + } +} + +private final class RetryableP256KeyProvider: P256KeyProviding, @unchecked Sendable { + private let lock = NSLock() + private var storedKey: SecKey? + private(set) var createCount = 0 + + func loadPrivateKey() throws -> SecKey? { + lock.lock() + defer { lock.unlock() } + return storedKey + } + + func createPrivateKey() throws -> SecKey { + lock.lock() + defer { lock.unlock() } + createCount += 1 + if createCount == 1 { + throw DeviceIdentityError.keychain(errSecInteractionNotAllowed) + } + let key = try makePrivateKey(type: kSecAttrKeyTypeECSECPrimeRandom, size: 256) + storedKey = key + return key + } +} + +private func makePrivateKey(type: CFString, size: Int) throws -> SecKey { + var error: Unmanaged? + guard let key = SecKeyCreateRandomKey([ + kSecAttrKeyType as String: type, + kSecAttrKeySizeInBits as String: size, + ] as CFDictionary, &error) else { + if let error { + throw error.takeRetainedValue() + } + throw DeviceIdentityError.keyGeneration("Missing test key error") + } + return key +} + +private func makePublicKey(x963: Data) -> SecKey? { + var error: Unmanaged? + return SecKeyCreateWithData(x963 as CFData, [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: 256, + ] as CFDictionary, &error) +} + +private extension Data { + init?(base64URL: String) { + var value = base64URL.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + value.append(String(repeating: "=", count: (4 - value.count % 4) % 4)) + self.init(base64Encoded: value) + } +}