import Foundation import Security import LocalAuthentication import CryptoKit struct Failure: Error { let code: String } // Legacy v1 Keychain service; kept only so "remove" can clean up old items. let service = "de.ch-j.servermanager.vault.biometry.v1" let wrapInfo = Data("CH-J Server Manager vault biometry v2".utf8) func fail(_ code: String) throws -> Never { throw Failure(code: code) } func laFailure(_ error: Error?) throws -> Never { let code = (error as? LAError)?.code if code == .userCancel || code == .appCancel || code == .systemCancel || code == .userFallback { try fail("BIOMETRIC_CANCELLED") } if code == .biometryLockout || code == .biometryNotAvailable { try fail("BIOMETRIC_DEVICE_UNAVAILABLE") } if code == .biometryNotEnrolled { try fail("BIOMETRIC_NO_ENROLLMENT") } try fail("BIOMETRIC_FAILED") } func authenticate(_ reason: String, _ context: LAContext = LAContext()) throws { context.localizedFallbackTitle = "" // Master-password fallback is in the application. let semaphore = DispatchSemaphore(value: 0) var accepted = false var failure: Error? context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { ok, error in accepted = ok; failure = error; semaphore.signal() } if semaphore.wait(timeout: .now() + 60) == .timedOut { context.invalidate(); try fail("BIOMETRIC_TIMEOUT") } if !accepted { try laFailure(failure) } } func keychainFailure(_ status: OSStatus) throws -> Never { if status == errSecUserCanceled { try fail("BIOMETRIC_CANCELLED") } if status == errSecItemNotFound { try fail("BIOMETRIC_ENROLLMENT_INVALIDATED") } if status == errSecNotAvailable || status == errSecInteractionNotAllowed { try fail("BIOMETRIC_DEVICE_UNAVAILABLE") } try fail("BIOMETRIC_CREDENTIAL_FAILED") } // The vault key is sealed with a key derived from ECDH against a Secure Enclave // P-256 key bound to the current biometric set. The enclave key never leaves the // chip; its opaque dataRepresentation is usable only on this Mac's Secure Enclave. // Unlike Keychain items with access control, this needs no keychain-access-groups // entitlement, so it works for ad-hoc and Developer ID signed builds alike. func symmetricKey(_ secret: SharedSecret, _ entry: String) -> SymmetricKey { secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: Data(entry.utf8), sharedInfo: wrapInfo, outputByteCount: 32) } func store(_ entry: String, _ key: Data) throws -> String { guard SecureEnclave.isAvailable else { try fail("BIOMETRIC_UNAVAILABLE") } var error: Unmanaged? guard let access = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet], &error) else { try fail("BIOMETRIC_UNAVAILABLE") } let enclaveKey: SecureEnclave.P256.KeyAgreement.PrivateKey do { enclaveKey = try SecureEnclave.P256.KeyAgreement.PrivateKey(accessControl: access) } catch { try fail("BIOMETRIC_CREDENTIAL_FAILED") } let ephemeral = P256.KeyAgreement.PrivateKey() guard let secret = try? ephemeral.sharedSecretFromKeyAgreement(with: enclaveKey.publicKey), let sealed = try? AES.GCM.seal(key, using: symmetricKey(secret, entry), authenticating: Data(entry.utf8)), let box = sealed.combined else { try fail("BIOMETRIC_CREDENTIAL_FAILED") } let payload: [String: Any] = ["v": 2, "se": enclaveKey.dataRepresentation.base64EncodedString(), "epk": ephemeral.publicKey.x963Representation.base64EncodedString(), "box": box.base64EncodedString()] return try JSONSerialization.data(withJSONObject: payload).base64EncodedString() } func retrieve(_ entry: String, _ wrapped: String, _ reason: String) throws -> Data { guard let raw = Data(base64Encoded: wrapped), let payload = try? JSONSerialization.jsonObject(with: raw) as? [String: Any], payload["v"] as? Int == 2, let se = (payload["se"] as? String).flatMap({ Data(base64Encoded: $0) }), let epk = (payload["epk"] as? String).flatMap({ Data(base64Encoded: $0) }), let box = (payload["box"] as? String).flatMap({ Data(base64Encoded: $0) }) else { try fail("BIOMETRIC_ENROLLMENT_INVALIDATED") } let context = LAContext() context.touchIDAuthenticationAllowableReuseDuration = 0 try authenticate(reason, context) // Fresh Touch ID for every unlock; the context then authorizes the enclave key. let enclaveKey: SecureEnclave.P256.KeyAgreement.PrivateKey do { enclaveKey = try SecureEnclave.P256.KeyAgreement.PrivateKey(dataRepresentation: se, authenticationContext: context) } catch { try fail("BIOMETRIC_ENROLLMENT_INVALIDATED") } // Biometric set changed or the key belongs to another Mac. let secret: SharedSecret do { secret = try enclaveKey.sharedSecretFromKeyAgreement(with: P256.KeyAgreement.PublicKey(x963Representation: epk)) } catch { if error is LAError { try laFailure(error) }; try fail("BIOMETRIC_ENROLLMENT_INVALIDATED") } guard let sealed = try? AES.GCM.SealedBox(combined: box), let key = try? AES.GCM.open(sealed, using: symmetricKey(secret, entry), authenticating: Data(entry.utf8)), key.count == 32 else { try fail("BIOMETRIC_INVALID_KEY") } return key } func run() throws -> [String: Any] { let input = FileHandle.standardInput.readDataToEndOfFile() guard input.count <= 16384, let request = try JSONSerialization.jsonObject(with: input) as? [String: Any], let op = request["op"] as? String else { try fail("BIOMETRIC_FAILED") } let reason = request["reason"] as? String ?? "Unlock CH-J Server Manager" if op == "status" { let context = LAContext(); var error: NSError? let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) && SecureEnclave.isAvailable let code = available ? "BIOMETRIC_AVAILABLE" : error?.code == LAError.biometryNotEnrolled.rawValue ? "BIOMETRIC_NO_ENROLLMENT" : error?.code == LAError.biometryLockout.rawValue ? "BIOMETRIC_DEVICE_UNAVAILABLE" : "BIOMETRIC_UNAVAILABLE" return ["ok": true, "available": available, "code": code] } if op == "authenticate" { try authenticate(reason); return ["ok": true] } guard let entry = request["entryId"] as? String, entry.count == 64, entry.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) else { try fail("BIOMETRIC_FAILED") } if op == "remove" { // v2 material lives only in the app's metadata file; delete any legacy v1 Keychain item. let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: entry] let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { try keychainFailure(status) } return ["ok": true] } if op == "store" { guard let encoded = request["key"] as? String, var key = Data(base64Encoded: encoded), key.count == 32 else { try fail("BIOMETRIC_INVALID_KEY") } defer { key.resetBytes(in: 0..