Compare commits

...
15 Commits
Author SHA1 Message Date
Max Goedjen 9edc879900 Fix bug where LAContext will be evaluated with incorrect policy for biometricCurrent secrets (#845)
* Fix bug where LAContext will be evaluated with deviceOwnerAuthentication instead of deviceOwnerAuthenticationWithBiometrics

* Fix test
2026-10-01 00:02:40 -05:00
Max Goedjen 54e0d00d87 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
2026-09-20 19:22:52 -07:00
Max Goedjen 43ee687232 Switch to glorious new Xcode 27 JSON project format (#837)
* Switch to glorious new Xcode 27 JSON project format

* Switch to glorious new Xcode 27 JSON project format
2026-09-16 19:51:45 -07:00
Max Goedjen 2382cd0153 Make enhanced security entitlements uniform (#833) 2026-09-08 02:44:22 +00:00
Max Goedjen 19e1c66697 import os -> import OSLog (#832) 2026-09-08 02:40:30 +00:00
Max GoedjenandPaul Heidekrüger 0a4f15e789 Secure Settings store (#831)
* Add SettingsStore

* Use keychain instead of UserDefaults in JustUpdatedChecker.swift

* Add ability disable the SSH comment via settings

* Make SettingsStore functions non-static, re-implement them as
subscripts and make settingsStore an environmentObject

Fixes: https://github.com/maxgoedjen/secretive/pull/536#discussion_r1508446459

* Add rawValues for CommentStyle enum

* Use SettingsStore for querying the comment style in SecretDetailView

Fixes: https://github.com/maxgoedjen/secretive/pull/536#discussion_r1509655340

* Revert "Use keychain instead of UserDefaults in JustUpdatedChecker.swift"

This reverts commit ae8a21a1bf7f9e2d04fcb83c4543995db0111325.

* Remove copyright info

* Use "enum Constants" in SettingsStore

* WIP

* Cleanup

* Cleanup

* Cleanup strings

* WIP

* Cleanup

* Cleanup

* Cleanup

* Cleanup

* Cleanup

---------

Co-authored-by: Paul Heidekrüger <paul.heidekrueger@tum.de>
2026-09-08 02:35:25 +00:00
Max Goedjen 59aca9327f Fix keyType constants being Data instead of String, specify in update calls (#830) 2026-09-07 23:48:57 +00:00
Max Goedjen 6122662b23 Fix Xcode 27 archiving (#829)
* Bumping to macOS 15 + set workaround for spm arm64e

* Fix missing dep

* Set 15 deployment target

* Modifier cleanup
2026-09-07 14:52:09 -07:00
Max Goedjen 56afd30db5 Fix hosts xpc service signing settings (#828) 2026-09-07 20:19:08 +00:00
Max Goedjen 70e32dcf99 Add host lookup xpc service (#826)
* Add host lookup xpc service

* Remove restriction that snuck in

* Test fixes
2026-09-07 04:32:51 +00:00
Max Goedjen e9a0ff31d3 Remove broken task entitlement lookup and only enforce on release builds. (#824) 2026-09-07 04:13:34 +00:00
Max Goedjen 8577e7cbc3 Parsing OpenSSH extensions (#823)
* Agent extension parsing

* Temporarily disable binding

* Fix test

* TODO Cleanup

* TODO Cleanup

* Put back assertion
2026-09-06 21:08:42 -07:00
Max Goedjen 85ba5a1a37 Set Xcode 27 builder (#822)
* Set Xcode 27 builder

* Fix Xcode 27 build errors
2026-09-07 03:46:51 +00:00
BrunoandMax Goedjen 039dddd107 improved localization for pt-BR (#804)
Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2026-09-07 01:17:59 +00:00
Max Goedjen b003286745 Fix macOS 27 UI issues (#814)
* Fix janky macOS 27 glass appearance.

* Fix janky macOS 27 glass appearance on multiline view too
2026-07-02 12:49:07 -07:00
75 changed files with 3823 additions and 2608 deletions
+2 -7
View File
@@ -11,7 +11,7 @@ on:
jobs: jobs:
analyze: analyze:
name: Analyze (${{ matrix.language }}) name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }} runs-on: ${{ (matrix.language == 'swift' && 'xcode-27') || 'ubuntu-latest' }}
permissions: permissions:
security-events: write security-events: write
packages: read packages: read
@@ -24,20 +24,15 @@ jobs:
include: include:
- language: actions - language: actions
build-mode: none build-mode: none
# Disable this until CodeQL supports Xcode 26 builds.
# - language: swift
# build-mode: manual
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual' - if: matrix.build-mode == 'manual'
name: "Select Xcode"
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- if: matrix.build-mode == 'manual' - if: matrix.build-mode == 'manual'
name: "Build" name: "Build"
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
+2 -4
View File
@@ -6,7 +6,7 @@ on:
jobs: jobs:
build: build:
runs-on: macos-26 runs-on: xcode-27
permissions: permissions:
id-token: write id-token: write
contents: write contents: write
@@ -25,8 +25,6 @@ jobs:
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }} APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Update Build Number - name: Update Build Number
env: env:
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}
@@ -36,7 +34,7 @@ jobs:
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
- name: Build - name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive run: IDEEnableNewPackagePIFBuilder=NO xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Move to Artifact Folder - name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts - name: Upload App to Artifacts
+2 -4
View File
@@ -5,7 +5,7 @@ on:
jobs: jobs:
build: build:
runs-on: macos-26 runs-on: xcode-27
permissions: permissions:
id-token: write id-token: write
contents: write contents: write
@@ -24,8 +24,6 @@ jobs:
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }} APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Update Build Number - name: Update Build Number
env: env:
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}
@@ -35,7 +33,7 @@ jobs:
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
- name: Build - name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive run: IDEEnableNewPackagePIFBuilder=NO xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Move to Artifact Folder - name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts - name: Upload App to Artifacts
+3 -7
View File
@@ -8,7 +8,7 @@ jobs:
test: test:
permissions: permissions:
contents: read contents: read
runs-on: macos-26 runs-on: xcode-27
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
@@ -21,8 +21,6 @@ jobs:
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }} APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Test - name: Test
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently? # SPM doesn't seem to pick up on the tests currently?
@@ -34,7 +32,7 @@ jobs:
attestations: write attestations: write
artifact-metadata: write artifact-metadata: write
actions: read actions: read
runs-on: macos-26 runs-on: xcode-27
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
@@ -47,8 +45,6 @@ jobs:
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }} APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Update Build Number - name: Update Build Number
env: env:
TAG_NAME: ${{ github.ref }} TAG_NAME: ${{ github.ref }}
@@ -59,7 +55,7 @@ jobs:
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_URL/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
- name: Build - name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive run: IDEEnableNewPackagePIFBuilder=NO xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Move to Artifact Folder - name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts - name: Upload App to Artifacts
+1 -3
View File
@@ -5,12 +5,10 @@ jobs:
test: test:
permissions: permissions:
contents: read contents: read
runs-on: macos-26 runs-on: xcode-27
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Test Main Packages - name: Test Main Packages
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently? # SPM doesn't seem to pick up on the tests currently?
@@ -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>
+12 -3
View File
@@ -1,4 +1,4 @@
// swift-tools-version:6.2 // swift-tools-version:6.4
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@@ -7,7 +7,7 @@ let package = Package(
name: "SecretivePackages", name: "SecretivePackages",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.macOS(.v14) .macOS(.v15)
], ],
products: [ products: [
.library( .library(
@@ -22,6 +22,9 @@ let package = Package(
.library( .library(
name: "CertificateKit", name: "CertificateKit",
targets: ["CertificateKit"]), targets: ["CertificateKit"]),
.library(
name: "SettingsKit",
targets: ["SettingsKit"]),
.library( .library(
name: "SecretAgentKit", name: "SecretAgentKit",
targets: ["SecretAgentKit"]), targets: ["SecretAgentKit"]),
@@ -76,6 +79,12 @@ let package = Package(
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
.target(
name: "SettingsKit",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target( .target(
name: "SecretAgentKit", name: "SecretAgentKit",
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"], dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],
@@ -111,7 +120,7 @@ let package = Package(
), ),
.target( .target(
name: "SharedXPCServices", name: "SharedXPCServices",
dependencies: ["CertificateKit", "SSHProtocolKit"], dependencies: ["XPCWrappers", "CertificateKit", "SSHProtocolKit"],
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -34,12 +34,12 @@ import XPCWrappers
) { ) {
self.osVersion = osVersion self.osVersion = osVersion
self.currentVersion = currentVersion self.currentVersion = currentVersion
Task { _ = Task {
if checkOnLaunch { if checkOnLaunch {
try await checkForUpdates() try await checkForUpdates()
} }
while !Task.isCancelled { while true {
try? await Task.sleep(for: .seconds(Int(checkFrequency))) try await Task.sleep(for: .seconds(Int(checkFrequency)))
try await checkForUpdates() try await checkForUpdates()
} }
} }
@@ -1,7 +1,7 @@
import Foundation import Foundation
import Observation import Observation
import Security import Security
import os import OSLog
import SecretKit import SecretKit
@Observable @MainActor public final class CertificateStore: Sendable { @Observable @MainActor public final class CertificateStore: Sendable {
@@ -67,6 +67,7 @@ import SecretKit
public func update(certificate: Certificate) throws { public func update(certificate: Certificate) throws {
let updateQuery = KeychainDictionary([ let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass, kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: certificate.id, kSecAttrAccount: certificate.id,
]) ])
@@ -134,7 +135,7 @@ extension CertificateStore {
enum Constants { enum Constants {
static let keyClass = kSecClassGenericPassword as String static let keyClass = kSecClassGenericPassword as String
static let keyTag = Data("com.maxgoedjen.certificatestore.opensshcertificate".utf8) static let keyTag = "com.maxgoedjen.certificatestore.opensshcertificate"
static let notificationToken = UUID().uuidString static let notificationToken = UUID().uuidString
} }
@@ -1,11 +1,11 @@
import SwiftUI import SwiftUI
struct PrimaryButtonModifier: ViewModifier { public struct PrimaryButtonModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.isEnabled) var isEnabled @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. // Tinted glass prominent is really hard to read on 26.0.
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled { if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
content.buttonStyle(.glassProminent) content.buttonStyle(.glassProminent)
@@ -18,15 +18,15 @@ struct PrimaryButtonModifier: ViewModifier {
extension View { extension View {
func primaryButton() -> some View { public func primaryButton() -> some View {
modifier(PrimaryButtonModifier()) 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, *) { if #available(macOS 26.0, *) {
content content
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle) .glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
@@ -40,15 +40,15 @@ struct ToolbarCircleButtonModifier: ViewModifier {
extension View { extension View {
func toolbarCircleButton() -> some View { public func toolbarCircleButton() -> some View {
modifier(ToolbarCircleButtonModifier()) 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, *) { if #available(macOS 26.0, *) {
content.buttonStyle(.glass) content.buttonStyle(.glass)
} else { } else {
@@ -60,17 +60,17 @@ struct NormalButtonModifier: ViewModifier {
extension View { extension View {
func normalButton() -> some View { public func normalButton() -> some View {
modifier(NormalButtonModifier()) modifier(NormalButtonModifier())
} }
} }
struct DangerButtonModifier: ViewModifier { public struct DangerButtonModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme @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. // Tinted glass prominent is really hard to read on 26.0.
if #available(macOS 26.0, *), colorScheme == .dark { if #available(macOS 26.0, *), colorScheme == .dark {
content.buttonStyle(.glassProminent) content.buttonStyle(.glassProminent)
@@ -87,7 +87,7 @@ struct DangerButtonModifier: ViewModifier {
extension View { extension View {
func danger() -> some View { public func danger() -> some View {
modifier(DangerButtonModifier()) 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()
}
@@ -19,7 +19,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
("nistp" + String(describing: secret.keyType.size)).lengthAndData + ("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData secret.publicKey.lengthAndData
case .mldsa: case .mldsa:
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt // https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
openSSHIdentifier(for: secret.keyType).lengthAndData + openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData secret.publicKey.lengthAndData
case .rsa: case .rsa:
@@ -42,6 +42,18 @@ public final class OpenSSHReader {
return convertEndianness ? T(value.bigEndian) : T(value) return convertEndianness ? T(value.bigEndian) : T(value)
} }
public func readNextByteAsBool() throws(OpenSSHReaderError) -> Bool {
let size = MemoryLayout<Bool>.size
guard remaining.count >= size else { throw .beyondBounds }
let lengthRange = 0..<size
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
if remaining.isEmpty {
done = true
}
return unsafe lengthChunk.bytes.unsafeLoad(as: Bool.self)
}
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String { public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self) try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
} }
@@ -53,5 +65,6 @@ public final class OpenSSHReader {
} }
public enum OpenSSHReaderError: Error, Codable { public enum OpenSSHReaderError: Error, Codable {
case incorrectFormat
case beyondBounds case beyondBounds
} }
@@ -17,7 +17,7 @@ public struct OpenSSHSignatureWriter: Sendable {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1 // https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
ecdsaSignature(signature, keyType: secret.keyType) ecdsaSignature(signature, keyType: secret.keyType)
case .mldsa: case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms // https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
mldsaSignature(signature, keyType: secret.keyType) mldsaSignature(signature, keyType: secret.keyType)
case .rsa: case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 // https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
@@ -3,6 +3,8 @@ import OSLog
import SecretKit import SecretKit
import CertificateKit import CertificateKit
import CryptoKit
public protocol SSHAgentInputParserProtocol { public protocol SSHAgentInputParserProtocol {
func parse(data: Data) async throws -> SSHAgent.Request func parse(data: Data) async throws -> SSHAgent.Request
@@ -53,8 +55,8 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
return .unlock return .unlock
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID: case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
return .addSmartcardKeyConstrained return .addSmartcardKeyConstrained
case SSHAgent.Request.protocolExtension.protocolID: case SSHAgent.Request.protocolExtension(.empty).protocolID:
return .protocolExtension return .protocolExtension(try protocolExtension(from: body))
default: default:
return .unknown(rawRequestInt) return .unknown(rawRequestInt)
} }
@@ -64,12 +66,154 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
extension SSHAgentInputParser { extension SSHAgentInputParser {
private enum Constants {
static let userAuthMagic: UInt8 = 50 // SSH2_MSG_USERAUTH_REQUEST
static let sshSigMagic = Data("SSHSIG".utf8)
}
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext { func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
let reader = OpenSSHReader(data: data) let reader = OpenSSHReader(data: data)
let rawKeyBlob = try reader.readNextChunk() let rawKeyBlob = try reader.readNextChunk()
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
let dataToSign = try reader.readNextChunk() let rawPayload = try reader.readNextChunk()
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign) let payload: SSHAgent.Request.SignatureRequestContext.SignaturePayload
do {
if rawPayload.count > 6 && rawPayload[0..<6] == Constants.sshSigMagic {
payload = .init(raw: rawPayload, decoded: .sshSig(try sshSigPayload(from: rawPayload[6...])))
} else {
payload = .init(raw: rawPayload, decoded: .sshConnection(try sshConnectionPayload(from: rawPayload)))
}
} catch {
payload = .init(raw: rawPayload, decoded: nil)
}
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: payload)
}
func sshSigPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHSigPayload {
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L79
let payloadReader = OpenSSHReader(data: data)
let namespace = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunk() // reserved
let hashAlgorithm = try payloadReader.readNextChunkAsString()
let hash = try payloadReader.readNextChunk()
return .init(
namespace: namespace,
hashAlgorithm: hashAlgorithm,
hash: hash
)
}
func sshConnectionPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHConnectionPayload {
let payloadReader = OpenSSHReader(data: data)
_ = try payloadReader.readNextChunk()
let magic = try payloadReader.readNextBytes(as: UInt8.self, convertEndianness: false)
guard magic == Constants.userAuthMagic else { throw .incorrectFormat }
let username = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunkAsString() // "ssh-connection"
_ = try payloadReader.readNextChunkAsString() // "publickey-hostbound-v00@openssh.com"
let hasSignature = try payloadReader.readNextByteAsBool()
let algorithm = try payloadReader.readNextChunkAsString()
let publicKeyReader = try payloadReader.readNextChunkAsSubReader()
_ = try publicKeyReader.readNextChunk()
_ = try publicKeyReader.readNextChunk()
let publicKey = try publicKeyReader.readNextChunk()
let hostKeyReader = try payloadReader.readNextChunkAsSubReader()
_ = try hostKeyReader.readNextChunk()
let hostKey = try hostKeyReader.readNextChunk()
return .init(
username: username,
hasSignature: hasSignature,
publicKeyAlgorithm: algorithm,
publicKey: publicKey,
hostKey: hostKey,
)
}
func protocolExtension(from data: Data) throws(AgentParsingError) -> SSHAgent.ProtocolExtension {
do {
let reader = OpenSSHReader(data: data)
let nameRaw = try reader.readNextChunkAsString()
let nameSplit = nameRaw.split(separator: "@")
guard nameSplit.count == 2 else {
throw AgentParsingError.invalidData
}
let (name, domain) = (nameSplit[0], nameSplit[1])
switch domain {
case SSHAgent.ProtocolExtension.OpenSSHExtension.domain:
switch name {
case SSHAgent.ProtocolExtension.OpenSSHExtension.sessionBind(.empty).name:
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
}
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)
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
}
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
hostKey: hostKeyData,
sessionID: sessionID,
signature: signature,
forwarding: forwarding
)
return .openSSH(.sessionBind(context))
} catch {
return .openSSH(.sessionBind(.empty))
}
default:
return .openSSH(.unknown(String(name)))
}
default:
return .unknown(nameRaw)
}
} catch let error as OpenSSHReaderError {
throw .openSSHReader(error)
} catch let error as AgentParsingError {
throw error
} catch {
throw .unknownRequest
}
} }
func certificatePublicKeyBlob(from hash: Data) -> Data? { func certificatePublicKeyBlob(from hash: Data) -> Data? {
@@ -99,6 +243,7 @@ extension SSHAgentInputParser {
case unknownRequest case unknownRequest
case unhandledRequest case unhandledRequest
case invalidData case invalidData
case incorrectSignature
case openSSHReader(OpenSSHReaderError) case openSSHReader(OpenSSHReaderError)
} }
@@ -19,7 +19,7 @@ extension SSHAgent {
case lock case lock
case unlock case unlock
case addSmartcardKeyConstrained case addSmartcardKeyConstrained
case protocolExtension case protocolExtension(ProtocolExtension)
case unknown(UInt8) case unknown(UInt8)
public var protocolID: UInt8 { public var protocolID: UInt8 {
@@ -60,18 +60,82 @@ extension SSHAgent {
public struct SignatureRequestContext: Sendable, Codable { public struct SignatureRequestContext: Sendable, Codable {
public let keyBlob: Data public let keyBlob: Data
public let dataToSign: Data public let dataToSign: SignaturePayload
public init(keyBlob: Data, dataToSign: Data) { public init(keyBlob: Data, dataToSign: SignaturePayload) {
self.keyBlob = keyBlob self.keyBlob = keyBlob
self.dataToSign = dataToSign self.dataToSign = dataToSign
} }
public static var empty: SignatureRequestContext { public static var empty: SignatureRequestContext {
SignatureRequestContext(keyBlob: Data(), dataToSign: Data()) SignatureRequestContext(keyBlob: Data(), dataToSign: SignaturePayload(raw: Data(), decoded: nil))
}
public struct SignaturePayload: Sendable, Codable {
public let raw: Data
public let decoded: DecodedPayload?
public init(
raw: Data,
decoded: DecodedPayload?
) {
self.raw = raw
self.decoded = decoded
}
public enum DecodedPayload: Sendable, Codable {
case sshConnection(SSHConnectionPayload)
case sshSig(SSHSigPayload)
public struct SSHConnectionPayload: Sendable, Codable {
public let username: String
public let hasSignature: Bool
public let publicKeyAlgorithm: String
public let publicKey: Data
public let hostKey: Data
public init(
username: String,
hasSignature: Bool,
publicKeyAlgorithm: String,
publicKey: Data,
hostKey: Data
) {
self.username = username
self.hasSignature = hasSignature
self.publicKeyAlgorithm = publicKeyAlgorithm
self.publicKey = publicKey
self.hostKey = hostKey
}
}
public struct SSHSigPayload: Sendable, Codable {
public let namespace: String
public let hashAlgorithm: String
public let hash: Data
public init(
namespace: String,
hashAlgorithm: String,
hash: Data,
) {
self.namespace = namespace
self.hashAlgorithm = hashAlgorithm
self.hash = hash
}
}
}
} }
} }
} }
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 /// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
@@ -88,8 +152,8 @@ extension SSHAgent {
switch self { switch self {
case .agentFailure: "SSH_AGENT_FAILURE" case .agentFailure: "SSH_AGENT_FAILURE"
case .agentSuccess: "SSH_AGENT_SUCCESS" case .agentSuccess: "SSH_AGENT_SUCCESS"
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER" case .agentIdentitiesAnswer: "SSH2_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE" case .agentSignResponse: "SSH2_AGENT_SIGN_RESPONSE"
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE" case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE" case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
} }
@@ -0,0 +1,76 @@
import Foundation
// Extensions, as defined in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.agent
extension SSHAgent {
public enum ProtocolExtension: CustomDebugStringConvertible, Codable, Sendable {
case openSSH(OpenSSHExtension)
case unknown(String)
public var debugDescription: String {
switch self {
case let .openSSH(protocolExtension):
protocolExtension.debugDescription
case .unknown(let string):
"Unknown (\(string))"
}
}
public static var empty: ProtocolExtension {
.unknown("empty")
}
private struct ProtocolExtensionParsingError: Error {}
}
}
extension SSHAgent.ProtocolExtension {
public enum OpenSSHExtension: CustomDebugStringConvertible, Codable, Sendable {
case sessionBind(SessionBindContext)
case unknown(String)
public static var domain: String {
"openssh.com"
}
public var name: String {
switch self {
case .sessionBind:
"session-bind"
case .unknown(let name):
name
}
}
public var debugDescription: String {
"\(name)@\(OpenSSHExtension.domain)"
}
}
}
extension SSHAgent.ProtocolExtension.OpenSSHExtension {
public struct SessionBindContext: Codable, Sendable {
public let hostKey: Data
public let sessionID: Data
public let signature: Data
public let forwarding: Bool
public init(hostKey: Data, sessionID: Data, signature: Data, forwarding: Bool) {
self.hostKey = hostKey
self.sessionID = sessionID
self.signature = signature
self.forwarding = forwarding
}
public static let empty = SessionBindContext(hostKey: Data(), sessionID: Data(), signature: Data(), forwarding: false)
}
}
@@ -10,20 +10,29 @@ import SSHProtocolKit
public final class Agent: Sendable { public final class Agent: Sendable {
private let storeList: SecretStoreList private let storeList: SecretStoreList
private let authenticationHandler: AuthenticationHandler
private let certificateStore: CertificateStore private let certificateStore: CertificateStore
private let witness: SigningWitness? private let witness: SigningWitness?
private let publicKeyWriter = OpenSSHPublicKeyWriter() private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let signatureWriter = OpenSSHSignatureWriter() private let signatureWriter = OpenSSHSignatureWriter()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@MainActor private var sessionID: SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext?
/// Initializes an agent with a store list and a witness. /// Initializes an agent with a store list and a witness.
/// - Parameters: /// - Parameters:
/// - storeList: The `SecretStoreList` to make available. /// - storeList: The `SecretStoreList` to make available.
/// - witness: A witness to notify of requests. /// - 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") logger.debug("Agent is running")
self.storeList = storeList self.storeList = storeList
self.certificateStore = certificateStore self.certificateStore = certificateStore
self.authenticationHandler = authenticationHandler
self.witness = witness self.witness = witness
} }
@@ -31,7 +40,12 @@ public final class Agent: Sendable {
extension Agent { extension Agent {
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data { public func handle(
request: SSHAgent.Request,
provenance: SigningRequestProvenance,
hosts: [Data: String]?
) async -> Data {
logger.debug("Agent received request of type \(request.debugDescription)")
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting // Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
await reloadSecretsIfNeccessary() await reloadSecretsIfNeccessary()
var response = Data() var response = Data()
@@ -42,9 +56,55 @@ extension Agent {
response.append(await identities()) response.append(await identities())
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
case .signRequest(let context): case .signRequest(let context):
let target: SigningRequestTarget?
switch context.dataToSign.decoded {
case .sshConnection(let payload):
target = .connection(
.init(
username: payload.username,
hasSignature: payload.hasSignature,
publicKeyAlgorithm: payload.publicKeyAlgorithm,
publicKey: payload.publicKey,
hostKey: payload.hostKey,
host: hosts?[payload.hostKey]
)
)
if let boundSession = await sessionID {
guard payload.hostKey == boundSession.hostKey else {
logger.error("Agent received bind request, but host key does not match signature request host key.")
throw BindingFailure()
}
}
case .sshSig(let payload):
target = .signature(
.init(
namespace: payload.namespace,
hashAlgorithm: payload.hashAlgorithm,
hash: payload.hash
)
)
default:
target = nil
}
_ = target
response.append(SSHAgent.Response.agentSignResponse.data) response.append(SSHAgent.Response.agentSignResponse.data)
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance)) response.append(try await sign(data: context.dataToSign.raw, keyBlob: context.keyBlob, provenance: provenance, target: target))
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .protocolExtension(.openSSH(.sessionBind(let bind))):
// This is disabled until forward enforcement is handled.
_ = bind
response = try await MainActor.run {
logger.debug("Agent received bind request but not currently supported.")
throw UnhandledRequestError()
// guard sessionID == nil else {
// logger.error("Agent received bind request, but already bound.")
// throw BindingFailure()
// }
// logger.debug("Agent bound")
// sessionID = bind
// return SSHAgent.Response.agentSuccess.data
}
logger.debug("Agent returned \(SSHAgent.Response.agentSuccess.debugDescription)")
case .unknown(let value): case .unknown(let value):
logger.error("Agent received unknown request of type \(value).") logger.error("Agent received unknown request of type \(value).")
throw UnhandledRequestError() throw UnhandledRequestError()
@@ -92,22 +152,28 @@ extension Agent {
/// - data: The data to sign. /// - data: The data to sign.
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request. /// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
/// - Returns: An OpenSSH formatted Data payload containing the signed data response. /// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data { func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
guard let (secret, store) = await secret(matching: keyBlob) else { guard let (secret, store) = await secret(matching: keyBlob) else {
let keyBlobHex = keyBlob.formatted(.hex()) let keyBlobHex = keyBlob.formatted(.hex())
logger.debug("Agent did not have a key matching \(keyBlobHex)") logger.debug("Agent did not have a key matching \(keyBlobHex)")
throw NoMatchingKeyError() 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) let request = SignatureRequest(secret: secret, provenance: provenance, target: target)
let newContext = AuthenticationContext(secret: secret, requests: [request])
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) let context = try await authenticationHandler.authenticatedContext(for: request, context: newContext)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) 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) try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: secret.authenticationRequirement.required)
logger.debug("Agent signed request") logger.debug("Agent signed request")
return signedData return signedData
} }
@@ -142,6 +208,7 @@ extension Agent {
struct NoMatchingKeyError: Error {} struct NoMatchingKeyError: Error {}
struct UnhandledRequestError: Error {} struct UnhandledRequestError: Error {}
struct BindingFailure: Error {}
} }
@@ -0,0 +1,218 @@
@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(requireBiometrics: Bool) async throws -> Bool {
guard let laContext else { return false }
let policy: LAPolicy = requireBiometrics ? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication
return try await laContext.evaluatePolicy(policy, 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(requireBiometrics: request.secret.authenticationRequirement == .biometryCurrent)) ?? 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(requireBiometrics: secret.authenticationRequirement == .biometryCurrent)
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(requireBiometrics: first.secret.authenticationRequirement == .biometryCurrent)) ?? 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)) let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN)) _ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
let path = unsafe String(cString: pathPointer) let path = unsafe String(cString: pathPointer)
var secCode: Unmanaged<SecCode>! var secCode: Unmanaged<SecCode>!
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks] 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 //// from SecTask.h
@_silgen_name("SecCodeCreateWithPID") @_silgen_name("SecCodeCreateWithPID")
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus @discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus
@@ -1,5 +1,6 @@
import Foundation import Foundation
import SecretKit import SecretKit
import SSHProtocolKit
/// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access. /// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access.
public protocol SigningWitness: Sendable { public protocol SigningWitness: Sendable {
@@ -10,13 +11,13 @@ public protocol SigningWitness: Sendable {
/// - store: The `Store` being asked to sign the request.. /// - store: The `Store` being asked to sign the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
/// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail. /// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail.
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws
/// Notifies the callee that a signing operation has been performed for a given secret. /// Notifies the callee that a signing operation has been performed for a given secret.
/// - Parameters: /// - Parameters:
/// - secret: The `Secret` that will was used to sign the request. /// - secret: The `Secret` that will was used to sign the request.
/// - store: The `Store` that signed the request.. /// - store: The `Store` that signed the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) 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 Task { @MainActor [fileHandle, sessionsContinuation, logger] in
// Create the sequence before triggering the notification to // Create the sequence before triggering the notification to
// ensure it will not be missed. // ensure it will not be missed.
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted, object: fileHandle)
fileHandle.acceptConnectionInBackgroundAndNotify() fileHandle.acceptConnectionInBackgroundAndNotify()
@@ -94,7 +94,7 @@ extension SocketController {
guard !data.isEmpty else { guard !data.isEmpty else {
logger.debug("Socket controller received empty data, ending continuation.") logger.debug("Socket controller received empty data, ending continuation.")
messagesContinuation.finish() messagesContinuation.finish()
try fileHandle.close() try? fileHandle.close()
return return
} }
messagesContinuation.yield(data) messagesContinuation.yield(data)
@@ -126,8 +126,8 @@ private extension SocketPort {
convenience init(path: String) { convenience init(path: String) {
var addr = sockaddr_un() var addr = sockaddr_un()
let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in let length = withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
unsafe path.withCString { cstring in path.withCString { cstring in
let len = unsafe strlen(cstring) let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len) unsafe strncpy(pointer, cstring, len)
return len return len
@@ -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 ### Authentication Persistence
- ``PersistedAuthenticationContext`` - ``AuthenticationContextProtocol``
### Errors ### Errors
@@ -1,4 +1,5 @@
import Foundation import Foundation
import LocalAuthentication
/// Type eraser for SecretStore. /// Type eraser for SecretStore.
open class AnySecretStore: SecretStore, @unchecked Sendable { open class AnySecretStore: SecretStore, @unchecked Sendable {
@@ -8,9 +9,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
private let _id: @Sendable () -> UUID private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String private let _name: @MainActor @Sendable () -> String
private let _secrets: @MainActor @Sendable () -> [AnySecret] private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, SigningRequestTarget?, LAContext?) async throws -> Data
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: @Sendable () async -> Void private let _reloadSecrets: @Sendable () async -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
@@ -19,9 +18,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
_name = { secretStore.name } _name = { secretStore.name }
_id = { secretStore.id } _id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } } _secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } _sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, target: $3, context: $4) }
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { await secretStore.reloadSecrets() } _reloadSecrets = { await secretStore.reloadSecrets() }
} }
@@ -41,16 +38,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
return _secrets() return _secrets()
} }
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
try await _sign(data, secret, provenance) try await _sign(data, secret, provenance, target, context)
}
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 reloadSecrets() async { 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(requireBiometrics: Bool) 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 Foundation
import LocalAuthentication
/// Manages access to Secrets, and performs signature operations on data using those Secrets. /// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore<SecretType>: Identifiable, Sendable { public protocol SecretStore<SecretType>: Identifiable, Sendable {
@@ -20,20 +21,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - secret: The ``Secret`` to sign with. /// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from. /// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: The signed data. /// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) 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
/// Requests that the store reload secrets from any backing store, if neccessary. /// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() async func reloadSecrets() async
@@ -2,13 +2,23 @@ import Foundation
import AppKit import AppKit
/// Describes the chain of applications that requested a signature operation. /// 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. /// 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` /// - 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 var chain: [Process]
public init(root: Process) {
public var date: Date
public init(root: Process, date: Date = .now) {
self.chain = [root] 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 { extension SigningRequestProvenance {
/// Describes a process in a `SigningRequestProvenance` chain. /// Describes a process in a `SigningRequestProvenance` chain.
public struct Process: Equatable, Sendable { public struct Process: Hashable, Sendable {
/// The pid of the process. /// The pid of the process.
public let pid: Int32 public let pid: Int32
@@ -0,0 +1,60 @@
import Foundation
import AppKit
/// Describes the target of the signature operation.
public enum SigningRequestTarget: Sendable, Hashable {
case connection(ConnectionPayload)
case signature(SignaturePayload)
public var batchID: Int {
hashValue
}
public struct ConnectionPayload: Sendable, Codable, Hashable {
public let username: String
public let hasSignature: Bool
public let publicKeyAlgorithm: String
public let publicKey: Data
public let hostKey: Data
public let host: String?
public init(
username: String,
hasSignature: Bool,
publicKeyAlgorithm: String,
publicKey: Data,
hostKey: Data,
host: String?
) {
self.username = username
self.hasSignature = hasSignature
self.publicKeyAlgorithm = publicKeyAlgorithm
self.publicKey = publicKey
self.hostKey = hostKey
self.host = host
}
}
public struct SignaturePayload: Sendable, Codable, Hashable {
public let namespace: String
public let hashAlgorithm: String
public let hash: Data
public init(
namespace: String,
hashAlgorithm: String,
hash: Data,
) {
self.namespace = namespace
self.hashAlgorithm = hashAlgorithm
self.hash = hash
}
}
}
@@ -3,7 +3,7 @@ import Security
import CryptoTokenKit import CryptoTokenKit
import CryptoKit import CryptoKit
import SecretKit import SecretKit
import os import OSLog
extension SecureEnclave { extension SecureEnclave {
@@ -4,7 +4,7 @@ import Security
import CryptoKit import CryptoKit
import LocalAuthentication import LocalAuthentication
import SecretKit import SecretKit
import os import OSLog
extension SecureEnclave { extension SecureEnclave {
@@ -17,7 +17,6 @@ extension SecureEnclave {
} }
public let id = UUID() public let id = UUID()
public let name = String(localized: .secureEnclave) public let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
/// Initializes a Store. /// Initializes a Store.
@MainActor public init() { @MainActor public init() {
@@ -37,17 +36,7 @@ extension SecureEnclave {
// MARK: SecretStore // MARK: SecretStore
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
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 queryAttributes = KeychainDictionary([ let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass, kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag, kSecAttrService: Constants.keyTag,
@@ -88,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() { @MainActor public func reloadSecrets() {
let before = secrets let before = secrets
secrets.removeAll() secrets.removeAll()
@@ -176,6 +157,7 @@ extension SecureEnclave {
let attributes = try JSONEncoder().encode(attributes) let attributes = try JSONEncoder().encode(attributes)
let updatedAttributes = KeychainDictionary([ let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name, kSecAttrLabel: name,
kSecAttrService: Constants.keyTag,
kSecAttrGeneric: attributes, kSecAttrGeneric: attributes,
]) ])
@@ -287,7 +269,7 @@ extension SecureEnclave.Store {
enum Constants { enum Constants {
static let keyClass = kSecClassGenericPassword as String static let keyClass = kSecClassGenericPassword as String
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) static let keyTag = "com.maxgoedjen.secretive.secureenclave.key"
static let notificationToken = UUID().uuidString static let notificationToken = UUID().uuidString
} }
@@ -0,0 +1,14 @@
import Foundation
struct RequireDestinationInformationSettingsKey: SettingsStore.SettingsKey {
static let defaultValue: Bool = true
}
extension SettingsStore {
public var requireDestinationInformation: Bool {
get { self[RequireDestinationInformationSettingsKey.self] }
set { self[RequireDestinationInformationSettingsKey.self] = newValue }
}
}
@@ -0,0 +1,114 @@
import Foundation
import Observation
import Security
import OSLog
// Setting store backed by macOS keychain for stronger guarantees around ownership/other-process-modification than UserDefaults offers.
@Observable @MainActor public final class SettingsStore: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.settings", category: "SettingsStore")
public init() {
}
private var state: [ObjectIdentifier: UUID] = [:]
subscript<SettingsKeyType: SettingsKey>(_ key: SettingsKeyType.Type) -> SettingsKeyType.Value {
get {
_ = state[ObjectIdentifier(key)]
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
kSecReturnAttributes: true,
kSecMatchLimit: kSecMatchLimitOne,
])
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [CFString: Any] else { return SettingsKeyType.defaultValue }
let decoder = JSONDecoder()
guard let data = typed[kSecValueData] as? Data else { return SettingsKeyType.defaultValue }
return (try? decoder.decode(SettingValue<SettingsKeyType.Value>.self, from: data).value) ?? SettingsKeyType.defaultValue
}
set {
do {
let data = try JSONEncoder().encode(SettingValue(value: newValue))
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: data,
])
let status = SecItemAdd(keychainAttributes, nil)
switch status {
case errSecSuccess:
break
case errSecDuplicateItem:
let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
])
let updatedAttributes = KeychainDictionary([
kSecValueData: data,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
default:
throw KeychainError(statusCode: status)
}
state[ObjectIdentifier(key)] = UUID()
} catch {
logger.error("Error updating key: \(String(describing: SettingsKeyType.self), privacy: .public): \(error.localizedDescription.debugDescription, privacy: .public)")
}
}
}
}
extension SettingsStore {
public protocol SettingsKey<Value>: Equatable, Codable, Sendable {
associatedtype Value: Codable
static var defaultValue: Value { get }
}
}
extension SettingsStore {
struct SettingValue<Value: Codable>: Codable {
let value: Value
}
}
extension SettingsStore {
fileprivate struct KeychainError: Error {
let statusCode: OSStatus?
}
}
fileprivate func KeychainDictionary(_ dictionary: [CFString: Any]) -> CFDictionary {
dictionary as CFDictionary
}
extension SettingsStore {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = "com.maxgoedjen.settingsStore"
}
}
@@ -2,7 +2,7 @@ import Foundation
import Security import Security
import CryptoTokenKit import CryptoTokenKit
import CryptoKit import CryptoKit
import os import OSLog
import SSHProtocolKit import SSHProtocolKit
import CertificateKit import CertificateKit
@@ -34,7 +34,6 @@ extension SmartCard {
public var secrets: [Secret] { public var secrets: [Secret] {
state.secrets state.secrets
} }
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
@@ -57,25 +56,19 @@ extension SmartCard {
// MARK: Public API // MARK: Public API
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) 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() } guard let tokenID = await state.tokenID else { fatalError() }
var context: LAContext var rawAttributes: [CFString: Any] = [
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = unsafe existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData, kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID, kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context,
kSecReturnRef: true kSecReturnRef: true
]) ]
if let context {
rawAttributes[kSecUseAuthenticationContext] = context
}
let attributes = KeychainDictionary(rawAttributes)
var untyped: CFTypeRef? var untyped: CFTypeRef?
let status = unsafe SecItemCopyMatching(attributes, &untyped) let status = unsafe SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess { if status != errSecSuccess {
@@ -93,14 +86,6 @@ extension SmartCard {
return signature as Data 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. /// Reloads all secrets from the store.
@MainActor public func reloadSecrets() { @MainActor public func reloadSecrets() {
reloadSecretsInternal() reloadSecretsInternal()
@@ -1,26 +0,0 @@
import Foundation
extension ProcessInfo {
private static let fallbackTeamID = "Z72PRUAWF6"
private static let teamID: String = {
#if DEBUG
guard let task = SecTaskCreateFromSelf(nil) else {
assertionFailure("SecTaskCreateFromSelf failed")
return fallbackTeamID
}
guard let value = SecTaskCopyValueForEntitlement(task, "com.apple.developer.team-identifier" as CFString, nil) as? String else {
assertionFailure("SecTaskCopyValueForEntitlement(com.apple.developer.team-identifier) failed")
return fallbackTeamID
}
return value
#else
/// Always use hardcoded team ID for release builds, just in case.
return fallbackTeamID
#endif
}()
public var teamID: String { Self.teamID }
}
@@ -12,7 +12,9 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self) newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
let exportedObject = exportedObject let exportedObject = exportedObject
newConnection.exportedObject = exportedObject newConnection.exportedObject = exportedObject
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"") #if !DEBUG
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
#endif
newConnection.resume() newConnection.resume()
return true return true
} }
@@ -8,7 +8,9 @@ public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error
public init(serviceName: String, warmup: Bool = false) async throws { public init(serviceName: String, warmup: Bool = false) async throws {
let connection = NSXPCConnection(serviceName: serviceName) let connection = NSXPCConnection(serviceName: serviceName)
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self) connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"") #if !DEBUG
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
#endif
connection.resume() connection.resume()
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() } guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
self.connection = connection self.connection = connection
@@ -11,17 +11,17 @@ import CertificateKit
// MARK: Identity Listing // MARK: Identity Listing
@Test func emptyStores() async throws { @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 request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestIdentitiesEmpty) #expect(response == Constants.Responses.requestIdentitiesEmpty)
} }
@Test func identitiesList() async throws { @Test func identitiesList() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) 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 request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let actual = OpenSSHReader(data: response) let actual = OpenSSHReader(data: response)
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple) let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
@@ -33,9 +33,9 @@ import CertificateKit
@Test func noMatchingIdentities() async throws { @Test func noMatchingIdentities() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) 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 request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
} }
@@ -43,8 +43,8 @@ import CertificateKit
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
guard case SSHAgent.Request.signRequest(let context) = request else { return } guard case SSHAgent.Request.signRequest(let context) = request else { return }
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) 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) let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let responseReader = OpenSSHReader(data: response) let responseReader = OpenSSHReader(data: response)
let length = try responseReader.readNextBytes(as: UInt32.self) let length = try responseReader.readNextBytes(as: UInt32.self)
let type = try responseReader.readNextBytes(as: UInt8.self) let type = try responseReader.readNextBytes(as: UInt8.self)
@@ -68,7 +68,7 @@ import CertificateKit
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs) let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
// Correct signature // Correct signature
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey) #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
.isValidSignature(signature, for: context.dataToSign)) .isValidSignature(signature, for: context.dataToSign.raw))
} }
// MARK: Witness protocol // MARK: Witness protocol
@@ -78,8 +78,8 @@ import CertificateKit
let witness = StubWitness(speakNow: { _,_ in let witness = StubWitness(speakNow: { _,_ in
return true return true
}, witness: { _, _ in }) }, 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) let response = await agent.handle(request: .signRequest(.empty), provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
} }
@@ -91,9 +91,9 @@ import CertificateKit
}, witness: { _, trace in }, witness: { _, trace in
witnessed = true 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) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test) _ = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(witnessed) #expect(witnessed)
} }
@@ -107,9 +107,9 @@ import CertificateKit
}, witness: { _, trace in }, witness: { _, trace in
witnessTrace = trace 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) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test) _ = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(witnessTrace == speakNowTrace) #expect(witnessTrace == speakNowTrace)
#expect(witnessTrace == .test) #expect(witnessTrace == .test)
} }
@@ -120,57 +120,18 @@ import CertificateKit
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = list.stores.first?.base as! Stub.Store let store = list.stores.first?.base as! Stub.Store
store.shouldThrow = true 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 request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
} }
// MARK: Unsupported // MARK: Unsupported
@Test func unhandledAdd() async throws { @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) let response = await agent.handle(request: .addIdentity, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure) #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,169 @@
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(requireBiometrics: Bool) 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 SecretKit
import CryptoKit import CryptoKit
import SSHProtocolKit import SSHProtocolKit
import LocalAuthentication
struct Stub {} struct Stub {}
@@ -49,7 +50,7 @@ extension Stub {
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))") print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) throws -> Data {
guard !shouldThrow else { guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil) throw NSError(domain: "test", code: 0, userInfo: nil)
} }
@@ -57,13 +58,6 @@ extension Stub {
return try privateKey.signature(for: data).rawRepresentation 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() { public func reloadSecrets() {
} }
@@ -10,14 +10,14 @@ struct StubWitness {
extension StubWitness: SigningWitness { extension StubWitness: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws { func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws {
let objection = speakNow(secret, provenance) let objection = speakNow(secret, provenance)
if objection { if objection {
throw TheresMyChance() throw TheresMyChance()
} }
} }
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws { func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
witness(secret, provenance) witness(secret, provenance)
} }
+119
View File
@@ -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 -3
View File
@@ -52,15 +52,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
Task { Task {
for await session in socketController.sessions { for await session in socketController.sessions {
Task { Task {
let inputParser = try await XPCAgentInputParser()
do { do {
let inputParser = try await XPCAgentInputParser()
let hostsReader = try? await XPCHostsfileReader()
let hosts = try? await hostsReader?.read()
for await message in session.messages { for await message in session.messages {
let request = try await inputParser.parse(data: message) let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance) let agentResponse = await agent.handle(request: request, provenance: session.provenance, hosts: hosts)
try session.write(agentResponse) try session.write(agentResponse)
} }
} catch { } catch {
try session.close() try? session.close()
} }
} }
} }
@@ -2,7 +2,7 @@ import Foundation
import Security import Security
import CryptoTokenKit import CryptoTokenKit
import CryptoKit import CryptoKit
import os import OSLog
import SSHProtocolKit import SSHProtocolKit
import CertificateKit import CertificateKit
import SharedXPCServices import SharedXPCServices
+35 -27
View File
@@ -5,6 +5,8 @@ import SecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
typealias PersistAction = (@Sendable (AnySecret, TimeInterval) async throws -> Void)
final class Notifier: Sendable { final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate() private let notificationDelegate = NotificationDelegate()
@@ -15,6 +17,12 @@ final class Notifier: Sendable {
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: []) let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], 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 = [ let rawDurations = [
Measurement(value: 1, unit: UnitDuration.minutes), Measurement(value: 1, unit: UnitDuration.minutes),
Measurement(value: 5, 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: []) let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
var allPersistenceActions = [doNotPersistAction] var allPersistenceActions = [doNotPersistAction]
let formatter = DateComponentsFormatter() let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day] formatter.allowedUnits = [.hour, .minute, .day]
var identifiers: [String: TimeInterval] = [:] var identifiers: [String: TimeInterval] = [:]
for duration in rawDurations { for duration in rawDurations {
let seconds = duration.converted(to: .seconds).value let seconds = duration.converted(to: .seconds).value
@@ -43,16 +49,11 @@ final class Notifier: Sendable {
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) { if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle") persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
} }
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) var categories = await UNUserNotificationCenter.current().notificationCategories()
UNUserNotificationCenter.current().delegate = notificationDelegate categories.insert(persistAuthenticationCategory)
UNUserNotificationCenter.current().setNotificationCategories(categories)
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)
}
}
await notificationDelegate.state.setPersistenceState(options: identifiers, action: action)
} }
func prompt() { func prompt() {
@@ -60,16 +61,23 @@ final class Notifier: Sendable {
notificationCenter.requestAuthorization(options: .alert) { _, _ in } notificationCenter.requestAuthorization(options: .alert) { _, _ in }
} }
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) 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) await notificationDelegate.state.setPending(secret: secret, store: store)
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName)) notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name)) switch target {
case .connection(let payload) where payload.host != nil:
notificationContent.subtitle = String(localized: .signedConnectionNotificationDescription(secretName: secret.name, username: payload.username, host: payload.host!))
case .signature(let payload):
notificationContent.subtitle = String(localized: .signedSignatureNotificationDescription(secretName: secret.name, namespace: payload.namespace))
default:
notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name))
}
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive notificationContent.interruptionLevel = .timeSensitive
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required { if offerPersistence {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
} }
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
@@ -100,11 +108,16 @@ final class Notifier: Sendable {
extension Notifier: SigningWitness { extension Notifier: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { func speakNowOrForeverHoldYourPeace(
forAccessTo secret: AnySecret,
from store: AnySecretStore,
by provenance: SigningRequestProvenance,
target: SigningRequestTarget?
) async throws {
} }
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { 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) await notify(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: offerPersistence)
} }
} }
@@ -133,28 +146,24 @@ extension Notifier {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
fileprivate actor State { fileprivate actor State {
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias IgnoreAction = (@Sendable (Release) async -> Void) typealias IgnoreAction = (@Sendable (Release) async -> Void)
fileprivate var release: Release? fileprivate var release: Release?
fileprivate var ignoreAction: IgnoreAction? fileprivate var ignoreAction: IgnoreAction?
fileprivate var persistAction: PersistAction? fileprivate var persistAction: PersistAction?
fileprivate var persistOptions: [String: TimeInterval] = [:] fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
func setPending(secret: AnySecret, store: AnySecretStore) { func setPending(secret: AnySecret, store: AnySecretStore) {
pendingPersistableSecrets[secret.id.description] = secret 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], guard let secret = pendingPersistableSecrets[secretID],
let store = pendingPersistableStores[storeID],
let options = persistOptions[optionID] else { let options = persistOptions[optionID] else {
return nil return nil
} }
pendingPersistableSecrets.removeValue(forKey: secretID) pendingPersistableSecrets.removeValue(forKey: secretID)
return (secret, store, options) return (secret, options)
} }
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) { func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
@@ -202,13 +211,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
} }
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async { func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String else {
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
return return
} }
let optionID = response.actionIdentifier let optionID = response.actionIdentifier
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return } guard let (secret, persistOptions) = await state.retrievePending(secretID: secretID, optionID: optionID) else { return }
await state.persistAction?(secret, store, persistOptions) 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)
}
}
+3 -3
View File
@@ -13,13 +13,13 @@
<key>com.apple.security.hardened-process.dyld-ro</key> <key>com.apple.security.hardened-process.dyld-ro</key>
<true/> <true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key> <key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string> <string>2</string>
<key>com.apple.security.hardened-process.hardened-heap</key> <key>com.apple.security.hardened-process.hardened-heap</key>
<true/> <true/>
<key>com.apple.security.smartcard</key>
<true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key> <key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string> <string>2</string>
<key>com.apple.security.smartcard</key>
<true/>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string> <string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>
@@ -0,0 +1,36 @@
import Foundation
import OSLog
import XPCWrappers
import OSLog
public final class XPCHostsfileReader {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "XPCHostsfileReader")
private let session: XPCTypedSession<[Data: String], HostsfileReaderError>
public init() async throws {
logger.debug("Creating XPCHostsfileReader")
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentHostsfileReader", warmup: true)
logger.debug("XPCHostsfileReader is warmed up.")
}
public func read() async throws -> [Data: String] {
logger.debug("Reading hosts file")
defer { logger.debug("Read hosts file") }
return try await session.send(Data())
}
deinit {
session.complete()
}
}
extension XPCHostsfileReader {
public enum HostsfileReaderError: Error, Codable {
case fileDoesNotExist
case parseError
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>XPCService</key>
<dict>
<key>ServiceType</key>
<string>Application</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>2</string>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
</dict>
</plist>
@@ -0,0 +1,25 @@
import Foundation
import OSLog
import XPCWrappers
import SSHProtocolKit
final class SecretAgentHostsfileReader: NSObject, XPCProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretAgentHostsfileReader", category: "SecretAgentHostsfileReader")
func process(_ data: Data) async throws -> [Data: String] {
logger.log("Parsing hostsfile")
var result: [Data: String] = [:]
for try await line in URL(filePath: NSHomeDirectory().appending("/.ssh/known_hosts")).lines {
let split = line.split(separator: " ").map(String.init)
guard split.count == 3 else { continue }
guard let decoded = Data(base64Encoded: split[2]) else { continue }
let reader = OpenSSHReader(data: decoded)
_ = try reader.readNextChunk()
let key = try reader.readNextChunk()
result[key] = split[0]
}
return result
}
}
@@ -0,0 +1,7 @@
import Foundation
import XPCWrappers
let delegate = XPCServiceDelegate(exportedObject: SecretAgentHostsfileReader())
let listener = NSXPCListener.service()
listener.delegate = delegate
listener.resume()
@@ -15,7 +15,7 @@
<key>com.apple.security.hardened-process.hardened-heap</key> <key>com.apple.security.hardened-process.hardened-heap</key>
<true/> <true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key> <key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string> <string>2</string>
<key>com.apple.security.hardened-process.platform-restrictions-string</key> <key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string> <string>2</string>
</dict> </dict>
File diff suppressed because it is too large Load Diff
+770
View File
@@ -0,0 +1,770 @@
{
"organization": "Max Goedjen",
"default-configuration": "Release",
"configurations": [
{ "name": "Debug", "file": "Config/Config.xcconfig" },
{ "name": "Release", "file": "Config/Config.xcconfig" },
{ "name": "Test", "file": "Config/Config.xcconfig" },
],
"localizations": {
"development": "en",
"supported": [
"ca",
"de",
"fi",
"fr",
"it",
"ko",
"pl",
"pt-BR",
"ru",
],
},
"files": [
{ "path": "Packages" },
{
"kind": "group",
"path": "Secretive",
"children": [
{ "path": "App.swift", "target-membership": [ "Secretive/compile-sources" ] },
{
"kind": "group",
"path": "Views",
"children": [
{
"kind": "group",
"path": "Configuration",
"children": [
{ "path": "ConfigurationItemView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "IntegrationsView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "Instructions.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "ToolConfigurationView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "SetupView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "GettingStartedView.swift", "target-membership": [ "Secretive/compile-sources" ] },
],
}, {
"kind": "group",
"path": "Secrets",
"children": [
{ "path": "CreateSecretView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "DeleteSecretView.swift", "encoding": "utf8", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "EditSecretView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "DeleteCertificateView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "EditCertificateView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "EmptyStoreView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "NoStoresView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "SecretDetailView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "CertificateDetailView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "SecretListItemView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "CertificateListItemView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "StoreListView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "SettingsView.swift", "target-membership": [ "Secretive/compile-sources" ] },
],
}, {
"kind": "group",
"path": "Modifiers",
"children": [
{ "path": "ErrorStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "BoxBackgroundStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "ToolbarButtonStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
],
}, {
"kind": "group",
"path": "Views",
"children": [
{ "path": "AboutView.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "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": "UpdateView.swift", "target-membership": [ "Secretive/compile-sources" ] },
],
},
],
}, {
"kind": "group",
"path": "Controllers",
"children": [
{ "path": "AgentStatusChecker.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "ApplicationDirectoryController.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "JustUpdatedChecker.swift", "target-membership": [ "Secretive/compile-sources" ] },
],
},
{ "path": "Info.plist" },
{ "path": "InternetAccessPolicy.plist", "encoding": "utf8", "target-membership": [ "Secretive/resources" ] },
{
"path": "AppIcon.icon",
"target-membership": [
"Secretive/resources",
"SecretAgent/resources",
],
},
{ "path": "Secretive.entitlements" },
{
"path": "<PROJECT>/Packages/Resources/Localizable.xcstrings",
"target-membership": [
"Secretive/resources",
"SecretAgent/resources",
],
}, {
"kind": "group",
"path": "Preview Content",
"children": [
{ "path": "Preview Assets.xcassets", "target-membership": [ "Secretive/resources" ] },
{ "path": "PreviewStore.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "PreviewUpdater.swift", "target-membership": [ "Secretive/compile-sources" ] },
{ "path": "PreviewAgentStatusChecker.swift", "target-membership": [ "Secretive/compile-sources" ] },
],
},
],
}, {
"kind": "group",
"path": "SecretAgent",
"children": [
{ "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" ] },
{
"kind": "variant-group",
"name": "Main.storyboard",
"target-membership": [
"SecretAgent/resources",
],
"children": [
{
"path": "en.lproj/Main.storyboard",
},
],
},
{ "path": "Info.plist" },
{ "path": "<PROJECT>/SecretAgent/InternetAccessPolicy.plist", "target-membership": [ "SecretAgent/resources" ] },
{ "path": "SecretAgent.entitlements" },
{
"kind": "group",
"path": "Preview Content",
"children": [
{ "path": "Preview Assets.xcassets", "target-membership": [ "SecretAgent/resources" ] },
],
},
],
}, {
"kind": "group",
"path": "Config",
"children": [
{ "path": "Secretive.xctestplan" },
{ "path": "Config.xcconfig" },
{ "path": "OpenSource.xcconfig" },
],
}, {
"kind": "group",
"path": "SecretiveUpdater",
"children": [
{ "path": "SecretiveUpdater.entitlements" },
{ "path": "Info.plist" },
{ "path": "InternetAccessPolicy.plist", "target-membership": [ "SecretiveUpdater/resources" ] },
{ "path": "main.swift", "target-membership": [ "SecretiveUpdater/compile-sources" ] },
{ "path": "SecretiveUpdater.swift", "target-membership": [ "SecretiveUpdater/compile-sources" ] },
],
}, {
"kind": "group",
"path": "SecretAgentInputParser",
"children": [
{ "path": "SecretAgentInputParser.entitlements" },
{ "path": "Info.plist" },
{ "path": "main.swift", "target-membership": [ "SecretAgentInputParser/compile-sources" ] },
{ "path": "SecretAgentInputParser.swift", "target-membership": [ "SecretAgentInputParser/compile-sources" ] },
],
}, {
"kind": "group",
"path": "SecretiveCertificateParser",
"children": [
{ "path": "Info.plist" },
{ "path": "main.swift", "target-membership": [ "SecretiveCertificateParser/compile-sources" ] },
{ "path": "SecretiveCertificateParser.entitlements" },
{ "path": "SecretiveCertificateParser.swift", "target-membership": [ "SecretiveCertificateParser/compile-sources" ] },
],
}, {
"kind": "group",
"path": "SecretAgentHostsfileReader",
"children": [
{ "path": "SecretAgentHostsfileReader.entitlements" },
{ "path": "Info.plist" },
{ "path": "main.swift", "target-membership": [ "SecretAgentHostsfileReader/compile-sources" ] },
{ "path": "SecretAgentHostsfileReader.swift", "target-membership": [ "SecretAgentHostsfileReader/compile-sources" ] },
],
}, {
"kind": "group",
"name": "Products",
"children": [
{ "path": "<PRODUCTS>/Secretive.app", "id": "50617D7F23FCE48E0099B055", "type": "wrapper.application", "index": false },
{ "path": "<PRODUCTS>/SecretAgent.app", "id": "50A3B78A24026B7500D209EA", "type": "wrapper.application", "index": false, "target-membership": [ { "build-phase": "Secretive/copy/Copy Files", "header-preservation": "remove-on-copy" } ] },
{
"path": "<PRODUCTS>/SecretiveUpdater.xpc",
"id": "50692D122E6FDB880043C7BB",
"type": "wrapper.xpc-service",
"index": false,
"target-membership": [
{ "build-phase": "Secretive/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
{ "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
],
}, {
"path": "<PRODUCTS>/SecretAgentInputParser.xpc",
"id": "50692E502E6FF9D20043C7BB",
"type": "wrapper.xpc-service",
"index": false,
"target-membership": [
{ "build-phase": "Secretive/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
{ "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
],
}, {
"path": "<PRODUCTS>/SecretiveCertificateParser.xpc",
"id": "50E205142FAAB81C00402380",
"type": "wrapper.xpc-service",
"index": false,
"target-membership": [
{ "build-phase": "Secretive/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
{ "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
],
},
{ "path": "<PRODUCTS>/SecretAgentHostsfileReader.xpc", "id": "5054028D3034B5E3000C3356", "type": "wrapper.xpc-service", "index": false, "target-membership": [ { "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" } ] },
],
},
{ "kind": "group", "name": "Frameworks" },
],
"targets": [
{
"name": "Secretive",
"id": "50617D7E23FCE48D0099B055",
"product": "Products/Secretive.app",
"product-type": "application",
"dependencies": [
"SecretAgent",
"SecretiveUpdater",
"SecretAgentInputParser",
"SecretiveCertificateParser",
],
"build-phases": [
"compile-sources",
"frameworks",
"resources",
{
"kind": "copy",
"name": "Embed Frameworks",
"bundle-base-path": "frameworks-directory",
}, {
"kind": "copy",
"name": "Copy Files",
"bundle-base-path": "root",
"relative-path": "Contents/Library/LoginItems",
}, {
"kind": "copy",
"name": "Embed XPC Services",
"bundle-base-path": "build-products-directory",
"relative-path": "$(CONTENTS_FOLDER_PATH)/XPCServices",
},
],
"package-product-members": [
{
"product-name": "Brief",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "CertificateKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "Common",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "Formatters",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SecretKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SecureEnclaveSecretKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SettingsKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SharedXPCServices",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SmartCardSecretKit",
"build-phase": { "build-phase": "frameworks" },
},
],
"build-settings": {
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
"CODE_SIGN_ENTITLEMENTS": "Secretive/Secretive.entitlements",
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
"CODE_SIGN_STYLE[config=Release]": "Manual",
"CODE_SIGN_STYLE[config=Test]": "Manual",
"COMBINE_HIDPI_IMAGES": "YES",
"CURRENT_PROJECT_VERSION": "1",
"DEAD_CODE_STRIPPING": "YES",
"DEVELOPMENT_ASSET_PATHS": "\"Secretive/Preview Content\"",
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"DEVELOPMENT_TEAM[config=Release]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"ENABLE_APP_SANDBOX": "YES",
"ENABLE_HARDENED_RUNTIME[config=Debug]": "YES",
"ENABLE_HARDENED_RUNTIME[config=Release]": "YES",
"ENABLE_HARDENED_RUNTIME[config=Test]": "NO",
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "NO",
"ENABLE_PREVIEWS": "YES",
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
"ENABLE_RESOURCE_ACCESS_PRINTING": "NO",
"ENABLE_RESOURCE_ACCESS_USB": "NO",
"INFOPLIST_FILE": "Secretive/Info.plist",
"LD_RUNPATH_SEARCH_PATHS": [
"$(inherited)",
"@executable_path/../Frameworks",
],
"MARKETING_VERSION": "1",
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).Host",
"PRODUCT_NAME": "$(TARGET_NAME)",
"PROVISIONING_PROFILE_SPECIFIER[config=Debug]": "",
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "Secretive - Host",
},
}, {
"name": "SecretAgent",
"id": "50A3B78924026B7500D209EA",
"product": "Products/SecretAgent.app",
"product-type": "application",
"dependencies": [
"SecretiveUpdater",
"SecretAgentInputParser",
"SecretiveCertificateParser",
"SecretAgentHostsfileReader",
],
"build-phases": [
"compile-sources",
"frameworks",
"resources",
{
"kind": "copy",
"name": "Embed Frameworks",
"bundle-base-path": "frameworks-directory",
}, {
"kind": "copy",
"name": "Embed XPC Services",
"bundle-base-path": "build-products-directory",
"relative-path": "$(CONTENTS_FOLDER_PATH)/XPCServices",
},
],
"package-product-members": [
{
"product-name": "Brief",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "Common",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SecretAgentKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SecretKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SecureEnclaveSecretKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SharedXPCServices",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SmartCardSecretKit",
"build-phase": { "build-phase": "frameworks" },
},
],
"build-settings": {
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
"AUTOMATION_APPLE_EVENTS": "NO",
"CODE_SIGN_ENTITLEMENTS": "SecretAgent/SecretAgent.entitlements",
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
"CODE_SIGN_STYLE[config=Release]": "Manual",
"CODE_SIGN_STYLE[config=Test]": "Manual",
"COMBINE_HIDPI_IMAGES": "YES",
"DEAD_CODE_STRIPPING": "YES",
"DEVELOPMENT_ASSET_PATHS": "\"SecretAgent/Preview Content\"",
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"DEVELOPMENT_TEAM[config=Release]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"ENABLE_APP_SANDBOX": "YES",
"ENABLE_HARDENED_RUNTIME": "YES",
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "NO",
"ENABLE_PREVIEWS": "YES",
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
"ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY": "NO",
"ENABLE_RESOURCE_ACCESS_PRINTING": "NO",
"ENABLE_RESOURCE_ACCESS_USB": "NO",
"INFOPLIST_FILE": "SecretAgent/Info.plist",
"LD_RUNPATH_SEARCH_PATHS": [
"$(inherited)",
"@executable_path/../Frameworks",
],
"MARKETING_VERSION": "1",
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretAgent",
"PRODUCT_NAME": "$(TARGET_NAME)",
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "Secretive - Secret Agent",
"RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES": "NO",
"RUNTIME_EXCEPTION_ALLOW_JIT": "NO",
"RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY": "NO",
"RUNTIME_EXCEPTION_DEBUGGING_TOOL": "NO",
"RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION": "NO",
"RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION": "NO",
},
}, {
"name": "SecretiveUpdater",
"id": "50692D112E6FDB880043C7BB",
"product": "Products/SecretiveUpdater.xpc",
"product-type": "xpc-service",
"dependencies": [
{ "kind": "package", "product-name": "Brief" },
{ "kind": "package", "product-name": "Formatters" },
{ "kind": "package", "product-name": "XPCWrappers" },
],
"build-phases": [
"compile-sources",
"frameworks",
"resources",
],
"package-product-members": [
{
"product-name": "Brief",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "Formatters",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "XPCWrappers",
"build-phase": { "build-phase": "frameworks" },
},
],
"build-settings": {
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
"CODE_SIGN_ENTITLEMENTS": "SecretiveUpdater/SecretiveUpdater.entitlements",
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
"CODE_SIGN_IDENTITY[config=Debug][sdk=macosx*]": "Apple Development",
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
"CODE_SIGN_STYLE[config=Release]": "Manual",
"CODE_SIGN_STYLE[config=Test]": "Automatic",
"COMBINE_HIDPI_IMAGES": "YES",
"CURRENT_PROJECT_VERSION": "1",
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"DEVELOPMENT_TEAM[config=Release]": "",
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"ENABLE_APP_SANDBOX": "YES",
"ENABLE_HARDENED_RUNTIME": "YES",
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
"ENABLE_RESOURCE_ACCESS_PRINTING": "NO",
"ENABLE_RESOURCE_ACCESS_USB": "NO",
"GCC_C_LANGUAGE_STANDARD": "gnu17",
"GENERATE_INFOPLIST_FILE": "YES",
"INFOPLIST_FILE": "SecretiveUpdater/Info.plist",
"INFOPLIST_KEY_CFBundleDisplayName": "SecretiveUpdater",
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2025 Max Goedjen. All rights reserved.",
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
"MARKETING_VERSION": "1.0",
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveUpdater",
"PRODUCT_NAME": "$(TARGET_NAME)",
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
"REGISTER_APP_GROUPS": "YES",
"SKIP_INSTALL": "YES",
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
"SWIFT_EMIT_LOC_STRINGS": "YES",
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
"SWIFT_VERSION": "5.0",
},
}, {
"name": "SecretiveCertificateParser",
"id": "50E205132FAAB81C00402380",
"product": "Products/SecretiveCertificateParser.xpc",
"product-type": "xpc-service",
"dependencies": [
{ "kind": "package", "product-name": "Formatters" },
{ "kind": "package", "product-name": "SSHProtocolKit" },
{ "kind": "package", "product-name": "XPCWrappers" },
],
"build-phases": [
"compile-sources",
"frameworks",
"resources",
],
"package-product-members": [
{
"product-name": "Formatters",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "SSHProtocolKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "XPCWrappers",
"build-phase": { "build-phase": "frameworks" },
},
],
"build-settings": {
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
"CODE_SIGN_ENTITLEMENTS": "SecretiveCertificateParser/SecretiveCertificateParser.entitlements",
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
"CODE_SIGN_STYLE[config=Release]": "Manual",
"CODE_SIGN_STYLE[config=Test]": "Automatic",
"COMBINE_HIDPI_IMAGES": "YES",
"CURRENT_PROJECT_VERSION": "1",
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"DEVELOPMENT_TEAM[config=Release]": "",
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "Z72PRUAWF6",
"ENABLE_APP_SANDBOX": "YES",
"ENABLE_HARDENED_RUNTIME": "YES",
"GCC_C_LANGUAGE_STANDARD": "gnu17",
"GENERATE_INFOPLIST_FILE": "YES",
"INFOPLIST_FILE": "SecretiveCertificateParser/Info.plist",
"INFOPLIST_KEY_CFBundleDisplayName": "SecretiveCertificateParser",
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2026 Max Goedjen. All rights reserved.",
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
"MARKETING_VERSION": "1.0",
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser",
"PRODUCT_NAME": "$(TARGET_NAME)",
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
"REGISTER_APP_GROUPS": "YES",
"SKIP_INSTALL": "YES",
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
"SWIFT_EMIT_LOC_STRINGS": "YES",
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
"SWIFT_VERSION": "5.0",
},
}, {
"name": "SecretAgentInputParser",
"id": "50692E4F2E6FF9D20043C7BB",
"product": "Products/SecretAgentInputParser.xpc",
"product-type": "xpc-service",
"dependencies": [
{ "kind": "package", "product-name": "SecretAgentKit" },
{ "kind": "package", "product-name": "XPCWrappers" },
],
"build-phases": [
"compile-sources",
"frameworks",
"resources",
],
"package-product-members": [
{
"product-name": "SecretAgentKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "XPCWrappers",
"build-phase": { "build-phase": "frameworks" },
},
],
"build-settings": {
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
"CODE_SIGN_ENTITLEMENTS": "SecretAgentInputParser/SecretAgentInputParser.entitlements",
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
"CODE_SIGN_STYLE[config=Release]": "Manual",
"CODE_SIGN_STYLE[config=Test]": "Automatic",
"COMBINE_HIDPI_IMAGES": "YES",
"CURRENT_PROJECT_VERSION": "1",
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"DEVELOPMENT_TEAM[config=Release]": "",
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"ENABLE_APP_SANDBOX": "YES",
"ENABLE_HARDENED_RUNTIME": "YES",
"GCC_C_LANGUAGE_STANDARD": "gnu17",
"GENERATE_INFOPLIST_FILE": "YES",
"INFOPLIST_FILE": "SecretAgentInputParser/Info.plist",
"INFOPLIST_KEY_CFBundleDisplayName": "SecretAgentInputParser",
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2025 Max Goedjen. All rights reserved.",
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
"MARKETING_VERSION": "1.0",
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretAgentInputParser",
"PRODUCT_NAME": "$(TARGET_NAME)",
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
"REGISTER_APP_GROUPS": "YES",
"SKIP_INSTALL": "YES",
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
"SWIFT_EMIT_LOC_STRINGS": "YES",
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
"SWIFT_VERSION": "5.0",
},
}, {
"name": "SecretAgentHostsfileReader",
"id": "5054028C3034B5E3000C3356",
"product": "Products/SecretAgentHostsfileReader.xpc",
"product-type": "xpc-service",
"dependencies": [
{ "kind": "package", "product-name": "SSHProtocolKit" },
{ "kind": "package", "product-name": "XPCWrappers" },
],
"build-phases": [
"compile-sources",
"frameworks",
"resources",
],
"package-product-members": [
{
"product-name": "SSHProtocolKit",
"build-phase": { "build-phase": "frameworks" },
}, {
"product-name": "XPCWrappers",
"build-phase": { "build-phase": "frameworks" },
},
],
"build-settings": {
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
"CODE_SIGN_ENTITLEMENTS": "SecretAgentHostsfileReader/SecretAgentHostsfileReader.entitlements",
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
"CODE_SIGN_STYLE[config=Release]": "Manual",
"CODE_SIGN_STYLE[config=Test]": "Automatic",
"COMBINE_HIDPI_IMAGES": "YES",
"CURRENT_PROJECT_VERSION": "1",
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
"DEVELOPMENT_TEAM[config=Release]": "",
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "Z72PRUAWF6",
"ENABLE_APP_SANDBOX": "NO",
"ENABLE_HARDENED_RUNTIME": "YES",
"GCC_C_LANGUAGE_STANDARD": "gnu17",
"GENERATE_INFOPLIST_FILE": "YES",
"INFOPLIST_FILE": "SecretAgentHostsfileReader/Info.plist",
"INFOPLIST_KEY_CFBundleDisplayName": "SecretAgentHostsfileReader",
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2026 Max Goedjen. All rights reserved.",
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
"MARKETING_VERSION": "1.0",
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretAgentHostsfileReader",
"PRODUCT_NAME": "$(TARGET_NAME)",
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
"REGISTER_APP_GROUPS": "YES",
"SKIP_INSTALL": "YES",
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
"SWIFT_EMIT_LOC_STRINGS": "YES",
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
"SWIFT_VERSION": "5.0",
},
},
],
"build-settings": {
"ALWAYS_SEARCH_USER_PATHS": "NO",
"ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS": "YES",
"CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED": "YES",
"CLANG_ANALYZER_NONNULL": "YES",
"CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION": "YES_AGGRESSIVE",
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++14",
"CLANG_CXX_LIBRARY": "libc++",
"CLANG_ENABLE_MODULES": "YES",
"CLANG_ENABLE_OBJC_ARC": "YES",
"CLANG_ENABLE_OBJC_WEAK": "YES",
"CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING": "YES",
"CLANG_WARN_BOOL_CONVERSION": "YES",
"CLANG_WARN_COMMA": "YES",
"CLANG_WARN_CONSTANT_CONVERSION": "YES",
"CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS": "YES",
"CLANG_WARN_DIRECT_OBJC_ISA_USAGE": "YES_ERROR",
"CLANG_WARN_DOCUMENTATION_COMMENTS": "YES",
"CLANG_WARN_EMPTY_BODY": "YES",
"CLANG_WARN_ENUM_CONVERSION": "YES",
"CLANG_WARN_INFINITE_RECURSION": "YES",
"CLANG_WARN_INT_CONVERSION": "YES",
"CLANG_WARN_NON_LITERAL_NULL_CONVERSION": "YES",
"CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF": "YES",
"CLANG_WARN_OBJC_LITERAL_CONVERSION": "YES",
"CLANG_WARN_OBJC_ROOT_CLASS": "YES_ERROR",
"CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER": "YES",
"CLANG_WARN_RANGE_LOOP_ANALYSIS": "YES",
"CLANG_WARN_STRICT_PROTOTYPES": "YES",
"CLANG_WARN_SUSPICIOUS_MOVE": "YES",
"CLANG_WARN_UNGUARDED_AVAILABILITY": "YES_AGGRESSIVE",
"CLANG_WARN_UNREACHABLE_CODE": "YES",
"CLANG_WARN__DUPLICATE_METHOD_MATCH": "YES",
"COPY_PHASE_STRIP": "NO",
"DEAD_CODE_STRIPPING": "YES",
"DEBUG_INFORMATION_FORMAT[config=Debug]": "dwarf",
"DEBUG_INFORMATION_FORMAT[config=Release]": "dwarf-with-dsym",
"DEBUG_INFORMATION_FORMAT[config=Test]": "dwarf",
"ENABLE_ENHANCED_SECURITY": "YES",
"ENABLE_NS_ASSERTIONS[config=Release]": "NO",
"ENABLE_POINTER_AUTHENTICATION": "YES",
"ENABLE_STRICT_OBJC_MSGSEND": "YES",
"ENABLE_TESTABILITY[config=Debug]": "YES",
"ENABLE_TESTABILITY[config=Test]": "YES",
"ENABLE_USER_SCRIPT_SANDBOXING": "YES",
"GCC_C_LANGUAGE_STANDARD": "gnu11",
"GCC_DYNAMIC_NO_PIC[config=Debug]": "NO",
"GCC_DYNAMIC_NO_PIC[config=Test]": "NO",
"GCC_NO_COMMON_BLOCKS": "YES",
"GCC_OPTIMIZATION_LEVEL[config=Debug]": "0",
"GCC_OPTIMIZATION_LEVEL[config=Test]": "0",
"GCC_PREPROCESSOR_DEFINITIONS[config=Debug]": [
"DEBUG=1",
"$(inherited)",
],
"GCC_PREPROCESSOR_DEFINITIONS[config=Test]": [
"DEBUG=1",
"$(inherited)",
],
"GCC_WARN_64_TO_32_BIT_CONVERSION": "YES",
"GCC_WARN_ABOUT_RETURN_TYPE": "YES_ERROR",
"GCC_WARN_UNDECLARED_SELECTOR": "YES",
"GCC_WARN_UNINITIALIZED_AUTOS": "YES_AGGRESSIVE",
"GCC_WARN_UNUSED_FUNCTION": "YES",
"GCC_WARN_UNUSED_VARIABLE": "YES",
"MACOSX_DEPLOYMENT_TARGET": "15.0",
"MTL_ENABLE_DEBUG_INFO[config=Debug]": "INCLUDE_SOURCE",
"MTL_ENABLE_DEBUG_INFO[config=Release]": "NO",
"MTL_ENABLE_DEBUG_INFO[config=Test]": "INCLUDE_SOURCE",
"MTL_FAST_MATH": "YES",
"ONLY_ACTIVE_ARCH[config=Debug]": "YES",
"ONLY_ACTIVE_ARCH[config=Test]": "YES",
"OTHER_SWIFT_FLAGS": "",
"SDKROOT": "macosx",
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
"STRIP_INSTALLED_PRODUCT": "NO",
"STRIP_SWIFT_SYMBOLS": "NO",
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG",
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Test]": "DEBUG",
"SWIFT_COMPILATION_MODE[config=Release]": "wholemodule",
"SWIFT_EMIT_LOC_STRINGS": "YES",
"SWIFT_OPTIMIZATION_LEVEL[config=Debug]": "-Onone",
"SWIFT_OPTIMIZATION_LEVEL[config=Release]": "-O",
"SWIFT_OPTIMIZATION_LEVEL[config=Test]": "-Onone",
"SWIFT_STRICT_MEMORY_SAFETY": "YES",
"SWIFT_TREAT_WARNINGS_AS_ERRORS": "YES",
"SWIFT_VERSION": "6.0",
},
"last-upgrade": "27.0",
"last-swift-update": "27.0",
}
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>iOSPackagesShouldBuildARM64e</key> <key>iOSPackagesShouldBuildARM64e</key>
<true/> <true />
</dict> <key>macOSPackagesShouldBuildARM64e</key>
</plist> <true />
</dict>
</plist>
@@ -87,6 +87,9 @@
ReferencedContainer = "container:Secretive.xcodeproj"> ReferencedContainer = "container:Secretive.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<MetalAPIValidationSettings
isEnabled = "No">
</MetalAPIValidationSettings>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
+14 -4
View File
@@ -4,10 +4,11 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit import SmartCardSecretKit
import Brief import Brief
import CertificateKit import CertificateKit
import SettingsKit
@main @main
struct Secretive: App { struct Secretive: App {
@Environment(\.agentLaunchController) var agentLaunchController @Environment(\.agentLaunchController) var agentLaunchController
@Environment(\.justUpdatedChecker) var justUpdatedChecker @Environment(\.justUpdatedChecker) var justUpdatedChecker
@@ -16,6 +17,7 @@ struct Secretive: App {
ContentView() ContentView()
.environment(EnvironmentValues._secretStoreList) .environment(EnvironmentValues._secretStoreList)
.environment(EnvironmentValues._certificateStore) .environment(EnvironmentValues._certificateStore)
.environment(EnvironmentValues._settingsStore)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task { Task {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false @AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@@ -25,7 +27,7 @@ struct Secretive: App {
guard !agentLaunchController.developmentBuild else { return } guard !agentLaunchController.developmentBuild else { return }
if justUpdatedChecker.justUpdatedBuild || !agentLaunchController.running { if justUpdatedChecker.justUpdatedBuild || !agentLaunchController.running {
// Relaunch the agent, since it'll be running from earlier update still // Relaunch the agent, since it'll be running from earlier update still
try await agentLaunchController.forceLaunch() try? await agentLaunchController.forceLaunch()
} }
} }
} }
@@ -42,6 +44,9 @@ struct Secretive: App {
} }
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize) .windowResizability(.contentSize)
Settings {
SettingsView()
}
} }
} }
@@ -101,8 +106,9 @@ extension EnvironmentValues {
return list return list
}() }()
@MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore() @MainActor fileprivate static let _certificateStore = CertificateStore()
@MainActor fileprivate static let _settingsStore = SettingsStore()
private static let _agentLaunchController = AgentLaunchController() private static let _agentLaunchController = AgentLaunchController()
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController @Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
@@ -122,6 +128,10 @@ extension EnvironmentValues {
@MainActor var certificateStore: CertificateStore { @MainActor var certificateStore: CertificateStore {
EnvironmentValues._certificateStore EnvironmentValues._certificateStore
} }
@MainActor var settingsStore: SettingsStore {
EnvironmentValues._settingsStore
}
} }
extension FocusedValues { extension FocusedValues {
@@ -1,5 +1,6 @@
import Foundation import Foundation
import SecretKit import SecretKit
import LocalAuthentication
enum Preview {} enum Preview {}
@@ -38,17 +39,10 @@ extension Preview {
self.init(secrets: new) self.init(secrets: new)
} }
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data { func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
return data return data
} }
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil
}
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
}
func reloadSecrets() { func reloadSecrets() {
} }
@@ -82,16 +76,10 @@ extension Preview {
self.init(secrets: new) self.init(secrets: new)
} }
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data { func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
return data return data
} }
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil
}
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
}
func reloadSecrets() { func reloadSecrets() {
} }
+1 -1
View File
@@ -15,7 +15,7 @@
<key>com.apple.security.hardened-process.hardened-heap</key> <key>com.apple.security.hardened-process.hardened-heap</key>
<true/> <true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key> <key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string> <string>2</string>
<key>com.apple.security.smartcard</key> <key>com.apple.security.smartcard</key>
<true/> <true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key> <key>com.apple.security.hardened-process.platform-restrictions-string</key>
@@ -28,8 +28,8 @@ struct IntegrationsView: View {
dismiss() dismiss()
} }
} }
.hiddenToolbar() .toolbarBackgroundVisibility(.hidden, for: .automatic)
.windowBackgroundStyle(.thinMaterial) .containerBackground(.thinMaterial, for: .window)
.onAppear { .onAppear {
selectedInstruction = instructions.gettingStarted selectedInstruction = instructions.gettingStarted
} }
@@ -1,47 +0,0 @@
import SwiftUI
struct WindowBackgroundStyleModifier: ViewModifier {
let shapeStyle: any ShapeStyle
func body(content: Content) -> some View {
if #available(macOS 15.0, *) {
content
.containerBackground(
shapeStyle, for: .window
)
} else {
content
}
}
}
extension View {
func windowBackgroundStyle(_ style: some ShapeStyle) -> some View {
modifier(WindowBackgroundStyleModifier(shapeStyle: style))
}
}
struct HiddenToolbarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(macOS 15.0, *) {
content
.toolbarBackgroundVisibility(.hidden, for: .automatic)
} else {
content
}
}
}
extension View {
func hiddenToolbar() -> some View {
modifier(HiddenToolbarModifier())
}
}
@@ -28,6 +28,13 @@ struct SecretDetailView<SecretType: Secret>: View {
image: Image(systemName: "touchid"), image: Image(systemName: "touchid"),
text: keyWriter.openSSHMD5Fingerprint(secret: secret) text: keyWriter.openSSHMD5Fingerprint(secret: secret)
) )
Spacer()
.frame(height: 20)
CopyableView(
title: .secretDetailPublicKeyLabel,
image: Image(systemName: "key"),
text: keyWriter.openSSHString(secret: secret)
)
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
CopyableView( CopyableView(
@@ -39,18 +46,14 @@ struct SecretDetailView<SecretType: Secret>: View {
if !certificates.isEmpty { if !certificates.isEmpty {
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
MultilineInfoView( MultilineInfoView(title: .secretDetailCertificatePathLabel, image: Image(systemName: "checkmark.seal.text.page")) {
title: .secretDetailCertificatePathLabel, ForEach(certificates) { certificate in
image: Image( Text(certificate.name)
systemName: "checkmark.seal.text.page" .multilineItemAction(image: Image(systemName: "chevron.forward")) {
), navigateToCertificate?(certificate)
items: certificates.map({ certificate in }
MultilineInfoView.Item( }
text: certificate.name, }
action: (Image(systemName: "chevron.forward"), { navigateToCertificate?(certificate) })
)
})
)
} }
Spacer() Spacer()
} }
@@ -0,0 +1,14 @@
import SwiftUI
import SettingsKit
struct SettingsView: View {
@Environment(\.settingsStore) var settingsStore
var body: some View {
Form {
}
.padding()
.frame(minWidth: 480, minHeight: 320)
}
}
@@ -44,7 +44,7 @@ struct StoreListView: View {
} }
} }
if !certificateStore.certificates.isEmpty { if !certificateStore.certificates.isEmpty {
Section("Certificates") { Section(.certificatesSectionTitle) {
ForEach(certificateStore.certificates) { certificate in ForEach(certificateStore.certificates) { certificate in
CertificateListItemView( CertificateListItemView(
certificate: certificate, certificate: certificate,
@@ -2,14 +2,10 @@ import SwiftUI
struct AboutView: View { struct AboutView: View {
var body: some View { var body: some View {
if #available(macOS 15.0, *) { AboutViewContent()
AboutViewContent() .containerBackground(
.containerBackground( .thinMaterial, for: .window
.thinMaterial, for: .window )
)
} else {
AboutViewContent()
}
} }
} }
@@ -101,7 +101,7 @@ struct AgentNotRunningView: View {
guard !loading else { return } guard !loading else { return }
loading = true loading = true
Task { Task {
try await agentLaunchController.forceLaunch() try? await agentLaunchController.forceLaunch()
loading = false loading = false
if !agentLaunchController.running { if !agentLaunchController.running {
@@ -163,8 +163,7 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
} else { } else {
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
content content
// Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow. .contentShape(RoundedRectangle(cornerRadius: 15))
.background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15))
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: 15)) .glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: 15))
.mask(RoundedRectangle(cornerRadius: 15)) .mask(RoundedRectangle(cornerRadius: 15))
.shadow(color: .black.opacity(0.1), radius: 5) .shadow(color: .black.opacity(0.1), radius: 5)
@@ -179,10 +178,15 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
func backgroundColor(interactionState: InteractionState) -> Color { func backgroundColor(interactionState: InteractionState) -> Color {
guard appearsActive else { return Color.clear } guard appearsActive else { return Color.clear }
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
let base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1) let base: Color
if #available(macOS 27.0, *) {
base = .clear
} else {
base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1)
}
switch interactionState { switch interactionState {
case .normal: case .normal:
return base return base.mix(with: .accentColor, by: 0)
case .hovering: case .hovering:
return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1) return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1)
case .clicking, .dragging: case .clicking, .dragging:
@@ -1,167 +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)
._background(interactionState: interactionStateIndex == item.offset ? interactionState : .normal, cornerRadius: 0)
.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
// Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow.
.background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15))
.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 = 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()
}
@@ -12,11 +12,11 @@
<true/> <true/>
<key>com.apple.security.hardened-process.dyld-ro</key> <key>com.apple.security.hardened-process.dyld-ro</key>
<true/> <true/>
<key>com.apple.security.hardened-process.enhanced-security-version</key> <key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<integer>1</integer> <string>2</string>
<key>com.apple.security.hardened-process.hardened-heap</key> <key>com.apple.security.hardened-process.hardened-heap</key>
<true/> <true/>
<key>com.apple.security.hardened-process.platform-restrictions</key> <key>com.apple.security.hardened-process.platform-restrictions-string</key>
<integer>2</integer> <string>2</string>
</dict> </dict>
</plist> </plist>
@@ -13,7 +13,7 @@
<key>com.apple.security.hardened-process.dyld-ro</key> <key>com.apple.security.hardened-process.dyld-ro</key>
<true/> <true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key> <key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string> <string>2</string>
<key>com.apple.security.hardened-process.hardened-heap</key> <key>com.apple.security.hardened-process.hardened-heap</key>
<true/> <true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key> <key>com.apple.security.hardened-process.platform-restrictions-string</key>