117 lines
4.6 KiB
Swift
117 lines
4.6 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import LocalAuthentication
|
|
import Security
|
|
import TermindApplication
|
|
|
|
public nonisolated enum SecureEnclaveKeyError: LocalizedError {
|
|
case unavailable
|
|
case accessControl(String)
|
|
case invalidSignature
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .unavailable:
|
|
String(localized: "Secure Enclave is not available on this device.")
|
|
case .accessControl(let detail):
|
|
String(localized: "Secure Enclave access could not be configured: \(detail)")
|
|
case .invalidSignature:
|
|
String(localized: "Secure Enclave returned an invalid P-256 signature.")
|
|
}
|
|
}
|
|
}
|
|
|
|
public nonisolated enum SecureEnclaveKey {
|
|
public static var isAvailable: Bool { SecureEnclave.isAvailable }
|
|
|
|
public static func generate(comment: String, requiresUserPresence: Bool) throws
|
|
-> SecureEnclaveKeyMaterial
|
|
{
|
|
guard isAvailable else { throw SecureEnclaveKeyError.unavailable }
|
|
|
|
var flags: SecAccessControlCreateFlags = [.privateKeyUsage]
|
|
if requiresUserPresence { flags.insert(.userPresence) }
|
|
var accessError: Unmanaged<CFError>?
|
|
guard
|
|
let accessControl = SecAccessControlCreateWithFlags(
|
|
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &accessError
|
|
)
|
|
else {
|
|
let detail =
|
|
accessError?.takeRetainedValue().localizedDescription
|
|
?? String(localized: "Unknown Security framework error")
|
|
throw SecureEnclaveKeyError.accessControl(detail)
|
|
}
|
|
|
|
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(
|
|
compactRepresentable: false,
|
|
accessControl: accessControl
|
|
)
|
|
let publicBlob = sshPublicBlob(for: privateKey.publicKey.x963Representation)
|
|
let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let publicKey =
|
|
"ecdsa-sha2-nistp256 \(publicBlob.base64EncodedString())"
|
|
+ (trimmedComment.isEmpty ? "" : " \(trimmedComment)")
|
|
return SecureEnclaveKeyMaterial(
|
|
credential: SecureEnclaveKeyCredential(
|
|
dataRepresentation: privateKey.dataRepresentation
|
|
),
|
|
publicKey: publicKey,
|
|
fingerprint: fingerprint(of: publicBlob)
|
|
)
|
|
}
|
|
|
|
/// Returns the SSH ECDSA signature payload: two length-prefixed mpints (r, s).
|
|
/// libssh2 adds the outer algorithm and signature strings around this value.
|
|
public static func sign(_ data: Data, credential: SecureEnclaveKeyCredential, allowInteraction: Bool = true) throws
|
|
-> Data
|
|
{
|
|
guard isAvailable else { throw SecureEnclaveKeyError.unavailable }
|
|
let context = LAContext()
|
|
context.interactionNotAllowed = !allowInteraction
|
|
context.localizedReason = String(localized: "Use your Secure Enclave SSH key")
|
|
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(
|
|
dataRepresentation: credential.dataRepresentation,
|
|
authenticationContext: context
|
|
)
|
|
let rawSignature = try privateKey.signature(for: data).rawRepresentation
|
|
guard rawSignature.count == 64 else { throw SecureEnclaveKeyError.invalidSignature }
|
|
return sshField(mpint(rawSignature.prefix(32)))
|
|
+ sshField(mpint(rawSignature.suffix(32)))
|
|
}
|
|
|
|
public static func publicBlob(from authorizedKey: String) -> Data? {
|
|
let parts = authorizedKey.split(whereSeparator: \Character.isWhitespace)
|
|
guard parts.count >= 2, parts[0] == "ecdsa-sha2-nistp256" else { return nil }
|
|
return Data(base64Encoded: String(parts[1]))
|
|
}
|
|
|
|
private static func sshPublicBlob(for x963Representation: Data) -> Data {
|
|
sshField(Data("ecdsa-sha2-nistp256".utf8))
|
|
+ sshField(Data("nistp256".utf8))
|
|
+ sshField(x963Representation)
|
|
}
|
|
|
|
private static func fingerprint(of blob: Data) -> String {
|
|
let digest = Data(SHA256.hash(data: blob)).base64EncodedString()
|
|
.replacingOccurrences(of: "=", with: "")
|
|
return "SHA256:\(digest)"
|
|
}
|
|
|
|
private static func mpint<S: DataProtocol>(_ bytes: S) -> Data {
|
|
var value = Data(bytes).drop(while: { $0 == 0 })
|
|
if value.isEmpty { return Data() }
|
|
if value.first.map({ $0 & 0x80 != 0 }) == true {
|
|
value.insert(0, at: value.startIndex)
|
|
}
|
|
return Data(value)
|
|
}
|
|
|
|
private static func sshField(_ value: Data) -> Data {
|
|
var length = UInt32(value.count).bigEndian
|
|
var result = withUnsafeBytes(of: &length) { Data($0) }
|
|
result.append(value)
|
|
return result
|
|
}
|
|
}
|