This commit is contained in:
Max Goedjen 2024-12-26 19:28:30 -05:00
parent 2dc317d398
commit 970e407e29
No known key found for this signature in database
13 changed files with 281 additions and 257 deletions

View File

@ -2,21 +2,19 @@ import Foundation
import Combine
/// Type eraser for SecretStore.
public class AnySecretStore: SecretStore {
public class AnySecretStore: SecretStore, @unchecked Sendable {
let base: Any
private let _isAvailable: () -> Bool
private let _id: () -> UUID
private let _name: () -> String
private let _secrets: () -> [AnySecret]
private let _sign: (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _verify: (Data, Data, AnySecret) async throws -> Bool
private let _existingPersistedAuthenticationContext: (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: () async -> Void
private let _isAvailable: @Sendable () -> Bool
private let _id: @Sendable () -> UUID
private let _name: @Sendable () -> String
private let _secrets: @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: @Sendable () async -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
base = secretStore
_isAvailable = { secretStore.isAvailable }
_name = { secretStore.name }
_id = { secretStore.id }
@ -66,11 +64,11 @@ public class AnySecretStore: SecretStore {
}
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
private let _create: (String, Bool) async throws -> Void
private let _delete: (AnySecret) async throws -> Void
private let _update: (AnySecret, String) async throws -> Void
private let _create: @Sendable (String, Bool) async throws -> Void
private let _delete: @Sendable (AnySecret) async throws -> Void
private let _update: @Sendable (AnySecret, String) async throws -> Void
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try await secretStore.create(name: $0, requiresAuthentication: $1) }

View File

@ -15,14 +15,14 @@ import Observation
/// Adds a non-type-erased SecretStore to the list.
public func add<SecretStoreType: SecretStore>(store: SecretStoreType) {
addInternal(store: AnySecretStore(store))
stores.append(AnySecretStore(store))
}
/// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store)
modifiableStore = modifiable
addInternal(store: modifiable)
stores.append(modifiable)
}
/// A boolean describing whether there are any Stores available.
@ -35,14 +35,3 @@ import Observation
}
}
extension SecretStoreList {
private func addInternal(store: AnySecretStore) {
stores.append(store)
// store.objectWillChange.sink {
// self.objectWillChange.send()
// }.store(in: &cancellables)
}
}

View File

