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",
@@ -1,57 +1,71 @@
import SwiftUI
import UniformTypeIdentifiers
struct MultilineInfoView: View {
public struct MultilineInfoView<TitleView: View, ItemView: View>: View {
struct Item {
let text: String
let action: (Image, () -> Void)?
// 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()
}
var title: LocalizedStringResource
var image: Image
var items: [Item]
init(title: LocalizedStringResource, image: Image, items: [Item]) {
self.title = title
self.image = image
self.items = items
}
init(title: LocalizedStringResource, image: Image, items: [String]) {
self.title = title
self.image = image
self.items = items.map({ Item(text: $0, action: nil) })
}
@State private var interactionState: InteractionState = .normal
@State private var interactionStateIndex: Int?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
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)
// .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)
HStack {
Text(item.element.text)
Spacer()
if let (image, _) = item.element.action {
image
.foregroundStyle(.secondary)
}
}
items.element
.safeAreaPadding(20)
.onHover { hovering in
withAnimation {
@@ -155,16 +169,16 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
}
#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()
}
//#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
+4 -6
View File
@@ -29,7 +29,6 @@ struct SecretAgent: App {
private let authenticationHandler = AuthenticationHandler()
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
@State var pending: ([[SignatureRequest]], (Set<SignatureRequest>) async throws -> Void)?
@Environment(\.openWindow) var openWindow
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "App")
@@ -100,8 +99,7 @@ struct SecretAgent: App {
}
}
.task {
await authenticationHandler.setBatchAuthHandler { @MainActor pending, authorize in
self.pending = (pending, authorize)
authenticationHandler.setBatchAuthHandler { @MainActor in
openWindow(id: String(describing: BatchedRequestsView.self))
}
@@ -129,10 +127,10 @@ struct SecretAgent: App {
@ViewBuilder
var pendingView: some View {
if let (requests, authorize) = pending {
BatchedRequestsView(pending: requests, review: authorize)
if !authenticationHandler.batchableRequests.isEmpty {
BatchedRequestsView(authenticationHandler: authenticationHandler)
}
}
}
+70 -10
View File
@@ -2,23 +2,21 @@ import SwiftUI
import SecretKit
import SecretAgentKit
import SmartCardSecretKit
import Common
struct BatchedRequestsView: View {
let pending: [[SignatureRequest]]
let review: (Set<SignatureRequest>) async throws -> Void
private let authenticationHandler: any AuthenticationHandlerProtocol
init(pending: [[SignatureRequest]], review: @escaping (Set<SignatureRequest>) async throws -> Void) {
self.pending = pending
self.review = review
init(authenticationHandler: some AuthenticationHandlerProtocol) {
self.authenticationHandler = authenticationHandler
}
var body: some View {
VStack(alignment: .leading) {
// .padding()
Form {
// Text("Multiple authenticated requests are pending. You can approve them batches, or request they all proceed individually.")
ForEach(Array(pending.enumerated()), id: \.offset) { group in
Text("Multiple authenticated requests are pending. You can approve them batches, or request they all proceed individually.")
ForEach(Array(authenticationHandler.batchableRequests.enumerated()), id: \.offset) { group in
Section {
ForEach(Array(group.element.enumerated()), id: \.offset) { pending in
HStack {
@@ -31,7 +29,7 @@ struct BatchedRequestsView: View {
Spacer()
Button("Review") {
Task {
try? await review([pending.element])
try? await authenticationHandler.requestAuthentication(for: [pending.element])
}
}
}
@@ -42,7 +40,7 @@ struct BatchedRequestsView: View {
Spacer()
Button("Review All") {
Task {
try? await review(Set(group.element))
try? await authenticationHandler.requestAuthentication(for: Set(group.element))
}
}
@@ -56,3 +54,65 @@ struct BatchedRequestsView: View {
}
}
private struct TestHandler: AuthenticationHandlerProtocol {
var batchableRequests: [[SignatureRequest]] = []
func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
}
func persistAuthentication<SecretType>(secret: SecretType, forDuration duration: TimeInterval) async throws where SecretType : Secret {
}
func setBatchAuthHandler(_ handler: @escaping () async throws -> Void) {
}
func waitForAuthentication(for request: SignatureRequest) async throws -> any AuthenticationContextProtocol {
fatalError()
}
}
//
//#Preview {
// ScrollView {
// MultilineInfoView(title: "GitHub", subtitle: "Ghostty", image: Image(systemName: "lock"), items: [
// "
// ])
//// Section {
//// ForEach(0..<2) { _ in
//// VStack(alignment: .leading) {
//// Text("Ghostty")
//// .font(.headline)
//// Text("zsh 􀯻 git 􀯻 zsh")
//// .font(.footnote)
//// Text("4:05 PM")
//// }
//// }
//// } header: {
//// Text("GitHub")
//// }
//// Section {
//// ForEach(0..<2) { _ in
//// VStack(alignment: .leading) {
//// Text("Ghostty")
//// .font(.headline)
//// Text("zsh 􀯻 git")
//// .font(.footnote.monospaced())
//// Text("Git Signature")
//// .font(.footnote)
//// Text("4:05 PM")
//// .font(.caption)
//// }
//// }
//// } header: {
//// Text("GitHub Signing Key")
//// }
// }
// .padding()
// .formStyle(.grouped)
// .frame(minHeight: 700)
//}
+1 -5
View File
@@ -75,7 +75,6 @@
50E0145E2EDB9CE400B121F1 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 50E0145D2EDB9CE400B121F1 /* Common */; };
50E204E92FA9D12700402380 /* CertificateDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E204E82FA9D12700402380 /* CertificateDetailView.swift */; };
50E204ED2FAA997F00402380 /* CertificateListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E204EC2FAA997F00402380 /* CertificateListItemView.swift */; };
50E204EF2FAA9C1400402380 /* MultilineInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E204EE2FAA9C1400402380 /* MultilineInfoView.swift */; };
50E2051D2FAAB81C00402380 /* SecretiveCertificateParser.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
50E205282FAAB82700402380 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E205242FAAB82700402380 /* main.swift */; };
50E2052C2FAAB85000402380 /* SecretiveCertificateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2052B2FAAB85000402380 /* SecretiveCertificateParser.swift */; };
@@ -276,7 +275,6 @@
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = "<group>"; };
50E204E82FA9D12700402380 /* CertificateDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateDetailView.swift; sourceTree = "<group>"; };
50E204EC2FAA997F00402380 /* CertificateListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateListItemView.swift; sourceTree = "<group>"; };
50E204EE2FAA9C1400402380 /* MultilineInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineInfoView.swift; sourceTree = "<group>"; };
50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = SecretiveCertificateParser.xpc; sourceTree = BUILT_PRODUCTS_DIR; };
50E205232FAAB82700402380 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50E205242FAAB82700402380 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
@@ -404,7 +402,6 @@
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */,
50617D8423FCE48E0099B055 /* ContentView.swift */,
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
50E204EE2FAA9C1400402380 /* MultilineInfoView.swift */,
50153E1F250AFCB200525160 /* UpdateView.swift */,
);
path = Views;
@@ -814,7 +811,6 @@
50E204E92FA9D12700402380 /* CertificateDetailView.swift in Sources */,
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
504789232E697DD300B4556F /* BoxBackgroundStyle.swift in Sources */,
50E204EF2FAA9C1400402380 /* MultilineInfoView.swift in Sources */,
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
@@ -868,7 +864,7 @@
buildActionMask = 2147483647;
files = (
50E205802FAB291E00402380 /* CertificateMigrator.swift in Sources */,
50020BB024064869003D4025 /* AppDelegate.swift in Sources */,
50020BB024064869003D4025 /* App.swift in Sources */,
5018F54F24064786002EB505 /* Notifier.swift in Sources */,
503647482F870B7800977A23 /* BatchedRequestsView.swift in Sources */,
501578132E6C0479004A37D0 /* XPCInputParser.swift in Sources */,
@@ -87,6 +87,9 @@
ReferencedContainer = "container:Secretive.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MetalAPIValidationSettings
isEnabled = "No">
</MetalAPIValidationSettings>
</LaunchAction>
<ProfileAction
buildConfiguration = "Debug"
@@ -28,6 +28,13 @@ struct SecretDetailView<SecretType: Secret>: View {
image: Image(systemName: "touchid"),
text: keyWriter.openSSHMD5Fingerprint(secret: secret)
)
Spacer()
.frame(height: 20)
CopyableView(
title: .secretDetailPublicKeyLabel,
image: Image(systemName: "key"),
text: keyWriter.openSSHString(secret: secret)
)
Spacer()
.frame(height: 20)
CopyableView(