mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-09-27 18:48:00 +02:00
Pending request view (#821)
* Splitting out auth context stuff in preparation for batch * Add uncommitted authhandler rename * Split out auth/nonauth paths * Messy auth batching infra WIP * Restrict connectionAcceptedNotifications to creating filehandle * WIP * WIP * WIP * WIP * WIP * Reenable multilineinfoview * WIP * WIP * Fixing up tests * WIP * Return empty bind response on parse throw * JSON project * JSON Project * WIP * WIP * Almost done * Tests * Cleanup test calls * Fixme * Cleanup * Localized strings
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "SecretAgentKit"
|
||||
BuildableName = "SecretAgentKit"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "SecretAgentKit"
|
||||
BuildableName = "SecretAgentKit"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -4990,6 +4990,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth_context_connecting_to_unknown_host" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Connecting to unknown host"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth_context_connecting_to_username_and_host" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Connecting to %1$(username)@@%2$(host)@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth_context_persist_for_duration" : {
|
||||
"comment" : "When the user clicks the notification to leave a secret unlocked, they are shown a prompt to approve the action. This is the description, showing which secret will used. The first placeholder is the name of the secret. The second placeholder is a localized description of the time period it will remain unlocked for (eg: “five minutes”)",
|
||||
"extractionState" : "manual",
|
||||
@@ -5362,6 +5384,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",
|
||||
@@ -5920,6 +5953,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth_context_signing_for_namespace" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Signing for %1$(namespace)@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_critical_options_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -6109,6 +6153,17 @@
|
||||
},
|
||||
"Certificates" : {
|
||||
|
||||
},
|
||||
"certificates_section_title" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Certificates"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"copyable_click_to_copy_button" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -19845,6 +19900,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pending_request_description" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Multiple authenticated requests are pending. You can approve them batches, or request they all proceed individually."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pending_requests_review_batch_button" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Review as Batch"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pending_requests_review_single_button" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Review"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"persist_authentication_accept_button" : {
|
||||
"comment" : "When the user authorizes an action using a secret that requires unlock, they're shown a notification offering to leave the secret unlocked for a set period of time. This is the title for the notification.",
|
||||
"extractionState" : "manual",
|
||||
|
||||
+12
-12
@@ -1,11 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrimaryButtonModifier: ViewModifier {
|
||||
public struct PrimaryButtonModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.isEnabled) var isEnabled
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
public func body(content: Content) -> some View {
|
||||
// Tinted glass prominent is really hard to read on 26.0.
|
||||
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
|
||||
content.buttonStyle(.glassProminent)
|
||||
@@ -18,15 +18,15 @@ struct PrimaryButtonModifier: ViewModifier {
|
||||
|
||||
extension View {
|
||||
|
||||
func primaryButton() -> some View {
|
||||
public func primaryButton() -> some View {
|
||||
modifier(PrimaryButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ToolbarCircleButtonModifier: ViewModifier {
|
||||
public struct ToolbarCircleButtonModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
public func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content
|
||||
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
|
||||
@@ -40,15 +40,15 @@ struct ToolbarCircleButtonModifier: ViewModifier {
|
||||
|
||||
extension View {
|
||||
|
||||
func toolbarCircleButton() -> some View {
|
||||
public func toolbarCircleButton() -> some View {
|
||||
modifier(ToolbarCircleButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct NormalButtonModifier: ViewModifier {
|
||||
public struct NormalButtonModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
public func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content.buttonStyle(.glass)
|
||||
} else {
|
||||
@@ -60,17 +60,17 @@ struct NormalButtonModifier: ViewModifier {
|
||||
|
||||
extension View {
|
||||
|
||||
func normalButton() -> some View {
|
||||
public func normalButton() -> some View {
|
||||
modifier(NormalButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DangerButtonModifier: ViewModifier {
|
||||
public struct DangerButtonModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
public func body(content: Content) -> some View {
|
||||
// Tinted glass prominent is really hard to read on 26.0.
|
||||
if #available(macOS 26.0, *), colorScheme == .dark {
|
||||
content.buttonStyle(.glassProminent)
|
||||
@@ -87,7 +87,7 @@ struct DangerButtonModifier: ViewModifier {
|
||||
|
||||
extension View {
|
||||
|
||||
func danger() -> some View {
|
||||
public func danger() -> some View {
|
||||
modifier(DangerButtonModifier())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct MultilineInfoView<TitleView: View, ItemView: View>: View {
|
||||
|
||||
var titleView: TitleView
|
||||
var items: ItemView
|
||||
|
||||
public init(@ViewBuilder titleView: () -> TitleView, @ContentBuilder items: () -> ItemView) {
|
||||
self.titleView = titleView()
|
||||
self.items = items()
|
||||
}
|
||||
|
||||
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource? = nil, image: Image, items: [String]) where TitleView == FixedTitleView, ItemView == FixedItemsView {
|
||||
self.init {
|
||||
FixedTitleView(title: title, subtitle: subtitle, image: image)
|
||||
} items: {
|
||||
FixedItemsView(items: items)
|
||||
}
|
||||
}
|
||||
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource? = nil, image: Image, @ContentBuilder items: () -> ItemView) where TitleView == FixedTitleView {
|
||||
self.init {
|
||||
FixedTitleView(title: title, subtitle: subtitle, image: image)
|
||||
} items: {
|
||||
items()
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
titleView
|
||||
.bold()
|
||||
.safeAreaPadding(20)
|
||||
Group(subviews: items) { subviews in
|
||||
ForEach(subviews: subviews) { subview in
|
||||
Divider()
|
||||
.ignoresSafeArea()
|
||||
.opacity(subview.id == subviews.first?.id ? 1 : 0.5)
|
||||
HoveringSubview {
|
||||
subview
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
._background(interactionState: .normal)
|
||||
.frame(minWidth: 150, maxWidth: .infinity)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct HoveringSubview<Content: View>: View {
|
||||
|
||||
@State private var multilineAction: MultilineItemActionKey.Box?
|
||||
private let content: Content
|
||||
@State private var interactionState: InteractionState = .normal
|
||||
|
||||
init(@ViewBuilder _ content: () -> Content) {
|
||||
self.multilineAction = nil
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
content
|
||||
Spacer()
|
||||
if let image = multilineAction?.image {
|
||||
image
|
||||
}
|
||||
}
|
||||
.safeAreaPadding(20)
|
||||
.onHover { hovering in
|
||||
withAnimation {
|
||||
guard multilineAction != nil else { return }
|
||||
interactionState = hovering ? .hovering : .normal
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
TapGesture()
|
||||
.onEnded {
|
||||
multilineAction?.closure()
|
||||
withAnimation {
|
||||
interactionState = .normal
|
||||
}
|
||||
}
|
||||
)
|
||||
.backgroundStyle(.primary.opacity(interactionState == .hovering ? 0.3 : 0))
|
||||
.onPreferenceChange(MultilineItemActionKey.self) {
|
||||
multilineAction = $0
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public struct FixedTitleView: View {
|
||||
let title: LocalizedStringResource
|
||||
let subtitle: LocalizedStringResource?
|
||||
let image: Image
|
||||
|
||||
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource?, image: Image) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.image = image
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
image
|
||||
.renderingMode(.template)
|
||||
.imageScale(.large)
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public struct FixedItemsView: View {
|
||||
|
||||
let items: [String]
|
||||
|
||||
public var body: some View {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) {
|
||||
Text($0.element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public struct MultilineItemAction: ViewModifier {
|
||||
|
||||
let image: Image?
|
||||
let action: () -> Void
|
||||
|
||||
public init(image: Image?, action: @escaping () -> Void) {
|
||||
self.image = image
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
content
|
||||
.preference(key: MultilineItemActionKey.self, value: MultilineItemActionKey.Box(image: image, closure: action))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
public func multilineItemAction(image: Image? = nil, action: @escaping () -> Void) -> some View {
|
||||
modifier(MultilineItemAction(image: image, action: action))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public struct MultilineItemActionKey : @MainActor PreferenceKey, ~Sendable {
|
||||
|
||||
public struct Box: Equatable {
|
||||
|
||||
let id: UUID = UUID()
|
||||
let image: Image?
|
||||
let closure: () -> Void
|
||||
|
||||
public static func == (lhs: borrowing MultilineItemActionKey.Box, rhs: borrowing MultilineItemActionKey.Box) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public typealias Value = Box?
|
||||
@MainActor public static let defaultValue: Box? = nil
|
||||
|
||||
public static func reduce(value: inout Box?, nextValue: () -> Box?) {
|
||||
value = nextValue()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MultilineInfoView {
|
||||
Text("Hello")
|
||||
} items: {
|
||||
Text("World")
|
||||
.multilineItemAction(image: Image(systemName: "person.wave")) {
|
||||
print("Hello")
|
||||
}
|
||||
Text("World")
|
||||
Text("World")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
#Preview {
|
||||
MultilineInfoView(title: "One", image: Image(systemName: "figure.wave"), items: ["Hello world.", "Hello world."])
|
||||
.padding()
|
||||
}
|
||||
@@ -142,60 +142,64 @@ extension SSHAgentInputParser {
|
||||
case SSHAgent.ProtocolExtension.OpenSSHExtension.domain:
|
||||
switch name {
|
||||
case SSHAgent.ProtocolExtension.OpenSSHExtension.sessionBind(.empty).name:
|
||||
let hostkeyBlob = try reader.readNextChunkAsSubReader()
|
||||
let hostKeyType = try hostkeyBlob.readNextChunkAsString()
|
||||
let hostKeyData = try hostkeyBlob.readNextChunk()
|
||||
let sessionID = try reader.readNextChunk()
|
||||
let signatureBlob = try reader.readNextChunkAsSubReader()
|
||||
_ = try signatureBlob.readNextChunk() // key type again
|
||||
let signature = try signatureBlob.readNextChunk()
|
||||
let forwarding = try reader.readNextByteAsBool()
|
||||
switch hostKeyType {
|
||||
case "ssh-ed25519":
|
||||
let hostKey = try CryptoKit.Curve25519.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
case "ecdsa-sha2-nistp256":
|
||||
let hostKey = try CryptoKit.P256.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
case "ecdsa-sha2-nistp384":
|
||||
let hostKey = try CryptoKit.P384.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
case "ssh-mldsa-65":
|
||||
if #available(macOS 26.0, *) {
|
||||
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
||||
do {
|
||||
let hostkeyBlob = try reader.readNextChunkAsSubReader()
|
||||
let hostKeyType = try hostkeyBlob.readNextChunkAsString()
|
||||
let hostKeyData = try hostkeyBlob.readNextChunk()
|
||||
let sessionID = try reader.readNextChunk()
|
||||
let signatureBlob = try reader.readNextChunkAsSubReader()
|
||||
_ = try signatureBlob.readNextChunk() // key type again
|
||||
let signature = try signatureBlob.readNextChunk()
|
||||
let forwarding = try reader.readNextByteAsBool()
|
||||
switch hostKeyType {
|
||||
case "ssh-ed25519":
|
||||
let hostKey = try CryptoKit.Curve25519.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
} else {
|
||||
throw AgentParsingError.unhandledRequest
|
||||
}
|
||||
case "ssh-mldsa-87":
|
||||
if #available(macOS 26.0, *) {
|
||||
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||
case "ecdsa-sha2-nistp256":
|
||||
let hostKey = try CryptoKit.P256.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
} else {
|
||||
case "ecdsa-sha2-nistp384":
|
||||
let hostKey = try CryptoKit.P384.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
case "ssh-mldsa-65":
|
||||
if #available(macOS 26.0, *) {
|
||||
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
} else {
|
||||
throw AgentParsingError.unhandledRequest
|
||||
}
|
||||
case "ssh-mldsa-87":
|
||||
if #available(macOS 26.0, *) {
|
||||
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||
throw AgentParsingError.incorrectSignature
|
||||
}
|
||||
} else {
|
||||
throw AgentParsingError.unhandledRequest
|
||||
}
|
||||
case "ssh-rsa":
|
||||
throw AgentParsingError.unhandledRequest
|
||||
default:
|
||||
throw AgentParsingError.unhandledRequest
|
||||
}
|
||||
case "ssh-rsa":
|
||||
throw AgentParsingError.unhandledRequest
|
||||
default:
|
||||
throw AgentParsingError.unhandledRequest
|
||||
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
|
||||
hostKey: hostKeyData,
|
||||
sessionID: sessionID,
|
||||
signature: signature,
|
||||
forwarding: forwarding
|
||||
)
|
||||
return .openSSH(.sessionBind(context))
|
||||
} catch {
|
||||
return .openSSH(.sessionBind(.empty))
|
||||
}
|
||||
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
|
||||
hostKey: hostKeyData,
|
||||
sessionID: sessionID,
|
||||
signature: signature,
|
||||
forwarding: forwarding
|
||||
)
|
||||
return .openSSH(.sessionBind(context))
|
||||
default:
|
||||
return .openSSH(.unknown(String(name)))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import SSHProtocolKit
|
||||
public final class Agent: Sendable {
|
||||
|
||||
private let storeList: SecretStoreList
|
||||
private let authenticationHandler: AuthenticationHandler
|
||||
private let certificateStore: CertificateStore
|
||||
private let witness: SigningWitness?
|
||||
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
||||
@@ -22,10 +23,16 @@ public final class Agent: Sendable {
|
||||
/// - Parameters:
|
||||
/// - storeList: The `SecretStoreList` to make available.
|
||||
/// - witness: A witness to notify of requests.
|
||||
public init(storeList: SecretStoreList, certificateStore: CertificateStore, witness: SigningWitness? = nil) {
|
||||
public init(
|
||||
storeList: SecretStoreList,
|
||||
certificateStore: CertificateStore,
|
||||
authenticationHandler: AuthenticationHandler,
|
||||
witness: SigningWitness? = nil
|
||||
) {
|
||||
logger.debug("Agent is running")
|
||||
self.storeList = storeList
|
||||
self.certificateStore = certificateStore
|
||||
self.authenticationHandler = authenticationHandler
|
||||
self.witness = witness
|
||||
}
|
||||
|
||||
@@ -151,17 +158,22 @@ extension Agent {
|
||||
logger.debug("Agent did not have a key matching \(keyBlobHex)")
|
||||
throw NoMatchingKeyError()
|
||||
}
|
||||
logger.debug("Agent offering witness chance to object")
|
||||
do {
|
||||
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance, target: target)
|
||||
} catch {
|
||||
logger.debug("Witness objected")
|
||||
throw error
|
||||
}
|
||||
logger.debug("Witness did not object")
|
||||
|
||||
|
||||
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance, target: target)
|
||||
|
||||
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance, target: target)
|
||||
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||
|
||||
try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target)
|
||||
|
||||
let request = SignatureRequest(secret: secret, provenance: provenance, target: target)
|
||||
let newContext = AuthenticationContext(secret: secret, requests: [request])
|
||||
let context = try await authenticationHandler.authenticatedContext(for: request, context: newContext)
|
||||
let result = try await store.sign(data: data, with: secret, for: provenance, target: target, context: context?.laContext)
|
||||
let signedData = signatureWriter.data(secret: secret, signature: result)
|
||||
try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: secret.authenticationRequirement.required)
|
||||
logger.debug("Agent signed request")
|
||||
|
||||
return signedData
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
@unsafe @preconcurrency import LocalAuthentication
|
||||
import SecretKit
|
||||
import OSLog
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
public final class AuthenticationContext: AuthenticationContextProtocol {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
public let secret: AnySecret
|
||||
/// The LAContext used to authorize the persistent context.
|
||||
public let laContext: LAContext?
|
||||
|
||||
enum Validity {
|
||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||
case time(monotonicExpiration: UInt64)
|
||||
case requestIDs(Set<UUID>)
|
||||
case exclusive(UUID)
|
||||
}
|
||||
|
||||
let validity: Validity
|
||||
|
||||
/// Initializes a context.
|
||||
/// - Parameters:
|
||||
/// - secret: The Secret to persist authentication for.
|
||||
/// - duration: The duration of the authorization context, in seconds.
|
||||
init<SecretType: Secret>(secret: SecretType, duration: TimeInterval) {
|
||||
self.secret = AnySecret(secret)
|
||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||
self.validity = .time(monotonicExpiration: clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds))
|
||||
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))
|
||||
laContext = newContext
|
||||
}
|
||||
|
||||
init<SecretType: Secret>(secret: SecretType, requests: Set<SignatureRequest>) {
|
||||
self.secret = AnySecret(secret)
|
||||
if requests.count == 1 {
|
||||
self.validity = .exclusive(requests.first!.id)
|
||||
} else {
|
||||
self.validity = .requestIDs(Set(requests.map(\.id)))
|
||||
}
|
||||
if secret.authenticationRequirement.required {
|
||||
let newContext = LAContext()
|
||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
let appName = requests.first!.provenance.origin.displayName
|
||||
if requests.count > 1 {
|
||||
newContext.localizedReason = String(localized: .authContextRequestMultiple(appName: appName, secretName: secret.name))
|
||||
} else {
|
||||
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: appName, secretName: secret.name))
|
||||
}
|
||||
laContext = newContext
|
||||
} else {
|
||||
laContext = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A boolean describing whether or not the context is still valid.
|
||||
public func valid(for request: SignatureRequest) -> Bool {
|
||||
switch validity {
|
||||
case .time(let monotonicExpiration):
|
||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||
case .requestIDs(let set):
|
||||
set.contains(request.id)
|
||||
case .exclusive(let id):
|
||||
id == request.id
|
||||
}
|
||||
}
|
||||
|
||||
public func evaluate() async throws -> Bool {
|
||||
guard let laContext else { return false }
|
||||
return try await laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: laContext.localizedReason)
|
||||
}
|
||||
|
||||
public func cancel() async {
|
||||
laContext?.invalidate()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@MainActor public protocol AuthenticationHandlerProtocol: Observable {
|
||||
var batchableRequests: [[SignatureRequest]] { get }
|
||||
func setPendingRequestHandler(_ handler: @escaping () async throws -> Void)
|
||||
func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)?
|
||||
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 authenticatedContexts: [AnySecret: AuthenticationContext] = [:]
|
||||
private var waitingRequests: Set<SignatureRequest> = []
|
||||
private var activeTask: Task<Bool, any Error>?
|
||||
private var activeContext: (any AuthenticationContextProtocol)?
|
||||
|
||||
private var lastBatchAuthPresentation: Set<SignatureRequest>?
|
||||
private var presentPendingAuth: (() async throws -> Void)?
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AuthenticationHandler")
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
public func setPendingRequestHandler(_ handler: @escaping () async throws -> Void) {
|
||||
self.presentPendingAuth = handler
|
||||
}
|
||||
|
||||
public func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)? {
|
||||
if request.secret.authenticationRequirement.required {
|
||||
// Slow path, will block caller until authenticated (either directly or via a pending requests view).
|
||||
return try await waitForAuthentication(for: request, context: context)
|
||||
} else {
|
||||
// Fast path, no blocking/enqueing required
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAuthentication(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> any AuthenticationContextProtocol {
|
||||
logger.log("Entering waitForAuthentication for \(request.id)")
|
||||
if let existing = existingAuthenticationContext(for: request) {
|
||||
logger.log("Short circuiting wait, existing valid context already exists.")
|
||||
return existing
|
||||
}
|
||||
waitingRequests.insert(request)
|
||||
logger.log("Waiting for authentication for \(request.id)")
|
||||
defer {
|
||||
logger.log("Removed hold for \(request.id)")
|
||||
waitingRequests.remove(request)
|
||||
}
|
||||
|
||||
if waitingRequests.count > 1 {
|
||||
return try await waitUntilRequestActedOn(request)
|
||||
}
|
||||
|
||||
// Hold onto the task and context so we can cancel them when prsenting pending.
|
||||
activeContext = context
|
||||
let currentTask = Task<Bool, any Error> {
|
||||
logger.log("Beginning individual auth prompt")
|
||||
let result = (try? await context.evaluate()) ?? false
|
||||
logger.log("Ended individual auth prompt")
|
||||
return result
|
||||
}
|
||||
activeTask = currentTask
|
||||
let result = try? await activeTask?.value
|
||||
if result == false && activeTask?.isCancelled == false {
|
||||
waitingRequests.remove(request)
|
||||
throw CancellationError()
|
||||
} else if currentTask.isCancelled {
|
||||
return try await waitUntilRequestActedOn(request)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func waitUntilRequestActedOn(_ request: SignatureRequest) async throws -> any AuthenticationContextProtocol {
|
||||
logger.log("Auth prompt was cancelled, waiting for explicit auth")
|
||||
// At this point, we essentially just block the task until either the request has been authenticated "externally" via the pending view.
|
||||
while waitingRequests.contains(request) {
|
||||
if waitingRequests != lastBatchAuthPresentation {
|
||||
// If we're about to present a batch, we cancel the individual auth prompt, and show the batch one.
|
||||
logger.log("Multiple pending requests exist, cancelling existing auth prompt")
|
||||
activeTask?.cancel()
|
||||
lastBatchAuthPresentation = waitingRequests
|
||||
logger.log("Requesting pending requests presentation")
|
||||
try await presentPendingAuth?()
|
||||
await activeContext?.cancel()
|
||||
logger.log("Requested pending requests presentation")
|
||||
}
|
||||
if let preauthenticated = existingAuthenticationContext(for: request) {
|
||||
logger.log("Explicit auth context found")
|
||||
return preauthenticated
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
}
|
||||
throw CancellationError()
|
||||
|
||||
}
|
||||
|
||||
public var batchableRequests: [[SignatureRequest]] {
|
||||
waitingRequests.reduce(into: [:]) { partialResult, next in
|
||||
partialResult[next.batchID, default: []].append(next)
|
||||
}
|
||||
.values
|
||||
.map { $0.sorted() }
|
||||
}
|
||||
|
||||
private func existingAuthenticationContext(for request: SignatureRequest) -> (any AuthenticationContextProtocol)? {
|
||||
guard let authenticated = authenticatedContexts[request.secret], authenticated.valid(for: request) else { return nil }
|
||||
return authenticated
|
||||
}
|
||||
|
||||
public func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
||||
let context = AuthenticationContext(secret: secret, duration: duration)
|
||||
let success = try await context.evaluate()
|
||||
guard success else { return }
|
||||
authenticatedContexts[AnySecret(secret)] = context
|
||||
}
|
||||
|
||||
public func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
|
||||
activeTask?.cancel()
|
||||
guard let first = requests.first else { return }
|
||||
let context = AuthenticationContext(secret: first.secret, requests: requests)
|
||||
let success = (try? await context.evaluate()) ?? false
|
||||
guard success else {
|
||||
waitingRequests.subtract(requests)
|
||||
return
|
||||
}
|
||||
// Even single-use ones get stuffed into authenticatedContexts, so that it can unblock the response path.
|
||||
authenticatedContexts[AnySecret(first.secret)] = context
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ extension SigningRequestTracer {
|
||||
|
||||
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
||||
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
||||
|
||||
let path = unsafe String(cString: pathPointer)
|
||||
var secCode: Unmanaged<SecCode>!
|
||||
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
|
||||
@@ -80,10 +81,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
|
||||
|
||||
@@ -18,6 +18,6 @@ public protocol SigningWitness: Sendable {
|
||||
/// - secret: The `Secret` that will was used to sign the request.
|
||||
/// - store: The `Store` that signed the request..
|
||||
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
|
||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws
|
||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws
|
||||
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public struct SocketController {
|
||||
Task { @MainActor [fileHandle, sessionsContinuation, logger] in
|
||||
// Create the sequence before triggering the notification to
|
||||
// ensure it will not be missed.
|
||||
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted)
|
||||
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted, object: fileHandle)
|
||||
|
||||
fileHandle.acceptConnectionInBackgroundAndNotify()
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import LocalAuthentication
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: SecretType
|
||||
/// The LAContext used to authorize the persistent context.
|
||||
package 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: SecretType, 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.
|
||||
package var valid: Bool {
|
||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||
}
|
||||
|
||||
package 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)
|
||||
}
|
||||
}
|
||||
|
||||
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
||||
|
||||
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
|
||||
|
||||
package init() {
|
||||
}
|
||||
|
||||
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? {
|
||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
package func persistAuthentication(secret: SecretType, 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ SecretKit is a collection of protocols describing secrets and stores.
|
||||
|
||||
### Authentication Persistence
|
||||
|
||||
- ``PersistedAuthenticationContext``
|
||||
- ``AuthenticationContextProtocol``
|
||||
|
||||
### Errors
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
/// Type eraser for SecretStore.
|
||||
open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
@@ -8,9 +9,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
private let _id: @Sendable () -> UUID
|
||||
private let _name: @MainActor @Sendable () -> String
|
||||
private let _secrets: @MainActor @Sendable () -> [AnySecret]
|
||||
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, SigningRequestTarget?) async throws -> Data
|
||||
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
|
||||
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
||||
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, SigningRequestTarget?, LAContext?) async throws -> Data
|
||||
private let _reloadSecrets: @Sendable () async -> Void
|
||||
|
||||
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
|
||||
@@ -19,9 +18,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
_name = { secretStore.name }
|
||||
_id = { secretStore.id }
|
||||
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
||||
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, target: $3) }
|
||||
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
|
||||
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
||||
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, target: $3, context: $4) }
|
||||
_reloadSecrets = { await secretStore.reloadSecrets() }
|
||||
}
|
||||
|
||||
@@ -41,16 +38,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
return _secrets()
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
|
||||
try await _sign(data, secret, provenance, target)
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
|
||||
await _existingPersistedAuthenticationContext(secret)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
|
||||
try await _persistAuthentication(secret, duration)
|
||||
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||
try await _sign(data, secret, provenance, target, context)
|
||||
}
|
||||
|
||||
public func reloadSecrets() async {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
/// Protocol describing an authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
|
||||
public protocol AuthenticationContextProtocol: Sendable, Identifiable {
|
||||
var secret: AnySecret { get }
|
||||
func valid(for request: SignatureRequest) -> Bool
|
||||
var laContext: LAContext? { get }
|
||||
func evaluate() async throws -> Bool
|
||||
func cancel() async
|
||||
}
|
||||
|
||||
public struct SignatureRequest: Identifiable, Hashable, Sendable, Comparable {
|
||||
|
||||
public let id: UUID
|
||||
public let date: Date
|
||||
public let secret: AnySecret
|
||||
public let provenance: SigningRequestProvenance
|
||||
public let target: SigningRequestTarget?
|
||||
|
||||
public init(secret: AnySecret, provenance: SigningRequestProvenance, target: SigningRequestTarget?) {
|
||||
self.id = UUID()
|
||||
self.date = Date()
|
||||
self.secret = secret
|
||||
self.provenance = provenance
|
||||
self.target = target
|
||||
}
|
||||
|
||||
public var batchID: Int {
|
||||
var hasher = Hasher()
|
||||
guard let target else {
|
||||
// Requests without target are not permitted to be batched.
|
||||
id.hash(into: &hasher)
|
||||
return hasher.finalize()
|
||||
}
|
||||
provenance.batchID.hash(into: &hasher)
|
||||
target.batchID.hash(into: &hasher)
|
||||
secret.id.hash(into: &hasher)
|
||||
return hasher.finalize()
|
||||
}
|
||||
|
||||
public static func < (lhs: SignatureRequest, rhs: SignatureRequest) -> Bool {
|
||||
lhs.date < rhs.date
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol describing a persisted authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
|
||||
public protocol PersistedAuthenticationContext: Sendable {
|
||||
/// Whether the context remains valid.
|
||||
var valid: Bool { get }
|
||||
/// The date at which the authorization expires and the context becomes invalid.
|
||||
var expiration: Date { get }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
|
||||
public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||
@@ -20,20 +21,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||
/// - secret: The ``Secret`` to sign with.
|
||||
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
|
||||
/// - Returns: The signed data.
|
||||
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data
|
||||
|
||||
/// Checks to see if there is currently a valid persisted authentication for a given secret.
|
||||
/// - Parameters:
|
||||
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
|
||||
/// - Returns: A persisted authentication context, if a valid one exists.
|
||||
func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
|
||||
|
||||
/// Persists user authorization for access to a secret.
|
||||
/// - Parameters:
|
||||
/// - secret: The ``Secret`` to persist the authorization for.
|
||||
/// - duration: The duration that the authorization should persist for.
|
||||
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
|
||||
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
|
||||
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data
|
||||
|
||||
/// Requests that the store reload secrets from any backing store, if neccessary.
|
||||
func reloadSecrets() async
|
||||
|
||||
@@ -2,13 +2,23 @@ import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Describes the chain of applications that requested a signature operation.
|
||||
public struct SigningRequestProvenance: Equatable, Sendable {
|
||||
public struct SigningRequestProvenance: Hashable, Sendable {
|
||||
|
||||
/// A list of processes involved in the request.
|
||||
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
|
||||
public var chain: [Process]
|
||||
public init(root: Process) {
|
||||
|
||||
public var date: Date
|
||||
|
||||
public init(root: Process, date: Date = .now) {
|
||||
self.chain = [root]
|
||||
self.date = date
|
||||
}
|
||||
|
||||
public var batchID: Int {
|
||||
var hasher = Hasher()
|
||||
chain.map(\.path).hash(into: &hasher)
|
||||
return hasher.finalize()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,7 +40,7 @@ extension SigningRequestProvenance {
|
||||
extension SigningRequestProvenance {
|
||||
|
||||
/// Describes a process in a `SigningRequestProvenance` chain.
|
||||
public struct Process: Equatable, Sendable {
|
||||
public struct Process: Hashable, Sendable {
|
||||
|
||||
/// The pid of the process.
|
||||
public let pid: Int32
|
||||
|
||||
@@ -2,12 +2,17 @@ import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Describes the target of the signature operation.
|
||||
public enum SigningRequestTarget: Sendable {
|
||||
public enum SigningRequestTarget: Sendable, Hashable {
|
||||
|
||||
case connection(ConnectionPayload)
|
||||
case signature(SignaturePayload)
|
||||
|
||||
public struct ConnectionPayload: Sendable, Codable{
|
||||
public var batchID: Int {
|
||||
hashValue
|
||||
}
|
||||
|
||||
|
||||
public struct ConnectionPayload: Sendable, Codable, Hashable {
|
||||
|
||||
public let username: String
|
||||
public let hasSignature: Bool
|
||||
@@ -34,7 +39,7 @@ public enum SigningRequestTarget: Sendable {
|
||||
|
||||
}
|
||||
|
||||
public struct SignaturePayload: Sendable, Codable {
|
||||
public struct SignaturePayload: Sendable, Codable, Hashable {
|
||||
|
||||
public let namespace: String
|
||||
public let hashAlgorithm: String
|
||||
|
||||
@@ -17,7 +17,6 @@ extension SecureEnclave {
|
||||
}
|
||||
public let id = UUID()
|
||||
public let name = String(localized: .secureEnclave)
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
||||
|
||||
/// Initializes a Store.
|
||||
@MainActor public init() {
|
||||
@@ -37,24 +36,7 @@ extension SecureEnclave {
|
||||
|
||||
// MARK: SecretStore
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
|
||||
var context: LAContext
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||
context = unsafe existing.context
|
||||
} else {
|
||||
let newContext = LAContext()
|
||||
switch target {
|
||||
case .connection(let payload) where payload.host != nil:
|
||||
newContext.localizedReason = String(localized: .authContextRequestSignatureForConnectionDescription(appName: provenance.origin.displayName, targetName: payload.host!, secretName: secret.name))
|
||||
case .signature(let payload):
|
||||
newContext.localizedReason = String(localized: .authContextRequestSignatureForSignatureDescription(namespace: payload.namespace, appName: provenance.origin.displayName, secretName: secret.name))
|
||||
default:
|
||||
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||
}
|
||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
context = newContext
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||
let queryAttributes = KeychainDictionary([
|
||||
kSecClass: Constants.keyClass,
|
||||
kSecAttrService: Constants.keyTag,
|
||||
@@ -95,14 +77,6 @@ extension SecureEnclave {
|
||||
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||
}
|
||||
|
||||
@MainActor public func reloadSecrets() {
|
||||
let before = secrets
|
||||
secrets.removeAll()
|
||||
|
||||
@@ -34,7 +34,6 @@ extension SmartCard {
|
||||
public var secrets: [Secret] {
|
||||
state.secrets
|
||||
}
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
||||
|
||||
/// Initializes a Store.
|
||||
public init() {
|
||||
@@ -57,25 +56,19 @@ extension SmartCard {
|
||||
|
||||
// MARK: Public API
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||
guard let tokenID = await state.tokenID else { fatalError() }
|
||||
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([
|
||||
var rawAttributes: [CFString: Any] = [
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||
kSecAttrApplicationLabel: secret.id as CFData,
|
||||
kSecAttrTokenID: tokenID,
|
||||
kSecUseAuthenticationContext: context,
|
||||
kSecReturnRef: true
|
||||
])
|
||||
]
|
||||
if let context {
|
||||
rawAttributes[kSecUseAuthenticationContext] = context
|
||||
}
|
||||
let attributes = KeychainDictionary(rawAttributes)
|
||||
var untyped: CFTypeRef?
|
||||
let status = unsafe SecItemCopyMatching(attributes, &untyped)
|
||||
if status != errSecSuccess {
|
||||
@@ -93,14 +86,6 @@ extension SmartCard {
|
||||
return signature as Data
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||
}
|
||||
|
||||
/// Reloads all secrets from the store.
|
||||
@MainActor public func reloadSecrets() {
|
||||
reloadSecretsInternal()
|
||||
|
||||
@@ -11,7 +11,7 @@ import CertificateKit
|
||||
// MARK: Identity Listing
|
||||
|
||||
@Test func emptyStores() async throws {
|
||||
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||
@@ -19,7 +19,7 @@ import CertificateKit
|
||||
|
||||
@Test func identitiesList() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||
|
||||
@@ -33,7 +33,7 @@ import CertificateKit
|
||||
|
||||
@Test func noMatchingIdentities() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
|
||||
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
@@ -43,7 +43,7 @@ import CertificateKit
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
guard case SSHAgent.Request.signRequest(let context) = request else { return }
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||
let responseReader = OpenSSHReader(data: response)
|
||||
let length = try responseReader.readNextBytes(as: UInt32.self)
|
||||
@@ -78,7 +78,7 @@ import CertificateKit
|
||||
let witness = StubWitness(speakNow: { _,_ in
|
||||
return true
|
||||
}, witness: { _, _ in })
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
|
||||
let response = await agent.handle(request: .signRequest(.empty), provenance: .test, hosts: nil)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ import CertificateKit
|
||||
}, witness: { _, trace in
|
||||
witnessed = true
|
||||
})
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||
#expect(witnessed)
|
||||
@@ -107,7 +107,7 @@ import CertificateKit
|
||||
}, witness: { _, trace in
|
||||
witnessTrace = trace
|
||||
})
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||
#expect(witnessTrace == speakNowTrace)
|
||||
@@ -120,7 +120,7 @@ import CertificateKit
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let store = list.stores.first?.base as! Stub.Store
|
||||
store.shouldThrow = true
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
@@ -129,48 +129,9 @@ import CertificateKit
|
||||
// MARK: Unsupported
|
||||
|
||||
@Test func unhandledAdd() async throws {
|
||||
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||
let response = await agent.handle(request: .addIdentity, provenance: .test, hosts: nil)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SigningRequestProvenance {
|
||||
|
||||
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
|
||||
|
||||
}
|
||||
|
||||
extension AgentTests {
|
||||
|
||||
func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||
let store = Stub.Store()
|
||||
store.secrets.append(contentsOf: secrets)
|
||||
let storeList = SecretStoreList()
|
||||
storeList.add(store: store)
|
||||
return storeList
|
||||
}
|
||||
|
||||
enum Constants {
|
||||
|
||||
enum Requests {
|
||||
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
||||
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
||||
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
||||
}
|
||||
|
||||
enum Responses {
|
||||
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
||||
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
|
||||
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
||||
}
|
||||
|
||||
enum Secrets {
|
||||
static let ecdsa256Secret = Stub.Secret(keySize: 256, publicKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08=")!, privateKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy09nw780wy/TSfUmzj15iJkV234AaCLNl+H8qFL6qK8VIg==")!)
|
||||
static let ecdsa384Secret = Stub.Secret(keySize: 384, publicKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDA==")!, privateKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDHNapAOzrt9E+9QC4/KYoXS7Uw4pmdAz53uIj02tttiq3c0ZyIQ7XoscWWRqRrz8Kw==")!)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import SecretKit
|
||||
import SecretAgentKit
|
||||
import SSHProtocolKit
|
||||
|
||||
public struct TestSecret: Secret {
|
||||
|
||||
public let id = UUID()
|
||||
public let name = "Test"
|
||||
public let publicKey = Data()
|
||||
public var attributes: Attributes
|
||||
|
||||
}
|
||||
|
||||
@MainActor final class TestContext: @preconcurrency AuthenticationContextProtocol, Sendable {
|
||||
|
||||
let id = UUID()
|
||||
private var testEvaluationResult: Bool
|
||||
|
||||
private(set) var canceled: Bool
|
||||
private(set) var evaluationResult: Bool? = nil
|
||||
|
||||
let approval: ApprovalMode
|
||||
let secret: AnySecret
|
||||
let laContext: LAContext? = nil
|
||||
|
||||
enum ApprovalMode {
|
||||
case manual
|
||||
case duration(Duration)
|
||||
}
|
||||
|
||||
init(authenticationRequirement: AuthenticationRequirement, testEvaluationResult: Bool, approval: ApprovalMode = .manual) {
|
||||
self.canceled = false
|
||||
self.testEvaluationResult = testEvaluationResult
|
||||
self.secret = AnySecret(TestSecret(attributes: .init(keyType: .ecdsa256, authentication: authenticationRequirement)))
|
||||
self.approval = approval
|
||||
}
|
||||
|
||||
nonisolated func valid(for request: SecretKit.SignatureRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
func evaluate() async throws -> Bool {
|
||||
if case let .duration(duration) = approval {
|
||||
try await Task.sleep(for: duration)
|
||||
} else {
|
||||
while true {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
guard !canceled else { return false }
|
||||
}
|
||||
}
|
||||
guard !canceled else { return false }
|
||||
evaluationResult = testEvaluationResult
|
||||
return testEvaluationResult
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
assert(!canceled)
|
||||
assert(evaluationResult == nil)
|
||||
canceled = true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@MainActor @Suite struct AuthenticationHandlerTests {
|
||||
|
||||
let handler = AuthenticationHandler()
|
||||
|
||||
@Test func singleImmediatelyRequests() async throws {
|
||||
var calledPresentPending = false
|
||||
handler.setPendingRequestHandler {
|
||||
calledPresentPending = true
|
||||
}
|
||||
let context = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.zero))
|
||||
_ = try await handler.authenticatedContext(for: .init(secret: .init(context.secret), provenance: .test, target: nil), context: context)
|
||||
#expect(context.evaluationResult == true)
|
||||
#expect(!calledPresentPending)
|
||||
}
|
||||
|
||||
@Test func sequentialRequestsDoNotBlock() async throws {
|
||||
var calledPresentPending = false
|
||||
handler.setPendingRequestHandler {
|
||||
calledPresentPending = true
|
||||
}
|
||||
let context = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.milliseconds(1)))
|
||||
_ = try await handler.authenticatedContext(for: .init(secret: .init(context.secret), provenance: .test, target: nil), context: context)
|
||||
#expect(context.evaluationResult == true)
|
||||
let second = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.milliseconds(1)))
|
||||
_ = try await handler.authenticatedContext(for: .init(secret: .init(second.secret), provenance: .test, target: nil), context: second)
|
||||
#expect(context.evaluationResult == true)
|
||||
#expect(second.evaluationResult == true)
|
||||
#expect(!calledPresentPending)
|
||||
}
|
||||
|
||||
@Test func secondRetractsAndPresentsBatch() async throws {
|
||||
var calledPresentPending = false
|
||||
handler.setPendingRequestHandler {
|
||||
calledPresentPending = true
|
||||
}
|
||||
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||
let contextB = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||
Task {
|
||||
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
|
||||
}
|
||||
Task {
|
||||
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
|
||||
}
|
||||
#expect(contextA.evaluationResult == nil)
|
||||
#expect(contextB.evaluationResult == nil)
|
||||
await Task.yield()
|
||||
#expect(contextA.canceled)
|
||||
#expect(calledPresentPending)
|
||||
}
|
||||
|
||||
@Test func authRequiredDoesNotBlockNoAuthRequired() async throws {
|
||||
var calledPresentPending = false
|
||||
handler.setPendingRequestHandler {
|
||||
calledPresentPending = true
|
||||
}
|
||||
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .manual)
|
||||
let contextB = TestContext(authenticationRequirement: .notRequired, testEvaluationResult: true)
|
||||
var unauthenticatedResult = false
|
||||
Task {
|
||||
Task {
|
||||
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
|
||||
unauthenticatedResult = true
|
||||
}
|
||||
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(contextA.evaluationResult == nil)
|
||||
#expect(unauthenticatedResult)
|
||||
#expect(!contextA.canceled)
|
||||
#expect(!contextB.canceled)
|
||||
#expect(!calledPresentPending)
|
||||
}
|
||||
|
||||
@Test func batchDoesNotBlockNoAuthRequired() async throws {
|
||||
var calledPresentPending = false
|
||||
handler.setPendingRequestHandler {
|
||||
calledPresentPending = true
|
||||
}
|
||||
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||
let contextB = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||
let contextC = TestContext(authenticationRequirement: .notRequired, testEvaluationResult: true)
|
||||
Task {
|
||||
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
|
||||
}
|
||||
Task {
|
||||
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
|
||||
}
|
||||
#expect(contextA.evaluationResult == nil)
|
||||
#expect(contextB.evaluationResult == nil)
|
||||
await Task.yield()
|
||||
#expect(contextA.canceled)
|
||||
#expect(calledPresentPending)
|
||||
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextC.secret), provenance: .test, target: nil), context: contextA)
|
||||
#expect(contextA.evaluationResult == nil)
|
||||
#expect(contextB.evaluationResult == nil)
|
||||
}
|
||||
|
||||
@Test func batching() async throws {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SecretKit
|
||||
import Foundation
|
||||
|
||||
extension SigningRequestProvenance {
|
||||
|
||||
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
|
||||
|
||||
}
|
||||
|
||||
extension AgentTests {
|
||||
|
||||
func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||
let store = Stub.Store()
|
||||
store.secrets.append(contentsOf: secrets)
|
||||
let storeList = SecretStoreList()
|
||||
storeList.add(store: store)
|
||||
return storeList
|
||||
}
|
||||
|
||||
enum Constants {
|
||||
|
||||
enum Requests {
|
||||
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
||||
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
||||
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
||||
}
|
||||
|
||||
enum Responses {
|
||||
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
||||
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
|
||||
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
||||
}
|
||||
|
||||
enum Secrets {
|
||||
static let ecdsa256Secret = Stub.Secret(keySize: 256, publicKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08=")!, privateKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy09nw780wy/TSfUmzj15iJkV234AaCLNl+H8qFL6qK8VIg==")!)
|
||||
static let ecdsa384Secret = Stub.Secret(keySize: 384, publicKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDA==")!, privateKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDHNapAOzrt9E+9QC4/KYoXS7Uw4pmdAz53uIj02tttiq3c0ZyIQ7XoscWWRqRrz8Kw==")!)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import SecretKit
|
||||
import CryptoKit
|
||||
import SSHProtocolKit
|
||||
import LocalAuthentication
|
||||
|
||||
struct Stub {}
|
||||
|
||||
@@ -49,7 +50,7 @@ extension Stub {
|
||||
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws -> Data {
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) throws -> Data {
|
||||
guard !shouldThrow else {
|
||||
throw NSError(domain: "test", code: 0, userInfo: nil)
|
||||
}
|
||||
@@ -57,13 +58,6 @@ extension Stub {
|
||||
return try privateKey.signature(for: data).rawRepresentation
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
|
||||
}
|
||||
|
||||
public func reloadSecrets() {
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ extension StubWitness: SigningWitness {
|
||||
}
|
||||
}
|
||||
|
||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws {
|
||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
|
||||
witness(secret, provenance)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import Cocoa
|
||||
import OSLog
|
||||
import SecretKit
|
||||
import SecureEnclaveSecretKit
|
||||
import SmartCardSecretKit
|
||||
import SecretAgentKit
|
||||
import Brief
|
||||
import Observation
|
||||
import Common
|
||||
import SwiftUI
|
||||
import CertificateKit
|
||||
|
||||
@main
|
||||
struct SecretAgent: App {
|
||||
|
||||
@MainActor private let storeList: SecretStoreList = {
|
||||
let list = SecretStoreList()
|
||||
let cryptoKit = SecureEnclave.Store()
|
||||
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||
try? migrator.migrate(to: cryptoKit)
|
||||
list.add(store: cryptoKit)
|
||||
list.add(store: SmartCard.Store())
|
||||
return list
|
||||
}()
|
||||
@MainActor private let certificateStore: CertificateStore = CertificateStore()
|
||||
|
||||
private let updater = Updater(checkOnLaunch: true)
|
||||
private let notifier = Notifier()
|
||||
private let authenticationHandler = AuthenticationHandler()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
|
||||
|
||||
@Environment(\.openWindow) var openWindow
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "App")
|
||||
@SceneBuilder var body: some Scene {
|
||||
MenuBarExtra(isInserted: .constant(false)) {
|
||||
EmptyView()
|
||||
} label: {
|
||||
Image(systemName: "lock")
|
||||
.task {
|
||||
await notifier.registerPersistenceHandler {
|
||||
try await authenticationHandler.persistAuthentication(secret: $0, forDuration: $1)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let socketController = SocketController(path: URL.socketPath)
|
||||
let agent = Agent(
|
||||
storeList: storeList,
|
||||
certificateStore: certificateStore,
|
||||
authenticationHandler: authenticationHandler,
|
||||
witness: notifier
|
||||
)
|
||||
for await session in socketController.sessions {
|
||||
Task {
|
||||
do {
|
||||
let inputParser = try await XPCAgentInputParser()
|
||||
let hosts = try? await XPCHostsfileReader().read()
|
||||
for await message in session.messages {
|
||||
let request = try await inputParser.parse(data: message)
|
||||
let agentResponse = await agent.handle(request: request, provenance: session.provenance, hosts: hosts)
|
||||
try session.write(agentResponse)
|
||||
}
|
||||
} catch {
|
||||
try? session.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: certificateStore)
|
||||
try? certsMigrator.migrate()
|
||||
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
|
||||
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
|
||||
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
authenticationHandler.setPendingRequestHandler { @MainActor in
|
||||
openWindow(value: PendingRequestsViewIdentifier())
|
||||
}
|
||||
|
||||
}
|
||||
.task {
|
||||
notifier.prompt()
|
||||
_ = withObservationTracking {
|
||||
updater.update
|
||||
} onChange: { [updater, notifier] in
|
||||
Task {
|
||||
guard !updater.currentVersion.isTestBuild else { return }
|
||||
await notifier.notify(update: updater.update!) { release in
|
||||
await updater.ignore(release: release)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowGroup(for: PendingRequestsViewIdentifier.self) { _ in
|
||||
pendingView
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var pendingView: some View {
|
||||
if !authenticationHandler.batchableRequests.isEmpty {
|
||||
PendingRequestsView(authenticationHandler: authenticationHandler)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct PendingRequestsViewIdentifier: Codable, Hashable {}
|
||||
@@ -5,6 +5,8 @@ import SecretKit
|
||||
import SecretAgentKit
|
||||
import Brief
|
||||
|
||||
typealias PersistAction = (@Sendable (AnySecret, TimeInterval) async throws -> Void)
|
||||
|
||||
final class Notifier: Sendable {
|
||||
|
||||
private let notificationDelegate = NotificationDelegate()
|
||||
@@ -15,6 +17,12 @@ final class Notifier: Sendable {
|
||||
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
|
||||
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
|
||||
|
||||
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory])
|
||||
UNUserNotificationCenter.current().delegate = notificationDelegate
|
||||
|
||||
}
|
||||
|
||||
func registerPersistenceHandler(action: @escaping PersistAction) async {
|
||||
let rawDurations = [
|
||||
Measurement(value: 1, unit: UnitDuration.minutes),
|
||||
Measurement(value: 5, unit: UnitDuration.minutes),
|
||||
@@ -24,11 +32,9 @@ final class Notifier: Sendable {
|
||||
|
||||
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
|
||||
var allPersistenceActions = [doNotPersistAction]
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
var identifiers: [String: TimeInterval] = [:]
|
||||
for duration in rawDurations {
|
||||
let seconds = duration.converted(to: .seconds).value
|
||||
@@ -43,16 +49,11 @@ final class Notifier: Sendable {
|
||||
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
|
||||
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
|
||||
}
|
||||
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
||||
UNUserNotificationCenter.current().delegate = notificationDelegate
|
||||
|
||||
Task {
|
||||
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
|
||||
guard let duration = duration else { return }
|
||||
try? await store.persistAuthentication(secret: secret, forDuration: duration)
|
||||
}
|
||||
}
|
||||
var categories = await UNUserNotificationCenter.current().notificationCategories()
|
||||
categories.insert(persistAuthenticationCategory)
|
||||
UNUserNotificationCenter.current().setNotificationCategories(categories)
|
||||
|
||||
await notificationDelegate.state.setPersistenceState(options: identifiers, action: action)
|
||||
}
|
||||
|
||||
func prompt() {
|
||||
@@ -60,7 +61,7 @@ final class Notifier: Sendable {
|
||||
notificationCenter.requestAuthorization(options: .alert) { _, _ in }
|
||||
}
|
||||
|
||||
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) async {
|
||||
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async {
|
||||
await notificationDelegate.state.setPending(secret: secret, store: store)
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
@@ -76,7 +77,7 @@ final class Notifier: Sendable {
|
||||
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
||||
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
||||
notificationContent.interruptionLevel = .timeSensitive
|
||||
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
|
||||
if offerPersistence {
|
||||
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
||||
}
|
||||
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
||||
@@ -115,13 +116,8 @@ extension Notifier: SigningWitness {
|
||||
) async throws {
|
||||
}
|
||||
|
||||
func witness(
|
||||
accessTo secret: AnySecret,
|
||||
from store: AnySecretStore,
|
||||
by provenance: SigningRequestProvenance,
|
||||
target: SigningRequestTarget?
|
||||
) async throws {
|
||||
await notify(accessTo: secret, from: store, by: provenance, target: target)
|
||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
|
||||
await notify(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: offerPersistence)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -150,28 +146,24 @@ extension Notifier {
|
||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
|
||||
|
||||
fileprivate actor State {
|
||||
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
||||
typealias IgnoreAction = (@Sendable (Release) async -> Void)
|
||||
fileprivate var release: Release?
|
||||
fileprivate var ignoreAction: IgnoreAction?
|
||||
fileprivate var persistAction: PersistAction?
|
||||
fileprivate var persistOptions: [String: TimeInterval] = [:]
|
||||
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
|
||||
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
|
||||
|
||||
func setPending(secret: AnySecret, store: AnySecretStore) {
|
||||
pendingPersistableSecrets[secret.id.description] = secret
|
||||
pendingPersistableStores[store.id.description] = store
|
||||
}
|
||||
|
||||
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
|
||||
func retrievePending(secretID: String, optionID: String) -> (AnySecret, 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)
|
||||
return (secret, options)
|
||||
}
|
||||
|
||||
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
|
||||
@@ -219,13 +211,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
||||
}
|
||||
|
||||
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
|
||||
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String,
|
||||
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
|
||||
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String else {
|
||||
return
|
||||
}
|
||||
let optionID = response.actionIdentifier
|
||||
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
|
||||
await state.persistAction?(secret, store, persistOptions)
|
||||
guard let (secret, persistOptions) = await state.retrievePending(secretID: secretID, optionID: optionID) else { return }
|
||||
try? await state.persistAction?(secret, persistOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import SecretAgentKit
|
||||
import SmartCardSecretKit
|
||||
import Common
|
||||
|
||||
struct PendingRequestsView: View {
|
||||
|
||||
private let authenticationHandler: any AuthenticationHandlerProtocol
|
||||
@Environment(\.dismissWindow) var dismiss
|
||||
|
||||
init(authenticationHandler: some AuthenticationHandlerProtocol) {
|
||||
self.authenticationHandler = authenticationHandler
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Text(.pendingRequestDescription)
|
||||
ForEach(Array(authenticationHandler.batchableRequests.enumerated()), id: \.offset) { group in
|
||||
MultilineInfoView {
|
||||
if let first = group.element.first {
|
||||
HStack {
|
||||
HStack {
|
||||
Image(nsImage: .init(byReferencing: first.provenance.origin.iconURL!))
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 50)
|
||||
VStack(alignment: .leading) {
|
||||
Text(first.provenance.origin.displayName)
|
||||
.font(.subheadline)
|
||||
Text(first.secret.name)
|
||||
.font(.headline)
|
||||
switch first.target {
|
||||
case .connection(let payload):
|
||||
if let host = payload.host {
|
||||
Text(.authContextConnectingToUsernameAndHost(username: payload.username, host: host))
|
||||
.font(.caption2)
|
||||
} else {
|
||||
Text(.authContextConnectingToUnknownHost)
|
||||
.font(.caption2)
|
||||
}
|
||||
case .signature(let payload):
|
||||
Text(.authContextSigningForNamespace(namespace: payload.namespace))
|
||||
.font(.caption2)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack {
|
||||
Button(.pendingRequestsReviewBatchButton) {
|
||||
Task {
|
||||
try? await authenticationHandler.requestAuthentication(for: Set(group.element))
|
||||
if authenticationHandler.batchableRequests.isEmpty {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.primaryButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
} items: {
|
||||
ForEach(Array(group.element.enumerated()), id: \.offset) { pending in
|
||||
HStack {
|
||||
Text(pending.element.provenance.date.formatted())
|
||||
Spacer()
|
||||
Button(.pendingRequestsReviewSingleButton) {
|
||||
Task {
|
||||
try? await authenticationHandler.requestAuthentication(for: [pending.element])
|
||||
if authenticationHandler.batchableRequests.isEmpty {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.normalButton()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaPadding(20)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 setPendingRequestHandler(_ handler: @escaping () async throws -> Void) {
|
||||
|
||||
}
|
||||
|
||||
func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)? {
|
||||
nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
if #available(macOS 26.0, *) {
|
||||
ScrollView {
|
||||
MultilineInfoView {
|
||||
HStack {
|
||||
HStack {
|
||||
Image("ghostty")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 50)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Ghostty")
|
||||
.font(.subheadline)
|
||||
Text("GitHub")
|
||||
.font(.headline)
|
||||
Text("Authenticating git@github.com")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack {
|
||||
Button("Review as Batch") {
|
||||
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.buttonStyle(.glassProminent)
|
||||
}
|
||||
}
|
||||
} items: {
|
||||
ForEach(0..<2) { _ in
|
||||
HStack {
|
||||
Text("4:05 PM")
|
||||
Spacer()
|
||||
Button("Review") {
|
||||
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
MultilineInfoView {
|
||||
HStack {
|
||||
HStack {
|
||||
Image("ghostty")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 50)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Ghostty")
|
||||
.font(.subheadline)
|
||||
Text("Git Signing")
|
||||
.font(.headline)
|
||||
Text("Git Signature")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack {
|
||||
Button("Review as Batch") {
|
||||
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.buttonStyle(.glassProminent)
|
||||
}
|
||||
}
|
||||
} items: {
|
||||
ForEach(0..<2) { _ in
|
||||
HStack {
|
||||
Text("4:05 PM")
|
||||
Spacer()
|
||||
Button("Review") {
|
||||
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
.padding()
|
||||
.formStyle(.grouped)
|
||||
.frame(minHeight: 700)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -64,7 +64,6 @@
|
||||
"kind": "group",
|
||||
"path": "Modifiers",
|
||||
"children": [
|
||||
{ "path": "ActionButtonStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
{ "path": "ErrorStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
{ "path": "BoxBackgroundStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
{ "path": "ToolbarButtonStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
@@ -77,7 +76,6 @@
|
||||
{ "path": "AgentStatusView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
{ "path": "ContentView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
{ "path": "CopyableView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
{ "path": "MultilineInfoView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
{ "path": "UpdateView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||
],
|
||||
},
|
||||
@@ -122,9 +120,10 @@
|
||||
"kind": "group",
|
||||
"path": "SecretAgent",
|
||||
"children": [
|
||||
{ "path": "AppDelegate.swift", "encoding": "utf8", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||
{ "path": "App.swift", "encoding": "utf8", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||
{ "path": "Notifier.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||
{ "path": "XPCInputParser.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||
{ "path": "PendingRequestsView.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||
{ "path": "XPCHostsfileReader.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||
{ "path": "CertificateMigrator.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SecretKit
|
||||
import LocalAuthentication
|
||||
|
||||
enum Preview {}
|
||||
|
||||
@@ -38,17 +39,10 @@ extension Preview {
|
||||
self.init(secrets: new)
|
||||
}
|
||||
|
||||
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws -> Data {
|
||||
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||
return data
|
||||
}
|
||||
|
||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
||||
nil
|
||||
}
|
||||
|
||||
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
|
||||
}
|
||||
|
||||
func reloadSecrets() {
|
||||
}
|
||||
|
||||
@@ -82,16 +76,10 @@ extension Preview {
|
||||
self.init(secrets: new)
|
||||
}
|
||||
|
||||
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws -> Data {
|
||||
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||
return data
|
||||
}
|
||||
|
||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
||||
nil
|
||||
}
|
||||
|
||||
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
|
||||
}
|
||||
|
||||
func reloadSecrets() {
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -39,18 +46,14 @@ struct SecretDetailView<SecretType: Secret>: View {
|
||||
if !certificates.isEmpty {
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(
|
||||
title: .secretDetailCertificatePathLabel,
|
||||
image: Image(
|
||||
systemName: "checkmark.seal.text.page"
|
||||
),
|
||||
items: certificates.map({ certificate in
|
||||
MultilineInfoView.Item(
|
||||
text: certificate.name,
|
||||
action: (Image(systemName: "chevron.forward"), { navigateToCertificate?(certificate) })
|
||||
)
|
||||
})
|
||||
)
|
||||
MultilineInfoView(title: .secretDetailCertificatePathLabel, image: Image(systemName: "checkmark.seal.text.page")) {
|
||||
ForEach(certificates) { certificate in
|
||||
Text(certificate.name)
|
||||
.multilineItemAction(image: Image(systemName: "chevron.forward")) {
|
||||
navigateToCertificate?(certificate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ struct StoreListView: View {
|
||||
}
|
||||
}
|
||||
if !certificateStore.certificates.isEmpty {
|
||||
Section("Certificates") {
|
||||
Section(.certificatesSectionTitle) {
|
||||
ForEach(certificateStore.certificates) { certificate in
|
||||
CertificateListItemView(
|
||||
certificate: certificate,
|
||||
|
||||
@@ -186,7 +186,7 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||
}
|
||||
switch interactionState {
|
||||
case .normal:
|
||||
return base
|
||||
return base.mix(with: .accentColor, by: 0)
|
||||
case .hovering:
|
||||
return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1)
|
||||
case .clicking, .dragging:
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct MultilineInfoView: View {
|
||||
|
||||
struct Item {
|
||||
let text: String
|
||||
let action: (Image, () -> Void)?
|
||||
}
|
||||
|
||||
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) {
|
||||
HStack {
|
||||
image
|
||||
.renderingMode(.template)
|
||||
.imageScale(.large)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Spacer()
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.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()
|
||||
}
|
||||
Reference in New Issue
Block a user