From 9b1c3fdc88fecf7ff255ee1c716b41bd50c9940e Mon Sep 17 00:00:00 2001 From: Thijs Mergaert Date: Fri, 3 Oct 2025 08:22:34 -0700 Subject: [PATCH] Implement LAContext persistence for SmartCardStore --- .../PersistentAuthenticationHandler.swift | 68 +++++++++++++++++++ .../SmartCardSecretKit/SmartCardStore.swift | 22 ++++-- 2 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 Sources/Packages/Sources/SmartCardSecretKit/PersistentAuthenticationHandler.swift diff --git a/Sources/Packages/Sources/SmartCardSecretKit/PersistentAuthenticationHandler.swift b/Sources/Packages/Sources/SmartCardSecretKit/PersistentAuthenticationHandler.swift new file mode 100644 index 0000000..0b71dc3 --- /dev/null +++ b/Sources/Packages/Sources/SmartCardSecretKit/PersistentAuthenticationHandler.swift @@ -0,0 +1,68 @@ +import LocalAuthentication +import SecretKit + +extension SmartCard { + + /// A context describing a persisted authentication. + final class PersistentAuthenticationContext: PersistedAuthenticationContext { + + /// The Secret to persist authentication for. + let secret: Secret + /// The LAContext used to authorize the persistent context. + nonisolated(unsafe) let context: LAContext + /// An expiration date for the context. + /// - Note - Monotonic time instead of Date() to prevent people setting the clock back. + let monotonicExpiration: UInt64 + + /// Initializes a context. + /// - Parameters: + /// - secret: The Secret to persist authentication for. + /// - context: The LAContext used to authorize the persistent context. + /// - duration: The duration of the authorization context, in seconds. + init(secret: Secret, context: LAContext, duration: TimeInterval) { + self.secret = secret + unsafe self.context = context + let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value + self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds) + } + + /// A boolean describing whether or not the context is still valid. + var valid: Bool { + clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration + } + + var expiration: Date { + let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC) + let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value + return Date(timeIntervalSinceNow: remainingInSeconds) + } + } + + 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: .authContextRequestDenyButton) + + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .spellOut + formatter.allowedUnits = [.hour, .minute, .day] + + + let durationString = formatter.string(from: duration)! + newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString)) + 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 + } + } +} \ No newline at end of file diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index a636fde..cd2b741 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -34,6 +34,7 @@ extension SmartCard { public var secrets: [Secret] { state.secrets } + private let persistentAuthenticationHandler = PersistentAuthenticationHandler() /// Initializes a Store. public init() { @@ -58,9 +59,15 @@ extension SmartCard { 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: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) - context.localizedCancelTitle = String(localized: .authContextRequestDenyButton) + var context: LAContext + if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { + context = unsafe existing.context + } else { + let newContext = LAContext() + newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) + newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) + context = newContext + } let attributes = KeychainDictionary([ kSecClass: kSecClassKey, kSecAttrKeyClass: kSecAttrKeyClassPrivate, @@ -86,11 +93,12 @@ extension SmartCard { return signature as Data } - public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { - nil + public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { + await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) } - public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws { + public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { + try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) } /// Reloads all secrets from the store. @@ -163,7 +171,7 @@ extension SmartCard.Store { let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)! let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any] let publicKey = publicKeyAttributes[kSecValueData] as! Data - let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown) + let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .presenceRequired) let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes) guard signatureAlgorithm(for: secret) != nil else { return nil } return secret