mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-09-20 03:10:57 +00:00
Actors.
This commit is contained in:
parent
c227c90fd4
commit
52e61735c9
@ -83,7 +83,6 @@ extension Updater {
|
||||
let latestVersion = SemVer(release.name)
|
||||
if latestVersion > currentVersion {
|
||||
await MainActor.run {
|
||||
print("SET \(release)")
|
||||
state.update = release
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// A protocol for retreiving the latest available version of an app.
|
||||
public protocol UpdaterProtocol: Observable, Sendable {
|
||||
|
@ -23,7 +23,7 @@ public final class Agent: Sendable {
|
||||
self.storeList = storeList
|
||||
self.witness = witness
|
||||
Task { @MainActor in
|
||||
certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ extension Agent {
|
||||
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
||||
func identities() async -> Data {
|
||||
let secrets = await storeList.allSecrets
|
||||
certificateHandler.reloadCertificates(for: secrets)
|
||||
await certificateHandler.reloadCertificates(for: secrets)
|
||||
var count = secrets.count
|
||||
var keyData = Data()
|
||||
|
||||
@ -97,7 +97,7 @@ extension Agent {
|
||||
keyData.append(writer.lengthAndData(of: keyBlob))
|
||||
keyData.append(writer.lengthAndData(of: curveData))
|
||||
|
||||
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) {
|
||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
||||
keyData.append(writer.lengthAndData(of: certificateData))
|
||||
keyData.append(writer.lengthAndData(of: name))
|
||||
count += 1
|
||||
@ -119,7 +119,7 @@ extension Agent {
|
||||
let payloadHash = reader.readNextChunk()
|
||||
let hash: Data
|
||||
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
||||
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
|
||||
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
||||
hash = certificatePublicKey
|
||||
} else {
|
||||
hash = payloadHash
|
||||
@ -192,8 +192,9 @@ extension Agent {
|
||||
/// Gives any store with no loaded secrets a chance to reload.
|
||||
func reloadSecretsIfNeccessary() async {
|
||||
for store in await storeList.stores {
|
||||
if store.secrets.isEmpty {
|
||||
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.")
|
||||
if await store.secrets.isEmpty {
|
||||
let name = await store.name
|
||||
logger.debug("Store \(name, privacy: .public) has no loaded secrets. Reloading.")
|
||||
await store.reloadSecrets()
|
||||
}
|
||||
}
|
||||
@ -203,15 +204,15 @@ extension Agent {
|
||||
/// - Parameter hash: The hash to match against.
|
||||
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
||||
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
||||
await storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in
|
||||
let allMatching = store.secrets.filter { secret in
|
||||
for store in await storeList.stores {
|
||||
let allMatching = await store.secrets.filter { secret in
|
||||
hash == writer.data(secret: secret)
|
||||
}
|
||||
if let matching = allMatching.first {
|
||||
return (store, matching)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}.first
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,11 +4,11 @@ import Combine
|
||||
/// Type eraser for SecretStore.
|
||||
public class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
|
||||
let base: Any
|
||||
private let _isAvailable: @Sendable () -> Bool
|
||||
let base: any Sendable
|
||||
private let _isAvailable: @MainActor @Sendable () -> Bool
|
||||
private let _id: @Sendable () -> UUID
|
||||
private let _name: @Sendable () -> String
|
||||
private let _secrets: @Sendable () -> [AnySecret]
|
||||
private let _name: @MainActor @Sendable () -> String
|
||||
private let _secrets: @MainActor @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?
|
||||
@ -28,7 +28,7 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
_reloadSecrets = { await secretStore.reloadSecrets() }
|
||||
}
|
||||
|
||||
public var isAvailable: Bool {
|
||||
@MainActor public var isAvailable: Bool {
|
||||
return _isAvailable()
|
||||
}
|
||||
|
||||
@ -36,11 +36,11 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
return _id()
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
@MainActor public var name: String {
|
||||
return _name()
|
||||
}
|
||||
|
||||
public var secrets: [AnySecret] {
|
||||
@MainActor public var secrets: [AnySecret] {
|
||||
return _secrets()
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,13 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import os
|
||||
|
||||
/// Manages storage and lookup for OpenSSH certificates.
|
||||
public final class OpenSSHCertificateHandler: Sendable {
|
||||
public actor OpenSSHCertificateHandler: Sendable {
|
||||
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||
private let writer = OpenSSHKeyWriter()
|
||||
private let keyBlobsAndNames: OSAllocatedUnfairLock<[AnySecret: (Data, Data)]> = .init(uncheckedState: [:])
|
||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
||||
|
||||
/// Initializes an OpenSSHCertificateHandler.
|
||||
public init() {
|
||||
@ -21,20 +20,10 @@ public final class OpenSSHCertificateHandler: Sendable {
|
||||
logger.log("No certificates, short circuiting")
|
||||
return
|
||||
}
|
||||
keyBlobsAndNames.withLock {
|
||||
$0 = secrets.reduce(into: [:]) { partialResult, next in
|
||||
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
|
||||
partialResult[next] = try? loadKeyblobAndName(for: next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
|
||||
/// - Parameter secret: The secret to check for a certificate.
|
||||
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
|
||||
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
|
||||
keyBlobsAndNames.withLock { $0[AnySecret(secret)] != nil }
|
||||
}
|
||||
|
||||
|
||||
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
|
||||
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
||||
@ -64,7 +53,7 @@ public final class OpenSSHCertificateHandler: Sendable {
|
||||
/// - Parameter secret: The secret to search for a certificate with
|
||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
||||
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
|
||||
keyBlobsAndNames.withLock { $0[AnySecret(secret)] }
|
||||
keyBlobsAndNames[AnySecret(secret)]
|
||||
}
|
||||
|
||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||
|
@ -7,13 +7,13 @@ public protocol SecretStore: Identifiable, Sendable {
|
||||
associatedtype SecretType: Secret
|
||||
|
||||
/// A boolean indicating whether or not the store is available.
|
||||
var isAvailable: Bool { get }
|
||||
@MainActor var isAvailable: Bool { get }
|
||||
/// A unique identifier for the store.
|
||||
var id: UUID { get }
|
||||
/// A user-facing name for the store.
|
||||
var name: String { get }
|
||||
@MainActor var name: String { get }
|
||||
/// The secrets the store manages.
|
||||
var secrets: [SecretType] { get }
|
||||
@MainActor var secrets: [SecretType] { get }
|
||||
|
||||
/// Signs a data payload with a specified Secret.
|
||||
/// - Parameters:
|
||||
|
@ -0,0 +1,37 @@
|
||||
import LocalAuthentication
|
||||
import SecretKit
|
||||
|
||||
extension SecureEnclave {
|
||||
|
||||
actor PersistentAuthenticationHandler: Sendable {
|
||||
|
||||
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
||||
|
||||
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
|
||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
let newContext = LAContext()
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
if let durationString = formatter.string(from: duration) {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
|
||||
} else {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
|
||||
}
|
||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||
guard success else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
persistedAuthenticationContexts[secret] = context
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -4,47 +4,28 @@ import Security
|
||||
import CryptoKit
|
||||
@preconcurrency import LocalAuthentication
|
||||
import SecretKit
|
||||
import os
|
||||
|
||||
public extension OSAllocatedUnfairLock where State: Sendable {
|
||||
|
||||
var lockedValue: State {
|
||||
get {
|
||||
withLock { $0 }
|
||||
}
|
||||
nonmutating set {
|
||||
withLock { $0 = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
extension SecureEnclave {
|
||||
|
||||
/// An implementation of Store backed by the Secure Enclave.
|
||||
@Observable public final class Store: SecretStoreModifiable {
|
||||
|
||||
@MainActor public var secrets: [Secret] = []
|
||||
public var isAvailable: Bool {
|
||||
CryptoKit.SecureEnclave.isAvailable
|
||||
}
|
||||
public let id = UUID()
|
||||
public let name = String(localized: "secure_enclave")
|
||||
public var secrets: [Secret] {
|
||||
_secrets.lockedValue
|
||||
}
|
||||
private let _secrets: OSAllocatedUnfairLock<[Secret]> = .init(uncheckedState: [])
|
||||
|
||||
private let persistedAuthenticationContexts: OSAllocatedUnfairLock<[Secret: PersistentAuthenticationContext]> = .init(uncheckedState: [:])
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
|
||||
|
||||
/// Initializes a Store.
|
||||
public init() {
|
||||
Task {
|
||||
await loadSecrets()
|
||||
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||
await reloadSecretsInternal(notifyAgent: false)
|
||||
}
|
||||
}
|
||||
loadSecrets()
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
@ -118,9 +99,9 @@ extension SecureEnclave {
|
||||
await reloadSecretsInternal()
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||
let context: LAContext
|
||||
if let existing = persistedAuthenticationContexts.lockedValue[secret], existing.valid {
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||
context = existing.context
|
||||
} else {
|
||||
let newContext = LAContext()
|
||||
@ -190,30 +171,12 @@ extension SecureEnclave {
|
||||
return verified
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
||||
guard let persisted = persistedAuthenticationContexts.lockedValue[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
let newContext = LAContext()
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
if let durationString = formatter.string(from: duration) {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
|
||||
} else {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
|
||||
}
|
||||
guard try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
self.persistedAuthenticationContexts.withLock {
|
||||
$0[secret] = context
|
||||
}
|
||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||
}
|
||||
|
||||
public func reloadSecrets() async {
|
||||
@ -228,12 +191,10 @@ extension SecureEnclave.Store {
|
||||
|
||||
/// Reloads all secrets from the store.
|
||||
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
|
||||
private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
||||
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
||||
let before = secrets
|
||||
_secrets.withLock {
|
||||
$0.removeAll()
|
||||
}
|
||||
loadSecrets()
|
||||
secrets.removeAll()
|
||||
await loadSecrets()
|
||||
if secrets != before {
|
||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||
if notifyAgent {
|
||||
@ -243,7 +204,7 @@ extension SecureEnclave.Store {
|
||||
}
|
||||
|
||||
/// Loads all secrets from the store.
|
||||
private func loadSecrets() {
|
||||
private func loadSecrets() async {
|
||||
let publicAttributes = KeychainDictionary([
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
||||
@ -294,8 +255,8 @@ extension SecureEnclave.Store {
|
||||
}
|
||||
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
|
||||
}
|
||||
_secrets.withLock {
|
||||
$0.append(contentsOf: wrapped)
|
||||
Task { @MainActor in
|
||||
secrets.append(contentsOf: wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,7 +296,7 @@ extension SecureEnclave {
|
||||
extension SecureEnclave {
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
private final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
||||
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: Secret
|
||||
|
@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Observation
|
||||
import Security
|
||||
import CryptoTokenKit
|
||||
@ -8,37 +7,39 @@ import SecretKit
|
||||
|
||||
extension SmartCard {
|
||||
|
||||
private struct State {
|
||||
@MainActor @Observable fileprivate final class State {
|
||||
var isAvailable = false
|
||||
var name = String(localized: "smart_card")
|
||||
var secrets: [Secret] = []
|
||||
let watcher = TKTokenWatcher()
|
||||
var tokenID: String? = nil
|
||||
nonisolated init() {}
|
||||
}
|
||||
|
||||
/// An implementation of Store backed by a Smart Card.
|
||||
@Observable public final class Store: SecretStore {
|
||||
|
||||
private let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init())
|
||||
private let state = State()
|
||||
public var isAvailable: Bool {
|
||||
state.withLock { $0.isAvailable }
|
||||
state.isAvailable
|
||||
}
|
||||
|
||||
public let id = UUID()
|
||||
public var name: String {
|
||||
state.withLock { $0.name }
|
||||
@MainActor public var name: String {
|
||||
state.name
|
||||
}
|
||||
public var secrets: [Secret] {
|
||||
state.withLock { $0.secrets }
|
||||
state.secrets
|
||||
}
|
||||
|
||||
/// Initializes a Store.
|
||||
public init() {
|
||||
state.withLock { state in
|
||||
Task { @MainActor in
|
||||
if let tokenID = state.tokenID {
|
||||
state.isAvailable = true
|
||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
|
||||
}
|
||||
loadSecrets()
|
||||
state.watcher.setInsertionHandler { id in
|
||||
// Setting insertion handler will cause it to be called immediately.
|
||||
// Make a thread jump so we don't hit a recursive lock attempt.
|
||||
@ -47,7 +48,6 @@ extension SmartCard {
|
||||
}
|
||||
}
|
||||
}
|
||||
loadSecrets()
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
@ -60,8 +60,8 @@ extension SmartCard {
|
||||
fatalError("Keys must be deleted on the smart card.")
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() }
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||
guard let tokenID = await state.tokenID 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")
|
||||
@ -120,7 +120,7 @@ extension SmartCard {
|
||||
}
|
||||
|
||||
/// Reloads all secrets from the store.
|
||||
public func reloadSecrets() {
|
||||
@MainActor public func reloadSecrets() {
|
||||
reloadSecretsInternal()
|
||||
}
|
||||
|
||||
@ -130,14 +130,11 @@ extension SmartCard {
|
||||
|
||||
extension SmartCard.Store {
|
||||
|
||||
private func reloadSecretsInternal() {
|
||||
let before = state.withLock {
|
||||
$0.isAvailable = $0.tokenID != nil
|
||||
let before = $0.secrets
|
||||
$0.secrets.removeAll()
|
||||
return before
|
||||
}
|
||||
self.loadSecrets()
|
||||
@MainActor private func reloadSecretsInternal() {
|
||||
let before = state.secrets
|
||||
state.isAvailable = state.tokenID != nil
|
||||
state.secrets.removeAll()
|
||||
loadSecrets()
|
||||
if self.secrets != before {
|
||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||
}
|
||||
@ -145,8 +142,7 @@ extension SmartCard.Store {
|
||||
|
||||
/// Resets the token ID and reloads secrets.
|
||||
/// - Parameter tokenID: The ID of the token that was inserted.
|
||||
private func smartcardInserted(for tokenID: String? = nil) {
|
||||
state.withLock { state in
|
||||
@MainActor private func smartcardInserted(for tokenID: String? = nil) {
|
||||
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
|
||||
guard state.tokenID == nil else { return }
|
||||
guard !string.contains("setoken") else { return }
|
||||
@ -154,28 +150,23 @@ extension SmartCard.Store {
|
||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
|
||||
state.tokenID = string
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the token ID and reloads secrets.
|
||||
/// - Parameter tokenID: The ID of the token that was removed.
|
||||
private func smartcardRemoved(for tokenID: String? = nil) {
|
||||
state.withLock {
|
||||
$0.tokenID = nil
|
||||
}
|
||||
@MainActor private func smartcardRemoved(for tokenID: String? = nil) {
|
||||
state.tokenID = nil
|
||||
reloadSecrets()
|
||||
}
|
||||
|
||||
/// Loads all secrets from the store.
|
||||
private func loadSecrets() {
|
||||
guard let tokenID = state.withLock({ $0.tokenID }) else { return }
|
||||
@MainActor private func loadSecrets() {
|
||||
guard let tokenID = state.tokenID else { return }
|
||||
|
||||
let fallbackName = String(localized: "smart_card")
|
||||
state.withLock {
|
||||
if let driverName = $0.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
||||
$0.name = driverName
|
||||
if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
||||
state.name = driverName
|
||||
} else {
|
||||
$0.name = fallbackName
|
||||
}
|
||||
state.name = fallbackName
|
||||
}
|
||||
|
||||
let attributes = KeychainDictionary([
|
||||
@ -199,9 +190,7 @@ extension SmartCard.Store {
|
||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
||||
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
|
||||
}
|
||||
state.withLock {
|
||||
$0.secrets.append(contentsOf: wrapped)
|
||||
}
|
||||
state.secrets.append(contentsOf: wrapped)
|
||||
}
|
||||
|
||||
}
|
||||
@ -244,8 +233,8 @@ extension SmartCard.Store {
|
||||
/// - secret: The secret to decrypt with.
|
||||
/// - 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 = state.withLock({ $0.tokenID }) else { fatalError() }
|
||||
public func decrypt(data: Data, with secret: SecretType) async throws -> Data {
|
||||
guard let tokenID = await state.tokenID 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")
|
||||
|
@ -59,7 +59,7 @@ import Foundation
|
||||
}
|
||||
|
||||
@Test
|
||||
func greatestSelectedIfOldPatchIsPublishedLater() async throws {
|
||||
@MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws {
|
||||
// If 2.x.x series has been published, and a patch for 1.x.x is issued
|
||||
// 2.x.x should still be selected if user can run it.
|
||||
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
|
||||
@ -77,7 +77,7 @@ import Foundation
|
||||
}
|
||||
|
||||
@Test
|
||||
func latestVersionIsRunnable() async throws {
|
||||
@MainActor func latestVersionIsRunnable() async throws {
|
||||
// If the 2.x.x series has been published but the user can't run it
|
||||
// the last version the user can run should be selected.
|
||||
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))
|
||||
|
@ -1,10 +1,8 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
import CryptoKit
|
||||
@testable import SecretKit
|
||||
@testable import SecretAgentKit
|
||||
import Common
|
||||
|
||||
@Suite struct AgentTests {
|
||||
|
||||
@ -21,7 +19,7 @@ import Common
|
||||
|
||||
@Test func identitiesList() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
|
||||
@ -31,7 +29,7 @@ import Common
|
||||
|
||||
@Test func noMatchingIdentities() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||
@ -42,7 +40,7 @@ import Common
|
||||
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||
_ = requestReader.readNextChunk()
|
||||
let dataToSign = requestReader.readNextChunk()
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
let outer = OpenSSHReader(data: stubWriter.data[5...])
|
||||
@ -64,7 +62,7 @@ import Common
|
||||
rs.append(s)
|
||||
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
|
||||
let store = list.stores.first!
|
||||
let store = await list.stores.first!
|
||||
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||
let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||
let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||
@ -80,7 +78,7 @@ import Common
|
||||
|
||||
@Test func witnessObjectionStopsRequest() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
let witness = StubWitness(speakNow: { _,_ in
|
||||
return true
|
||||
}, witness: { _, _ in })
|
||||
@ -91,44 +89,43 @@ import Common
|
||||
|
||||
@Test func witnessSignature() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
let witnessed: OSAllocatedUnfairLock<Bool> = .init(uncheckedState: false)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
nonisolated(unsafe) var witnessed = false
|
||||
let witness = StubWitness(speakNow: { _, trace in
|
||||
return false
|
||||
}, witness: { _, trace in
|
||||
witnessed.lockedValue = true
|
||||
witnessed = true
|
||||
})
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
let value = witnessed.lockedValue
|
||||
#expect(value)
|
||||
#expect(witnessed)
|
||||
}
|
||||
|
||||
@Test func requestTracing() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
let speakNowTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil)
|
||||
let witnessTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
||||
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
||||
let witness = StubWitness(speakNow: { _, trace in
|
||||
speakNowTrace.lockedValue = trace
|
||||
speakNowTrace = trace
|
||||
return false
|
||||
}, witness: { _, trace in
|
||||
witnessTrace.lockedValue = trace
|
||||
witnessTrace = trace
|
||||
})
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(witnessTrace.lockedValue == speakNowTrace.lockedValue)
|
||||
#expect(witnessTrace.lockedValue?.origin.displayName == "Finder")
|
||||
#expect(witnessTrace.lockedValue?.origin.validSignature == true)
|
||||
#expect(witnessTrace.lockedValue?.origin.parentPID == 1)
|
||||
#expect(witnessTrace == speakNowTrace)
|
||||
#expect(witnessTrace?.origin.displayName == "Finder")
|
||||
#expect(witnessTrace?.origin.validSignature == true)
|
||||
#expect(witnessTrace?.origin.parentPID == 1)
|
||||
}
|
||||
|
||||
// MARK: Exception Handling
|
||||
|
||||
@Test func signatureException() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let store = list.stores.first?.base as! Stub.Store
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let store = await list.stores.first?.base as! Stub.Store
|
||||
store.shouldThrow = true
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
@ -148,7 +145,7 @@ import Common
|
||||
|
||||
extension AgentTests {
|
||||
|
||||
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList {
|
||||
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||
let store = Stub.Store()
|
||||
store.secrets.append(contentsOf: secrets)
|
||||
let storeList = SecretStoreList()
|
||||
|
@ -4,7 +4,6 @@ import AppKit
|
||||
import SecretKit
|
||||
import SecretAgentKit
|
||||
import Brief
|
||||
import os
|
||||
|
||||
final class Notifier: Sendable {
|
||||
|
||||
@ -30,14 +29,13 @@ final class Notifier: Sendable {
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
var identifiers: [String: TimeInterval] = [:]
|
||||
for duration in rawDurations {
|
||||
let seconds = duration.converted(to: .seconds).value
|
||||
guard let string = formatter.string(from: seconds)?.capitalized else { continue }
|
||||
let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)")
|
||||
let action = UNNotificationAction(identifier: identifier, title: string, options: [])
|
||||
notificationDelegate.state.withLock { state in
|
||||
state.persistOptions[identifier] = seconds
|
||||
}
|
||||
identifiers[identifier] = seconds
|
||||
allPersistenceActions.append(action)
|
||||
}
|
||||
|
||||
@ -48,8 +46,8 @@ final class Notifier: Sendable {
|
||||
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
||||
UNUserNotificationCenter.current().delegate = notificationDelegate
|
||||
|
||||
notificationDelegate.state.withLock { state in
|
||||
state.persistAuthentication = { secret, store, duration in
|
||||
Task {
|
||||
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
|
||||
guard let duration = duration else { return }
|
||||
try? await store.persistAuthentication(secret: secret, forDuration: duration)
|
||||
}
|
||||
@ -63,10 +61,7 @@ final class Notifier: Sendable {
|
||||
}
|
||||
|
||||
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
|
||||
notificationDelegate.state.withLock { state in
|
||||
state.pendingPersistableSecrets[secret.id.description] = secret
|
||||
state.pendingPersistableStores[store.id.description] = store
|
||||
}
|
||||
await notificationDelegate.state.setPending(secret: secret, store: store)
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)")
|
||||
@ -84,11 +79,8 @@ final class Notifier: Sendable {
|
||||
try? await notificationCenter.add(request)
|
||||
}
|
||||
|
||||
func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) {
|
||||
notificationDelegate.state.withLock { [update] state in
|
||||
state.release = update
|
||||
state.ignore = ignore
|
||||
}
|
||||
func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) async {
|
||||
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
if update.critical {
|
||||
@ -101,7 +93,7 @@ final class Notifier: Sendable {
|
||||
notificationContent.body = update.body
|
||||
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
||||
notificationCenter.add(request, withCompletionHandler: nil)
|
||||
try? await notificationCenter.add(request)
|
||||
}
|
||||
|
||||
}
|
||||
@ -140,18 +132,45 @@ extension Notifier {
|
||||
|
||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
|
||||
|
||||
struct State {
|
||||
typealias PersistAuthentication = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
||||
typealias Ignore = ((Release) -> Void)
|
||||
fileprivate actor State {
|
||||
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
||||
typealias IgnoreAction = (@Sendable (Release) -> Void)
|
||||
fileprivate var release: Release?
|
||||
fileprivate var ignore: Ignore?
|
||||
fileprivate var persistAuthentication: PersistAuthentication?
|
||||
fileprivate var ignoreAction: IgnoreAction?
|
||||
fileprivate var persistAction: PersistAction?
|
||||
fileprivate var persistOptions: [String: TimeInterval] = [:]
|
||||
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
|
||||
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
|
||||
|
||||
func setPending(secret: AnySecret, store: AnySecretStore) {
|
||||
pendingPersistableSecrets[secret.id.description] = secret
|
||||
pendingPersistableStores[store.id.description] = store
|
||||
}
|
||||
|
||||
fileprivate let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init())
|
||||
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
|
||||
guard let secret = pendingPersistableSecrets[secretID],
|
||||
let store = pendingPersistableStores[storeID],
|
||||
let options = persistOptions[optionID] else {
|
||||
return nil
|
||||
}
|
||||
pendingPersistableSecrets.removeValue(forKey: secretID)
|
||||
return (secret, store, options)
|
||||
}
|
||||
|
||||
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
|
||||
self.persistOptions = options
|
||||
self.persistAction = action
|
||||
}
|
||||
|
||||
func prepareForNotification(release: Release, ignoreAction: IgnoreAction?) {
|
||||
self.release = release
|
||||
self.ignoreAction = ignoreAction
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fileprivate let state = State()
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
|
||||
|
||||
@ -161,7 +180,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
||||
let category = response.notification.request.content.categoryIdentifier
|
||||
switch category {
|
||||
case Notifier.Constants.updateCategoryIdentitifier:
|
||||
handleUpdateResponse(response: response)
|
||||
await handleUpdateResponse(response: response)
|
||||
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
|
||||
await handlePersistAuthenticationResponse(response: response)
|
||||
default:
|
||||
@ -169,37 +188,27 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateResponse(response: UNNotificationResponse) {
|
||||
func handleUpdateResponse(response: UNNotificationResponse) async {
|
||||
let id = response.actionIdentifier
|
||||
state.withLock { state in
|
||||
guard let update = state.release else { return }
|
||||
guard let update = await state.release else { return }
|
||||
switch id {
|
||||
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
|
||||
NSWorkspace.shared.open(update.html_url)
|
||||
case Notifier.Constants.ignoreActionIdentitifier:
|
||||
state.ignore?(update)
|
||||
await state.ignoreAction?(update)
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
|
||||
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String,
|
||||
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
|
||||
return
|
||||
}
|
||||
let id = response.actionIdentifier
|
||||
|
||||
let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in
|
||||
guard let secret = state.pendingPersistableSecrets[secretID],
|
||||
let store = state.pendingPersistableStores[storeID]
|
||||
else { return (nil, nil, nil, nil) }
|
||||
state.pendingPersistableSecrets[secretID] = nil
|
||||
return (secret, store, state.persistOptions[id], state.persistAuthentication)
|
||||
}
|
||||
guard let secret, let store, let persistOptions else { return }
|
||||
await callback?(secret, store, persistOptions)
|
||||
let optionID = response.actionIdentifier
|
||||
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
|
||||
await state.persistAction?(secret, store, persistOptions)
|
||||
}
|
||||
|
||||
|
||||
|
@ -2511,9 +2511,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Update: %@" : {
|
||||
|
||||
},
|
||||
"no_secure_storage_description" : {
|
||||
"localizations" : {
|
||||
|
@ -1,25 +1,21 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Observation
|
||||
import Brief
|
||||
|
||||
@Observable final class PreviewUpdater: UpdaterProtocol {
|
||||
@Observable @MainActor final class PreviewUpdater: UpdaterProtocol {
|
||||
|
||||
var update: Release? {
|
||||
_update.lockedValue
|
||||
}
|
||||
let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil)
|
||||
var update: Release? = nil
|
||||
|
||||
let testBuild = false
|
||||
|
||||
init(update: Update = .none) {
|
||||
switch update {
|
||||
case .none:
|
||||
_update.lockedValue = nil
|
||||
self.update = nil
|
||||
case .advisory:
|
||||
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
|
||||
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
|
||||
case .critical:
|
||||
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
|
||||
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,8 +91,6 @@ extension ContentView {
|
||||
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
|
||||
UpdateDetailView(update: update)
|
||||
}
|
||||
} else {
|
||||
Text("No Update: \(updater.update as Any)")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,13 +42,19 @@ struct StoreListView: View {
|
||||
if let activeSecret {
|
||||
SecretDetailView(secret: activeSecret)
|
||||
} else {
|
||||
EmptyStoreView(store: storeList.stores.first)
|
||||
EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.onAppear {
|
||||
withObservationTracking {
|
||||
_ = nextDefaultSecret
|
||||
} onChange: {
|
||||
Task { @MainActor in
|
||||
activeSecret = nextDefaultSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 100, idealWidth: 240)
|
||||
|
||||
}
|
||||
@ -57,7 +63,7 @@ struct StoreListView: View {
|
||||
extension StoreListView {
|
||||
|
||||
private var nextDefaultSecret: AnySecret? {
|
||||
return storeList.stores.compactMap(\.secrets.first).first
|
||||
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
|
||||
}
|
||||
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user