@ -1,7 +1,7 @@
import Foundation
/// Protocol describing a persisted authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
public protocol PersistedAuthenticationContext {
public protocol PersistedAuthenticationContext: Sendable {
/// Whether the context remains valid.
var valid: Bool { get }
/// The date at which the authorization expires and the context becomes invalid.

View File

@ -2,7 +2,7 @@ import Foundation
import Combine
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore: Identifiable {
public protocol SecretStore: Identifiable, Sendable {
associatedtype SecretType: Secret

View File

@ -21,16 +21,15 @@ extension SecureEnclave {
}
private let _secrets: Mutex<[Secret]> = .init([])
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
private let persistedAuthenticationContexts: Mutex<[Secret: PersistentAuthenticationContext]> = .init([:])
/// Initializes a Store.
public init() {
// FIXME: THIS
// Task {
// for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
// await reloadSecretsInternal(notifyAgent: false)
// }
// }
Task {
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false)
}
}
loadSecrets()
}
@ -106,40 +105,42 @@ extension SecureEnclave {
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
let context: LAContext
if let existing = persistedAuthenticationContexts[secret], existing.valid {
context = existing.context
} else {
let context: Mutex<LAContext>
// if let existing = persistedAuthenticationContexts.withLock({ $0 })[secret], existing.valid {
// context = existing.context
// } else {
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
context = newContext
}
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
context = .init(newContext)
// }
return try context.withLock { context in
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
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?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
@ -178,7 +179,7 @@ extension SecureEnclave {
}
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
guard let persisted = persistedAuthenticationContexts.withLock({ $0 })[secret], persisted.valid else { return nil }
return persisted
}
@ -197,9 +198,11 @@ extension SecureEnclave {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
}
newContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) { [weak self] success, _ in
guard success else { return }
guard success, let self else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
self?.persistedAuthenticationContexts[secret] = context
self.persistedAuthenticationContexts.withLock {
$0[secret] = context
}
}
}
@ -322,12 +325,12 @@ extension SecureEnclave {
extension SecureEnclave {
/// A context describing a persisted authentication.
private struct PersistentAuthenticationContext: PersistedAuthenticationContext {
private final class PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
/// The LAContext used to authorize the persistent context.
let context: LAContext
nonisolated(unsafe) let context: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let monotonicExpiration: UInt64

View File

@ -1,5 +1,6 @@
import Foundation
import Combine
import Synchronization
import Observation
import Security
import CryptoTokenKit
import LocalAuthentication
@ -8,32 +9,54 @@ import SecretKit
extension SmartCard {
/// An implementation of Store backed by a Smart Card.
public final class Store: SecretStore {
@Observable public final class Store: SecretStore {
public var isAvailable: Bool {
_isAvailable.withLock { $0 }
}
private let _isAvailable: Mutex<Bool> = .init(false)
@Published public var isAvailable: Bool = false
public let id = UUID()
public private(set) var name = String(localized: "smart_card")
@Published public private(set) var secrets: [Secret] = []
private let watcher = TKTokenWatcher()
private var tokenID: String?
public var name: String {
_name.withLock { $0 }
}
private let _name: Mutex<String> = .init(String(localized: "smart_card"))
public var secrets: [Secret] {
_secrets.withLock { $0 }
}
private let _secrets: Mutex<[Secret]> = .init([])
private let watcher: Mutex<TKTokenWatcher> = .init(TKTokenWatcher())
private let tokenID: Mutex<String?> = .init(nil)
/// Initializes a Store.
public init() {
tokenID = watcher.nonSecureEnclaveTokens.first
// FIXME: THIS
watcher.setInsertionHandler { string in
guard self.tokenID == nil else { return }
guard !string.contains("setoken") else { return }
self.tokenID = string
// DispatchQueue.main.async {
// reload()
// }
self.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
tokenID.withLock { tokenID in
watcher.withLock { watcher in
let id = watcher.nonSecureEnclaveTokens.first
watcher.setInsertionHandler { string in
// guard self.tokenID == nil else { return }
// guard !string.contains("setoken") else { return }
//
//// self.tokenID.withLock {
//// $0 = string
//// }
// // DispatchQueue.main.async {
// // reload()
// // }
// watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
}
tokenID = id
}
}
if let tokenID = tokenID {
self.isAvailable = true
self.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
// FIXME: THIS
if let tokenID = tokenID.withLock({ $0 }) {
_isAvailable.withLock {
$0 = true
}
watcher.withLock {
$0.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
}
}
loadSecrets()
}
@ -49,7 +72,7 @@ extension SmartCard {
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard let tokenID = tokenID else { fatalError() }
guard let tokenID = tokenID.withLock({ $0 }) else { fatalError() }
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
@ -119,9 +142,13 @@ extension SmartCard {
extension SmartCard.Store {
private func reloadSecretsInternal() {
self.isAvailable = self.tokenID != nil
_isAvailable.withLock {
$0 = tokenID.withLock({ $0 }) != nil
}
let before = self.secrets
self.secrets.removeAll()
self._secrets.withLock {
$0.removeAll()
}
self.loadSecrets()
if self.secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
@ -131,19 +158,23 @@ extension SmartCard.Store {
/// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was removed.
private func smartcardRemoved(for tokenID: String? = nil) {
self.tokenID = nil
self.tokenID.withLock {
$0 = nil
}
reloadSecrets()
}
/// Loads all secrets from the store.
private func loadSecrets() {
guard let tokenID = tokenID else { return }
guard let tokenID = tokenID.withLock({ $0 }) else { return }
let fallbackName = String(localized: "smart_card")
if let driverName = watcher.tokenInfo(forTokenID: tokenID)?.driverName {
name = driverName
} else {
name = fallbackName
_name.withLock {
if let driverName = watcher.withLock({ $0.tokenInfo(forTokenID: tokenID)?.driverName }) {
$0 = driverName
} else {
$0 = fallbackName
}
}
let attributes = KeychainDictionary([
@ -167,7 +198,9 @@ extension SmartCard.Store {
let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
_secrets.withLock {
$0.append(contentsOf: wrapped)
}
}
}
@ -211,7 +244,7 @@ extension SmartCard.Store {
/// - Returns: The decrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func decrypt(data: Data, with secret: SecretType) throws -> Data {
guard let tokenID = tokenID else { fatalError() }
guard let tokenID = tokenID.withLock({ $0 }) else { fatalError() }
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")

View File

@ -69,26 +69,24 @@ struct Secretive: App {
extension Secretive {
private func reinstallAgent() {
// justUpdatedChecker.check()
// FIXME: THIS
// LaunchAgentController().install {
// // Wait a second for launchd to kick in (next runloop isn't enough).
// DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// agentStatusChecker.check()
// if !agentStatusChecker.running {
// forceLaunchAgent()
// }
// }
// }
justUpdatedChecker.check()
Task {
await LaunchAgentController().install()
try? await Task.sleep(for: .seconds(1))
agentStatusChecker.check()
if !agentStatusChecker.running {
forceLaunchAgent()
}
}
}
private func forceLaunchAgent() {
// We've run setup, we didn't just update, launchd is just not doing it's thing.
// Force a launch directly.
// FIXME: THIS
// LaunchAgentController().forceLaunch { _ in
// agentStatusChecker.check()
// }
Task {
_ = await LaunchAgentController().forceLaunch()
agentStatusChecker.check()
}
}
}

View File

@ -18,69 +18,69 @@ extension Preview {
}
extension Preview {
class Store: SecretStore, ObservableObject {
let isAvailable = true
let id = UUID()
var name: String { "Preview Store" }
@Published var secrets: [Secret] = []
init(secrets: [Secret]) {
self.secrets.append(contentsOf: secrets)
}
init(numberOfRandomSecrets: Int = 5) {
let new = (0..<numberOfRandomSecrets).map { Secret(name: String(describing: $0)) }
self.secrets.append(contentsOf: new)
}
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
return data
}
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
true
}
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil
}
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
}
func reloadSecrets() {
}
}
class StoreModifiable: Store, SecretStoreModifiable {
override var name: String { "Modifiable Preview Store" }
func create(name: String, requiresAuthentication: Bool) throws {
}
func delete(secret: Preview.Secret) throws {
}
func update(secret: Preview.Secret, name: String) throws {
}
}
}
extension Preview {
static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
let list = SecretStoreList()
for store in stores {
list.add(store: store)
}
for storeModifiable in modifiableStores {
list.add(store: storeModifiable)
}
return list
}
}
//extension Preview {
//
// class Store: SecretStore, ObservableObject {
//
// let isAvailable = true
// let id = UUID()
// var name: String { "Preview Store" }
// @Published var secrets: [Secret] = []
//
// init(secrets: [Secret]) {
// self.secrets.append(contentsOf: secrets)
// }
//
// init(numberOfRandomSecrets: Int = 5) {
// let new = (0..<numberOfRandomSecrets).map { Secret(name: String(describing: $0)) }
// self.secrets.append(contentsOf: new)
// }
//
// func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
// return data
// }
//
// func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
// true
// }
//
// func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
// nil
// }
//
// func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
// }
//
// func reloadSecrets() {
// }
//
// }
//
// class StoreModifiable: Store, SecretStoreModifiable {
// override var name: String { "Modifiable Preview Store" }
//
// func create(name: String, requiresAuthentication: Bool) throws {
// }
//
// func delete(secret: Preview.Secret) throws {
// }
//
// func update(secret: Preview.Secret, name: String) throws {
// }
// }
//}
//
//extension Preview {
//
// static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
// let list = SecretStoreList()
// for store in stores {
// list.add(store: store)
// }
// for storeModifiable in modifiableStores {
// list.add(store: storeModifiable)
// }
// return list
// }
//
//}

View File

@ -193,41 +193,41 @@ extension ContentView {
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
private static let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
private static let agentStatusChecker = AgentStatusChecker()
private static let justUpdatedChecker = JustUpdatedChecker()
@State var hasRunSetup = false
@State private var showingSetup = false
@State private var showingCreation = false
static var previews: some View {
Group {
// Empty on modifiable and nonmodifiable
ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environmentObject(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
.environmentObject(PreviewUpdater())
.environmentObject(agentStatusChecker)
// 5 items on modifiable and nonmodifiable
ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environmentObject(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
.environmentObject(PreviewUpdater())
.environmentObject(agentStatusChecker)
}
.environmentObject(agentStatusChecker)
}
}
#endif
//#if DEBUG
//
//struct ContentView_Previews: PreviewProvider {
//
// private static let storeList: SecretStoreList = {
// let list = SecretStoreList()
// list.add(store: SecureEnclave.Store())
// list.add(store: SmartCard.Store())
// return list
// }()
// private static let agentStatusChecker = AgentStatusChecker()
// private static let justUpdatedChecker = JustUpdatedChecker()
//
// @State var hasRunSetup = false
// @State private var showingSetup = false
// @State private var showingCreation = false
//
// static var previews: some View {
// Group {
// // Empty on modifiable and nonmodifiable
// ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
// .environmentObject(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
// .environmentObject(PreviewUpdater())
// .environmentObject(agentStatusChecker)
//
// // 5 items on modifiable and nonmodifiable
// ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
// .environmentObject(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
// .environmentObject(PreviewUpdater())
// .environmentObject(agentStatusChecker)
// }
// .environmentObject(agentStatusChecker)
//
// }
//}
//
//#endif

View File

@ -45,9 +45,10 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
}
func save() {
// FIXME: THIS
// try! store.create(name: name, requiresAuthentication: requiresAuthentication)
showing = false
Task {
try! await store.create(name: name, requiresAuthentication: requiresAuthentication)
showing = false
}
}
}
@ -231,19 +232,19 @@ struct NotificationView: View {
}
#if DEBUG
struct CreateSecretView_Previews: PreviewProvider {
static var previews: some View {
Group {
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
AuthenticationView().environment(\.colorScheme, .dark)
AuthenticationView().environment(\.colorScheme, .light)
NotificationView().environment(\.colorScheme, .dark)
NotificationView().environment(\.colorScheme, .light)
}
}
}
#endif
//#if DEBUG
//
//struct CreateSecretView_Previews: PreviewProvider {
//
// static var previews: some View {
// Group {
// CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
// AuthenticationView().environment(\.colorScheme, .dark)
// AuthenticationView().environment(\.colorScheme, .light)
// NotificationView().environment(\.colorScheme, .dark)
// NotificationView().environment(\.colorScheme, .light)
// }
// }
//}
//
//#endif

View File

@ -49,9 +49,10 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
}
func delete() {
// FIXME: THIS
// try! store.delete(secret: secret)
dismissalBlock(true)
Task {
try! await store.delete(secret: secret)
dismissalBlock(true)
}
}
}

View File

@ -44,8 +44,9 @@ struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
}
func rename() {
// FIXME: THIS
// try? await store.update(secret: secret, name: newName)
dismissalBlock(true)
Task {
try? await store.update(secret: secret, name: newName)
dismissalBlock(true)
}
}
}

View File

@ -47,12 +47,12 @@ struct SecretDetailView<SecretType: Secret>: View {
}
#if DEBUG
struct SecretDetailView_Previews: PreviewProvider {
static var previews: some View {
SecretDetailView(secret: Preview.Store(numberOfRandomSecrets: 1).secrets[0])
}
}
#endif
//#if DEBUG
//
//struct SecretDetailView_Previews: PreviewProvider {
// static var previews: some View {
// SecretDetailView(secret: Preview.Store(numberOfRandomSecrets: 1).secrets[0])
// }
//}
//
//#endif