mirror of
https://github.com/maxgoedjen/secretive.git
synced 2024-11-25 23:17:07 +00:00
271 lines
10 KiB
Swift
271 lines
10 KiB
Swift
import Foundation
|
|
import Security
|
|
import CryptoTokenKit
|
|
import LocalAuthentication
|
|
|
|
extension SecureEnclave {
|
|
|
|
public class Store: SecretStoreModifiable {
|
|
|
|
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.
|
|
TKTokenWatcher().tokenIDs.contains("com.apple.setoken")
|
|
}
|
|
public let id = UUID()
|
|
public let name = NSLocalizedString("Secure Enclave", comment: "Secure Enclave")
|
|
@Published public private(set) var secrets: [Secret] = []
|
|
|
|
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
|
|
|
public init() {
|
|
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
|
|
self.reloadSecrets(notify: false)
|
|
}
|
|
loadSecrets()
|
|
}
|
|
|
|
// 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 createKeyError: SecurityError?
|
|
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
|
|
if let error = createKeyError {
|
|
throw error.takeRetainedValue() as Error
|
|
}
|
|
guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else {
|
|
throw KeychainError(statusCode: nil)
|
|
}
|
|
try savePublicKey(publicKey, name: name)
|
|
reloadSecrets()
|
|
}
|
|
|
|
public func delete(secret: Secret) throws {
|
|
let deleteAttributes = [
|
|
kSecClass: kSecClassKey,
|
|
kSecAttrApplicationLabel: secret.id as CFData
|
|
] as CFDictionary
|
|
let status = SecItemDelete(deleteAttributes)
|
|
if status != errSecSuccess {
|
|
throw KeychainError(statusCode: status)
|
|
}
|
|
reloadSecrets()
|
|
}
|
|
|
|
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)\""
|
|
let attributes = [
|
|
kSecClass: kSecClassKey,
|
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
|
kSecAttrApplicationLabel: secret.id as CFData,
|
|
kSecAttrKeyType: Constants.keyType,
|
|
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
|
kSecAttrApplicationTag: Constants.keyTag,
|
|
kSecUseAuthenticationContext: context,
|
|
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()
|
|
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.touchIDAuthenticationAllowableReuseDuration = duration
|
|
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
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
extension SecureEnclave.Store {
|
|
|
|
private func reloadSecrets(notify: Bool = true) {
|
|
secrets.removeAll()
|
|
loadSecrets()
|
|
if notify {
|
|
DistributedNotificationCenter.default().post(name: .secretStoreUpdated, object: nil)
|
|
}
|
|
}
|
|
|
|
private func loadSecrets() {
|
|
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)
|
|
}
|
|
|
|
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
|
|
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)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
|
|
}
|