medmap-ios/medmap/Utilities/KeyPairManager.swift
2026-08-27 23:26:14 +01:00

341 lines
12 KiB
Swift

//
// KeyPairManager.swift
// FluxRead
//
// Created by RSA on 11/03/2026.
//
import Foundation
import Security
import CryptoKit
// MARK: - KeyPairManager
/// Manages generation and secure storage of RSA / EC key pairs using the iOS Secure Enclave & Keychain.
final class KeyPairManager {
// MARK: - Configuration
enum KeyAlgorithm {
case rsa(bits: Int) // e.g. .rsa(bits: 2048)
case ecSecureEnclave // P-256 backed by Secure Enclave (recommended)
case ecSoftware // P-256 in software keychain (simulator / older devices)
}
struct KeyPairConfig {
let tag: String // Unique reverse-DNS tag e.g. "com.myapp.userkey"
let algorithm: KeyAlgorithm
let requiresBiometrics: Bool
}
// MARK: - Errors
enum KeyPairError: LocalizedError {
case generationFailed(OSStatus)
case publicKeyExportFailed(OSStatus)
case keyNotFound
case deletionFailed(OSStatus)
case unsupportedAlgorithm
case secureEnclaveUnavailable
var errorDescription: String? {
switch self {
case .generationFailed(let s): return "Key generation failed: \(s)"
case .publicKeyExportFailed(let s): return "Public key export failed: \(s)"
case .keyNotFound: return "Key not found in Keychain"
case .deletionFailed(let s): return "Key deletion failed: \(s)"
case .unsupportedAlgorithm: return "Algorithm not supported on this device"
case .secureEnclaveUnavailable: return "Secure Enclave is not available"
}
}
}
// MARK: - Public API
/// Generate a key pair and store the **private key** securely in the Keychain / Secure Enclave.
/// - Returns: The public key as a DER-encoded `Data` blob ready to be sent to a server.
@discardableResult
func generateAndStoreKeyPair(config: KeyPairConfig) throws -> Data {
// Delete any existing key with the same tag first
try? deleteKeyPair(tag: config.tag)
let privateKey: SecKey
switch config.algorithm {
case .ecSecureEnclave:
privateKey = try generateSecureEnclaveKey(config: config)
case .ecSoftware:
privateKey = try generateECKey(config: config)
case .rsa(let bits):
privateKey = try generateRSAKey(bits: bits, config: config)
}
return try exportPublicKey(from: privateKey)
}
/// Retrieve the stored **private key** from the Keychain.
func retrievePrivateKey(tag: String) throws -> SecKey {
let query = baseKeychainQuery(tag: tag)
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let key = item as! SecKey? else {
throw KeyPairError.keyNotFound
}
return key
}
/// Export the **public key** (DER-encoded) for the stored key pair.
func exportPublicKeyData(tag: String) throws -> Data {
let privateKey = try retrievePrivateKey(tag: tag)
return try exportPublicKey(from: privateKey)
}
/// Export the public key as a Base64-encoded PEM string.
func exportPublicKeyPEM(tag: String) throws -> String {
let der = try exportPublicKeyData(tag: tag)
let b64 = der.base64EncodedString(options: [.lineLength64Characters])
return "-----BEGIN PUBLIC KEY-----\n\(b64)\n-----END PUBLIC KEY-----"
}
/// Delete both keys associated with the given tag.
func deleteKeyPair(tag: String) throws {
let query = baseKeychainQuery(tag: tag)
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeyPairError.deletionFailed(status)
}
}
// MARK: - Private: Key Generation
private func generateSecureEnclaveKey(config: KeyPairConfig) throws -> SecKey {
guard SecureEnclave.isAvailable else {
throw KeyPairError.secureEnclaveUnavailable
}
var accessError: Unmanaged<CFError>?
let flags: SecAccessControlCreateFlags = config.requiresBiometrics
? [.privateKeyUsage, .biometryCurrentSet]
: [.privateKeyUsage]
guard let access = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError
) else {
throw accessError!.takeRetainedValue() as Error
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tagData(config.tag),
kSecAttrAccessControl as String: access
]
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw error!.takeRetainedValue() as Error
}
return privateKey
}
private func generateECKey(config: KeyPairConfig) throws -> SecKey {
var accessError: Unmanaged<CFError>?
let flags: SecAccessControlCreateFlags = config.requiresBiometrics
? [.biometryCurrentSet]
: []
guard let access = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError
) else {
throw accessError!.takeRetainedValue() as Error
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tagData(config.tag),
kSecAttrAccessControl as String: access
]
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw error!.takeRetainedValue() as Error
}
return privateKey
}
private func generateRSAKey(bits: Int, config: KeyPairConfig) throws -> SecKey {
var accessError: Unmanaged<CFError>?
let flags: SecAccessControlCreateFlags = config.requiresBiometrics
? [.biometryCurrentSet]
: []
guard let access = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError
) else {
throw accessError!.takeRetainedValue() as Error
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits as String: bits,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tagData(config.tag),
kSecAttrAccessControl as String: access
]
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw error!.takeRetainedValue() as Error
}
return privateKey
}
// MARK: - Private: Export
private func exportPublicKey(from privateKey: SecKey) throws -> Data {
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
throw KeyPairError.publicKeyExportFailed(errSecParam)
}
var error: Unmanaged<CFError>?
guard let data = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
throw error!.takeRetainedValue() as Error
}
return data
}
// MARK: - Private: Helpers
private func tagData(_ tag: String) -> Data {
tag.data(using: .utf8)!
}
private func baseKeychainQuery(tag: String) -> [String: Any] {
[
kSecClass as String: kSecClassKey,
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
kSecAttrApplicationTag as String: tagData(tag),
kSecReturnRef as String: true
]
}
}
// MARK: - Convenience: Sign & Verify
extension KeyPairManager {
/// Sign arbitrary data with the stored private key.
func sign(data: Data, tag: String) throws -> Data {
let privateKey = try retrievePrivateKey(tag: tag)
let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else {
throw KeyPairError.unsupportedAlgorithm
}
var error: Unmanaged<CFError>?
guard let signature = SecKeyCreateSignature(
privateKey,
algorithm,
data as CFData,
&error
) as Data? else {
throw error!.takeRetainedValue() as Error
}
return signature
}
/// Verify a signature using the public key derived from the stored private key.
func verify(data: Data, signature: Data, tag: String) throws -> Bool {
let privateKey = try retrievePrivateKey(tag: tag)
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
throw KeyPairError.keyNotFound
}
let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
var error: Unmanaged<CFError>?
let valid = SecKeyVerifySignature(
publicKey,
algorithm,
data as CFData,
signature as CFData,
&error
)
return valid
}
}
// MARK: - Demo / Usage
/// Call this from your AppDelegate, a ViewModel, or a SwiftUI .task modifier.
func runKeyPairDemo() {
let manager = KeyPairManager()
// --- 1. Secure Enclave (recommended on real devices) ---
let seConfig = KeyPairManager.KeyPairConfig(
tag: "com.myapp.userkey.se",
algorithm: .ecSecureEnclave,
requiresBiometrics: false
)
// --- 2. EC in software keychain (works on simulator too) ---
let ecConfig = KeyPairManager.KeyPairConfig(
tag: "com.myapp.userkey.ec",
algorithm: .ecSoftware,
requiresBiometrics: false
)
// --- 3. RSA 2048 ---
let rsaConfig = KeyPairManager.KeyPairConfig(
tag: "com.myapp.userkey.rsa",
algorithm: .rsa(bits: 2048),
requiresBiometrics: false
)
// Pick the appropriate config:
let config = SecureEnclave.isAvailable ? seConfig : ecConfig
do {
// Generate & store
let publicKeyData = try manager.generateAndStoreKeyPair(config: config)
print("✅ Key pair generated. Public key (\(publicKeyData.count) bytes).")
// Export PEM for upload to server
let pem = try manager.exportPublicKeyPEM(tag: config.tag)
print("📤 Public Key PEM:\n\(pem)")
// Sign something
let payload = "Hello, secure world!".data(using: .utf8)!
let signature = try manager.sign(data: payload, tag: config.tag)
print("✍️ Signature (\(signature.count) bytes): \(signature.base64EncodedString())")
// Verify
let valid = try manager.verify(data: payload, signature: signature, tag: config.tag)
print("🔍 Signature valid: \(valid)")
// Clean up (optional)
// try manager.deleteKeyPair(tag: config.tag)
} catch {
print("❌ Error: \(error.localizedDescription)")
}
}