secretive/SecretKit/SecureEnclave/SecureEnclaveStore.swift

268 lines
10 KiB
Swift
Raw Normal View History

2020-02-19 04:52:00 +00:00
import Foundation
import Security
2020-03-07 23:42:40 +00:00
import CryptoTokenKit
import LocalAuthentication
2020-02-19 04:52:00 +00:00
extension SecureEnclave {
2020-03-09 03:03:40 +00:00
public class Store: SecretStoreModifiable {
2020-02-19 04:52:00 +00:00
2020-03-07 23:42:40 +00:00
public var isAvailable: Bool {
// For some reason, as of build time, CryptoKit.SecureEnclave.isAvailable always returns false
// error msg "Received error sending GET UNIQUE DEVICE command"
// Verify it with TKTokenWatcher manually.
2020-03-11 08:53:20 +00:00
TKTokenWatcher().tokenIDs.contains("com.apple.setoken")
2020-03-07 23:42:40 +00:00
}
2020-03-09 03:03:40 +00:00
public let id = UUID()
2020-03-04 07:14:38 +00:00
public let name = NSLocalizedString("Secure Enclave", comment: "Secure Enclave")
2020-05-16 06:19:00 +00:00
@Published public private(set) var secrets: [Secret] = []
2020-02-19 04:52:00 +00:00
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
2020-02-19 04:52:00 +00:00
public init() {
2020-03-04 07:14:38 +00:00
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
self.reloadSecrets(notify: false)
}
2020-02-19 04:52:00 +00:00
loadSecrets()
}
2020-03-04 07:14:38 +00:00
// MARK: Public API
public func create(name: String, requiresAuthentication: Bool) throws {
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags
if requiresAuthentication {
flags = [.privateKeyUsage, .userPresence]
} else {
flags = .privateKeyUsage
}
let access =
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError) as Any
if let error = accessError {
throw error.takeRetainedValue() as Error
}
let attributes = [
kSecAttrLabel: name,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecPrivateKeyAttrs: [
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
] as CFDictionary
var privateKey: SecKey? = nil
var publicKey: SecKey? = nil
let status = SecKeyGeneratePair(attributes, &publicKey, &privateKey)
guard privateKey != nil, let pk = publicKey else {
throw KeychainError(statusCode: status)
}
try savePublicKey(pk, name: name)
reloadSecrets()
}
public func delete(secret: Secret) throws {
let deleteAttributes = [
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
] as CFDictionary
2020-03-04 07:14:38 +00:00
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadSecrets()
2020-02-19 04:52:00 +00:00
}
public func update(secret: Secret, name: String) throws {
let updateQuery = [
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
] as CFDictionary
let updatedAttributes = [
kSecAttrLabel: name,
] as CFDictionary
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadSecrets()
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> SignedData {
let context: LAContext
if let existing = persistedAuthenticationContexts[secret], existing.valid {
context = existing.context
} else {
let newContext = LAContext()
newContext.localizedCancelTitle = "Deny"
context = newContext
}
context.localizedReason = "sign a request from \"\(provenance.origin.displayName)\" using secret \"\(secret.name)\""
2020-03-04 07:14:38 +00:00
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
2020-03-04 07:14:38 +00:00
kSecReturnRef: true
] as CFDictionary
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
let signingStartTime = Date()
2020-03-04 07:14:38 +00:00
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
let signatureDuration = Date().timeIntervalSince(signingStartTime)
// Hack to determine if the user had to authenticate to sign.
// Since there's now way to inspect SecAccessControl to determine (afaict).
let requiredAuthentication = signatureDuration > Constants.unauthenticatedThreshold
return SignedData(data: signature as Data, requiredAuthentication: requiredAuthentication)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws {
let newContext = LAContext()
newContext.localizedCancelTitle = "Deny"
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
if let durationString = formatter.string(from: duration) {
newContext.localizedReason = "unlock secret \"\(secret.name)\" for \(durationString)"
} else {
newContext.localizedReason = "unlock secret \"\(secret.name)\""
}
newContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) { [weak self] success, _ in
guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
self?.persistedAuthenticationContexts[secret] = context
}
2020-03-04 07:14:38 +00:00
}
}
}
extension SecureEnclave.Store {
2020-05-16 06:19:00 +00:00
private func reloadSecrets(notify: Bool = true) {
2020-03-04 07:14:38 +00:00
secrets.removeAll()
loadSecrets()
if notify {
DistributedNotificationCenter.default().post(name: .secretStoreUpdated, object: nil)
}
}
2020-05-16 06:19:00 +00:00
private func loadSecrets() {
2020-03-04 07:14:38 +00:00
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
] as CFDictionary
var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped: [SecureEnclave.Secret] = typed.map {
let name = $0[kSecAttrLabel] as? String ?? "Unnamed"
let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
}
2020-05-16 06:19:00 +00:00
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
2020-03-04 07:14:38 +00:00
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecValueRef: publicKey,
kSecAttrIsPermanent: true,
kSecReturnData: true,
kSecAttrLabel: name
] as CFDictionary
let status = SecItemAdd(attributes, nil)
if status != errSecSuccess {
throw SecureEnclave.KeychainError(statusCode: status)
}
}
2020-03-04 07:14:38 +00:00
}
extension SecureEnclave {
public struct KeychainError: Error {
public let statusCode: OSStatus
}
public struct SigningError: Error {
public let error: SecurityError?
}
}
extension SecureEnclave {
public typealias SecurityError = Unmanaged<CFError>
}
extension SecureEnclave {
enum Constants {
2020-05-16 06:19:00 +00:00
static let keyTag = "com.maxgoedjen.secretive.secureenclave.key".data(using: .utf8)! as CFData
static let keyType = kSecAttrKeyTypeECSECPrimeRandom
static let unauthenticatedThreshold: TimeInterval = 0.05
}
}
extension SecureEnclave {
private struct PersistentAuthenticationContext {
let secret: Secret
let context: LAContext
// Monotonic time instead of Date() to prevent people setting the clock back.
let expiration: UInt64
init(secret: Secret, context: LAContext, duration: TimeInterval) {
self.secret = secret
self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.expiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < expiration
}
2020-02-19 04:52:00 +00:00
}
}