This commit is contained in:
Max Goedjen
2026-08-15 19:11:35 +02:00
parent 51c88de7a7
commit 4f8a151745
10 changed files with 190 additions and 97 deletions
@@ -5372,6 +5372,17 @@
}
}
},
"auth_context_request_multiple" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "sign multiple requests from “%1$(appName)@” using secret “%2$(secretName)@”"
}
}
}
},
"auth_context_request_signature_description" : {
"comment" : "When the user performs a signature action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used, and where the request is coming from. The first placeholder is the name of the app requesting the operation. The second placeholder is the name of the secret.",
"extractionState" : "manual",
@@ -18843,6 +18854,9 @@
}
}
}
},
"Multiple authenticated requests are pending. You can approve them batches, or request they all proceed individually." : {
},
"no_secure_storage_description" : {
"extractionState" : "manual",
@@ -0,0 +1,184 @@
import SwiftUI
import UniformTypeIdentifiers
public struct MultilineInfoView<TitleView: View, ItemView: View>: View {
// public struct Item {
// public let text: String
// public let action: (Image, () -> Void)?
//
// public init(text: String, action: (Image, () -> Void)?) {
// self.text = text
// self.action = action
// }
//
// }
var titleView: TitleView
var items: [ItemView]
public init(titleView: () -> TitleView, items: () -> [ItemView]) {
self.titleView = titleView()
self.items = items()
}
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource, image: Image, items: [String]) where TitleView == HStack<TupleView<(Image, Text, Spacer)>> , ItemView == Text {
self.init {
HStack {
image
.renderingMode(.template)
// .imageScale(.large)
.foregroundColor(primaryTextColor)
Text(title)
.font(.headline)
.foregroundColor(primaryTextColor)
Spacer()
}
} items: {
[Text("Hello")]
}
// self.init {
// } items: {
// ForEach(items) { item in
// return HStack {
// Text(item)
// Spacer()
// // if let (image, _) = $0.1 {
// // image
// // .foregroundStyle(.secondary)
// // }
// }
// }
// }
}
@State private var interactionState: InteractionState = .normal
@State private var interactionStateIndex: Int?
public var body: some View {
VStack(alignment: .leading, spacing: 0) {
titleView
.safeAreaPadding(20)
ForEach(Array(items.enumerated()), id: \.offset) { item in
Divider()
.ignoresSafeArea()
.opacity(item.offset == 0 ? 1 : 0.75)
items.element
.safeAreaPadding(20)
.onHover { hovering in
withAnimation {
guard item.element.action != nil else { return }
interactionState = hovering ? .hovering : .normal
interactionStateIndex = item.offset
}
}
.gesture(
TapGesture()
.onEnded {
item.element.action?.1()
withAnimation {
interactionState = .normal
interactionStateIndex = nil
}
}
)
}
}
._background(interactionState: .normal)
.frame(minWidth: 150, maxWidth: .infinity)
}
var primaryTextColor: Color {
switch interactionState {
case .normal, .hovering:
return Color(.textColor)
}
}
var secondaryTextColor: Color {
switch interactionState {
case .normal, .hovering:
return Color(.secondaryLabelColor)
}
}
}
fileprivate enum InteractionState {
case normal, hovering
}
extension View {
fileprivate func _background(interactionState: InteractionState, cornerRadius: Double = 15) -> some View {
modifier(BackgroundViewModifier(interactionState: interactionState, cornerRadius: cornerRadius))
}
}
fileprivate struct BackgroundViewModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appearsActive) private var appearsActive
let interactionState: InteractionState
let cornerRadius: Double
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: cornerRadius))
.mask(RoundedRectangle(cornerRadius: cornerRadius))
.shadow(color: .black.opacity(0.1), radius: 5)
} else {
content
.background(backgroundColor(interactionState: interactionState))
.cornerRadius(10)
}
}
func backgroundColor(interactionState: InteractionState) -> Color {
guard appearsActive else { return Color.clear }
if #available(macOS 26.0, *) {
let base: Color
if #available(macOS 27.0, *) {
base = .clear
} else {
base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1)
}
switch interactionState {
case .normal:
return base
case .hovering:
return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1)
}
} else {
switch interactionState {
case .normal:
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
case .hovering:
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
}
}
}
}
//#Preview {
// MultilineInfoView(title: "Multiple", image: Image(systemName: "figure.wave"), items: [
// MultilineInfoView.Item(text: "hello", action: (Image(systemName: "chevron.forward"), {})),
// MultilineInfoView.Item(text: "World", action: (Image(systemName: "chevron.forward"), {})),
// ])
// .padding()
//}
//
//
//#Preview {
// MultilineInfoView(title: "One", image: Image(systemName: "figure.wave"), items: ["Hello world."])
// .padding()
//}
@@ -133,20 +133,6 @@ extension Agent {
}
func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data {
// let context: any AuthenticationContextProtocol
// let offerPersistence: Bool
// if let existing = await authenticationHandler.existingAuthenticationContextProtocol(for: SignatureRequest(secret: secret, provenance: provenance)) {
// context = existing
// offerPersistence = false
// logger.debug("Using existing auth context")
// } else {
// context = authenticationHandler.createAuthenticationContext(for: SignatureRequest(secret: secret, provenance: provenance))
// offerPersistence = secret.authenticationRequirement.required
// logger.debug("Creating fresh auth context")
// }
let context = try await authenticationHandler.waitForAuthentication(for: SignatureRequest(secret: secret, provenance: provenance))
let result = try await store.sign(data: data, with: secret, for: provenance, context: context.laContext)
let signedData = signatureWriter.data(secret: secret, signature: result)
@@ -57,20 +57,28 @@ public final class AuthenticationContext: AuthenticationContextProtocol {
}
public actor AuthenticationHandler {
@MainActor public protocol AuthenticationHandlerProtocol: Observable {
func setBatchAuthHandler(_ handler: @escaping () async throws -> Void)
func waitForAuthentication(for request: SignatureRequest) async throws -> any AuthenticationContextProtocol
var batchableRequests: [[SignatureRequest]] { get }
func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws
func requestAuthentication(for requests: Set<SignatureRequest>) async throws
}
@Observable @MainActor public class AuthenticationHandler: AuthenticationHandlerProtocol {
private var persistedContexts: [AnySecret: AuthenticationContext] = [:]
private var holdingRequests: Set<SignatureRequest> = []
private var activeTask: Task<Void, any Error>?
private var lastBatchAuthPresentation: Set<SignatureRequest>?
private var presentBatchAuth: (([[SignatureRequest]], @escaping @Sendable (Set<SignatureRequest>) async throws -> Void) async throws -> Void)?
private var presentBatchAuth: (() async throws -> Void)?
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
public init() {
}
public func setBatchAuthHandler(_ handler: @escaping (@Sendable ([[SignatureRequest]], @escaping @Sendable (Set<SignatureRequest>) async throws -> Void) async throws -> Void)) {
public func setBatchAuthHandler(_ handler: @escaping () async throws -> Void) {
self.presentBatchAuth = handler
}
@@ -92,7 +100,7 @@ public actor AuthenticationHandler {
activeTask?.cancel()
lastBatchAuthPresentation = holdingRequests
logger.log("Requesting batch auth presentation")
try await presentBatchAuth?(batchableRequests, persistAuthentication(for:))
try await presentBatchAuth?()
logger.log("Requested batch auth presentation")
}
if let preauthorized = existingAuthenticationContext(for: request) {
@@ -110,11 +118,10 @@ public actor AuthenticationHandler {
activeTask = Task {
logger.log("Beginning individual auth prompt")
try await Task.sleep(for: .seconds(1000))
// _ = try? await laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: laContext.localizedReason)
_ = try? await laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: laContext.localizedReason)
logger.log("Ended individual auth prompt")
}
_ = try await activeTask?.value
_ = try? await activeTask?.value
// TODO: Check something beyond cancellation? id?
// Is this okay? Do we always assume that a cancelled task will be the proceeded on?
if activeTask?.isCancelled ?? false {
@@ -131,7 +138,7 @@ public actor AuthenticationHandler {
return context
}
private var batchableRequests: [[SignatureRequest]] {
public var batchableRequests: [[SignatureRequest]] {
holdingRequests.reduce(into: [:]) { partialResult, next in
partialResult[next.batchID, default: []].append(next)
}
@@ -167,14 +174,19 @@ public actor AuthenticationHandler {
persistedContexts[AnySecret(secret)] = context
}
private func persistAuthentication(for requests: Set<SignatureRequest>) async throws {
public func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
activeTask?.cancel()
guard let first = requests.first else { return }
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
newContext.localizedReason = String("Multiple")
// newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
let appNames = Set(requests.map(\.provenance.origin.displayName)).joined(separator: ", ")
let secretNames = Set(requests.map(\.secret.name)).joined(separator: ", ")
if requests.count > 1 {
newContext.localizedReason = String(localized: .authContextRequestMultiple(appName: appNames, secretName: secretNames))
} else {
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: appNames, secretName: secretNames))
}
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return }
let context = AuthenticationContext(secret: first.secret, context: newContext, requestIDs: Set(requests.map(\.id)))
@@ -44,6 +44,13 @@ extension SigningRequestTracer {
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
// let bufferLength = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
let x = UnsafeMutablePointer<UInt64>.allocate(capacity: Int(MAXPATHLEN))
_ = unsafe proc_pidinfo(pid, PROC_PIDLISTFDS, 0, x, Int32(MAXPATHLEN))
let buffer = unsafe UnsafeBufferPointer(start: x, count: Int(MAXPATHLEN)/64)
unsafe print(buffer)
let path = unsafe String(cString: pathPointer)
var secCode: Unmanaged<SecCode>!
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
@@ -80,10 +87,6 @@ extension SigningRequestTracer {
}
// from libproc.h
@_silgen_name("proc_pidpath")
@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32
//// from SecTask.h
@_silgen_name("SecCodeCreateWithPID")
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus