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