Compare commits

..

22 Commits

Author SHA1 Message Date
Max Goedjen
b2c294211a Symlink to Package.swift 2025-08-23 15:50:38 -07:00
Max Goedjen
e3938caecb Remove unused verify functions. (#621) 2025-08-23 22:26:40 +00:00
Max Goedjen
bd096c3012 Add attestation info to readme (#620)
* Update README.md

* Enhance README with attestation visibility details

* Update README to clarify build process and attestations
2025-08-23 22:07:09 +00:00
Max Goedjen
2355d3f989 Use the symlink (#619) 2025-08-21 05:20:40 +00:00
Max Goedjen
45bcb03fef Enable enhanced security. (#618) 2025-08-20 07:10:23 +00:00
Max Goedjen
e86aa559a4 Remove unchecked sendable (#617) 2025-08-20 06:32:46 +00:00
Max Goedjen
d36537b919 Release and attestation tweaks (#616)
* Abs path

* Write.

* Pass attestation.

* Attest nightly
2025-08-19 23:27:58 -07:00
Max Goedjen
8adb4423ac Release management using gh cli (#615) 2025-08-19 07:24:22 +00:00
Max Goedjen
8dbf992cce Add attestation (#614) 2025-08-19 07:07:43 +00:00
Max Goedjen
f382d72ee5 Update localization status (#613) 2025-08-18 03:31:53 +00:00
Max Goedjen
9749cd6f3e Switch to generated localized string symbols (#607)
* Switch to string symbols

* Names

* Cleanup packages

* Cleanup packages

* Remove namespace

* More cleanup

* Fix extra param.

* Use swiftbuild
2025-08-18 03:26:13 +00:00
Max Goedjen
83ecc15332 Dim cells on background (#612) 2025-08-18 03:19:13 +00:00
Max Goedjen
ecd001a082 Update (#611) 2025-08-18 02:42:23 +00:00
Max Goedjen
eeec2c3169 Update icon for macOS 26 (#610)
* Icons.

* .

* Icon

* Icon composer

* Update readme.
2025-08-18 01:37:57 +00:00
Max Goedjen
609adf04bf Add glass appearance on macOS 26 (#609)
* Accessibility.

* Cleanup
2025-08-17 20:45:07 +00:00
Max Goedjen
197f63d1eb Use more modern warnings as errors setting (#608) 2025-08-17 20:36:09 +00:00
Alexander Pushkov
efe5858ce1 Update Credits.rtf link (#602) 2025-08-17 17:49:30 +00:00
Max Goedjen
ed2678a615 Toolbar style (#606) 2025-08-17 17:44:57 +00:00
Max Goedjen
0e6b218f1f Swift 6 / Concurrency fixes (#578)
* Enable language mode

* WIP

* WIP

* Fix concurrency issues in SmartCardStore

* Switch to SMAppService

* Bump runners

* Base

* Finish Testing migration

* Tweak async for updater

* More

* Backport mutex

* Revert "Backport mutex"

This reverts commit 9b02afb20c.

* WIP

* Reenable

* Fix preview.

* Update package.

* Bump to latest public macOS and Xcode

* Bump back down to 6.1

* Update to Xcode 26.

* Fixed tests.

* More cleanup

* Env fixes

* var->let

* Cleanup

* Persist auth async

* Whitespace.

* Whitespace.

* Cleanup.

* Cleanup

* Redoing locks in actors bc of observable

* Actors.

* .

* Specify b5

* Update package to 6.2

* Fix disabled updater

* Remove preconcurrency warning

* Move updater init
2025-08-17 12:38:18 -05:00
Nathan Manceaux-Panot
5cc62b628a Add Retcon instructions to app config FAQ (#588) 2025-08-17 17:17:17 +00:00
Vladimir
feffdb4856 Add Russian localization (#553) 2025-08-17 17:16:18 +00:00
Adam Maciejczuk
c7d28678e7 add Polish localization (#585) 2025-08-17 17:13:32 +00:00
77 changed files with 2002 additions and 1113 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

16
.github/templates/release.md vendored Normal file
View File

@@ -0,0 +1,16 @@
Update description
## Features
## Fixes
## Minimum macOS Version
## Build
https://github.com/maxgoedjen/secretive/actions/runs/RUN_ID
## Attestation
https://github.com/maxgoedjen/secretive/attestations/ATTESTATION_ID

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup Signing - name: Setup Signing
env: env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }} SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -20,7 +20,7 @@ jobs:
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 - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Update Build Number - name: Update Build Number
env: env:
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}
@@ -39,14 +39,11 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Document SHAs - name: Attest
run: | id: attest
echo "sha-512:" uses: actions/attest-build-provenance@v2
shasum -a 512 Secretive.zip with:
shasum -a 512 Archive.zip subject-path: 'Secretive.zip'
echo "sha-256:"
shasum -a 256 Secretive.zip
shasum -a 256 Archive.zip
- name: Upload App to Artifacts - name: Upload App to Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup Signing - name: Setup Signing
env: env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }} SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -21,18 +21,19 @@ jobs:
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 - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Test - name: Test
run: | run: swift build --build-system swiftbuild --package-path Sources/Packages
pushd Sources/Packages
swift test
popd
build: build:
# runs-on: macOS-latest # runs-on: macOS-latest
runs-on: macos-15 runs-on: macos-15
permissions:
id-token: write
contents: write
attestations: write
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup Signing - name: Setup Signing
env: env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }} SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -43,7 +44,7 @@ jobs:
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 - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Update Build Number - name: Update Build Number
env: env:
TAG_NAME: ${{ github.ref }} TAG_NAME: ${{ github.ref }}
@@ -58,54 +59,29 @@ jobs:
- name: Create ZIPs - name: Create ZIPs
run: | run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Xcode_Archive.zip
- name: Notarize - name: Notarize
env: env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Document SHAs - name: Attest
run: | id: attest
echo "sha-512:" uses: actions/attest-build-provenance@v2
shasum -a 512 Secretive.zip with:
shasum -a 512 Archive.zip subject-path: 'Secretive.zip, Xcode_Archive.zip'
echo "sha-256:"
shasum -a 256 Secretive.zip
shasum -a 256 Archive.zip
- name: Create Release - name: Create Release
id: create_release run: |
uses: actions/create-release@v1 sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
gh release create $TAG_NAME -d -F .github/templates/release.md
gh release upload Secretive.zip
gh release upload Xcode_Archive.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: TAG_NAME: ${{ github.ref }}
tag_name: ${{ github.ref }} RUN_ID: ${{ github.run_id }}
release_name: ${{ github.ref }} ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
body: |
Update description
## Features
## Fixes
## Minimum macOS Version
## Build
https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }}
draft: true
prerelease: false
- name: Upload App to Release
id: upload-release-asset-app
uses: actions/upload-release-asset@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./Secretive.zip
asset_name: Secretive.zip
asset_content_type: application/zip
- name: Upload App to Artifacts - name: Upload App to Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -115,4 +91,4 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: Xcode_Archive.zip name: Xcode_Archive.zip
path: Archive.zip path: Xcode_Archive.zip

View File

@@ -7,11 +7,8 @@ jobs:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set Environment - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Test - name: Test
run: | run: swift build --build-system swiftbuild --package-path Sources/Packages
pushd Sources/Packages
swift test
popd

View File

@@ -110,6 +110,15 @@ Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH) Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
## Retcon
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
# The app I use isn't listed here! # The app I use isn't listed here!
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome. If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.

View File

@@ -20,7 +20,7 @@ If you'd like to contribute a translation, please see [Localizing](LOCALIZING.md
## Credits ## Credits
If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Secretive/Credits.rtf). If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Sources/Secretive/Credits.rtf).
## Collaborator Status ## Collaborator Status

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,59 @@
{
"fill" : {
"solid" : "srgb:0.00000,0.53333,1.00000,0.00000"
},
"groups" : [
{
"blur-material" : 0.5,
"layers" : [
{
"image-name" : "Icon 7.png",
"name" : "Signature",
"position" : {
"scale" : 1,
"translation-in-points" : [
64.00083178971097,
-58.21801551632592
]
}
},
{
"image-name" : "Rectangle Copy 10.png",
"name" : "Border"
},
{
"fill-specializations" : [
{
"appearance" : "tinted",
"value" : {
"solid" : "display-p3:0.00000,0.00000,0.00000,0.50000"
}
}
],
"image-name" : "Rectangle 2 8.png",
"name" : "Backing",
"opacity-specializations" : [
{
"appearance" : "tinted",
"value" : 1
}
]
}
],
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}

1
Package.swift Symbolic link
View File

@@ -0,0 +1 @@
Sources/Packages/Package.swift

View File

@@ -49,7 +49,7 @@ There's a [FAQ here](FAQ.md).
### Auditable Build Process ### Auditable Build Process
Builds are produced by GitHub Actions with an auditable build and release generation process. Each build has a "Document SHAs" step, which will output SHA checksums for the build produced by the GitHub Action, so you can verify that the source code for a given build corresponds to any given release. Builds are produced by GitHub Actions with an auditable build and release generation process. Starting with Secretive 3.0, builds are attested using [GitHub Artifact Attestation](https://docs.github.com/en/actions/concepts/security/artifact-attestations). Attestations are viewable in the build log for a build, and also on the [main attestation page](https://github.com/maxgoedjen/secretive/attestations).
### A Note Around Code Signing and Keychains ### A Note Around Code Signing and Keychains

View File

@@ -1,10 +1,11 @@
// swift-tools-version:6.1 // swift-tools-version:6.2
// 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
let package = Package( let package = Package(
name: "SecretivePackages", name: "SecretivePackages",
defaultLocalization: "en",
platforms: [ platforms: [
.macOS(.v14) .macOS(.v14)
], ],
@@ -27,16 +28,14 @@ let package = Package(
.library( .library(
name: "Brief", name: "Brief",
targets: ["Brief"]), targets: ["Brief"]),
.library(
name: "Common",
targets: ["Common"]),
], ],
dependencies: [ dependencies: [
], ],
targets: [ targets: [
.target( .target(
name: "SecretKit", name: "SecretKit",
dependencies: ["Common"], dependencies: [],
resources: [localization],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.testTarget( .testTarget(
@@ -46,17 +45,20 @@ let package = Package(
), ),
.target( .target(
name: "SecureEnclaveSecretKit", name: "SecureEnclaveSecretKit",
dependencies: ["Common", "SecretKit"], dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.target( .target(
name: "SmartCardSecretKit", name: "SmartCardSecretKit",
dependencies: ["Common", "SecretKit"], dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.target( .target(
name: "SecretAgentKit", name: "SecretAgentKit",
dependencies: ["Common", "SecretKit", "SecretAgentKitHeaders"], dependencies: ["SecretKit", "SecretAgentKitHeaders"],
resources: [localization],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.systemLibrary( .systemLibrary(
@@ -68,24 +70,24 @@ let package = Package(
, ,
.target( .target(
name: "Brief", name: "Brief",
dependencies: ["Common"], dependencies: [],
resources: [localization],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.testTarget( .testTarget(
name: "BriefTests", name: "BriefTests",
dependencies: ["Brief"] dependencies: ["Brief"]
), ),
.target(
name: "Common",
dependencies: [],
swiftSettings: swiftSettings
),
] ]
) )
var localization: Resource {
.process("../../Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] { var swiftSettings: [PackageDescription.SwiftSetting] {
[ [
.swiftLanguageMode(.v6), .swiftLanguageMode(.v6),
.unsafeFlags(["-warnings-as-errors"]) .treatAllWarnings(as: .error),
] ]
} }

View File

@@ -1,15 +1,18 @@
import Foundation import Foundation
import Observation import Observation
import os
import Common
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version. /// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
@Observable public final class Updater: UpdaterProtocol, ObservableObject, Sendable { @Observable public final class Updater: UpdaterProtocol, Sendable {
public var update: Release? { private let state = State()
_update.lockedValue @MainActor @Observable public final class State {
var update: Release? = nil
nonisolated init() {}
} }
private let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil) public var update: Release? {
state.update
}
public let testBuild: Bool public let testBuild: Bool
/// The current OS version. /// The current OS version.
@@ -23,7 +26,12 @@ import Common
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour. /// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
/// - osVersion: The current OS version. /// - osVersion: The current OS version.
/// - currentVersion: The current version of the app that is running. /// - currentVersion: The current version of the app that is running.
public init(checkOnLaunch: Bool, checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) { public init(
checkOnLaunch: Bool,
checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value,
osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion),
currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")
) {
self.osVersion = osVersion self.osVersion = osVersion
self.currentVersion = currentVersion self.currentVersion = currentVersion
testBuild = currentVersion == SemVer("0.0.0") testBuild = currentVersion == SemVer("0.0.0")
@@ -54,7 +62,7 @@ import Common
guard !release.critical else { return } guard !release.critical else { return }
defaults.set(true, forKey: release.name) defaults.set(true, forKey: release.name)
await MainActor.run { await MainActor.run {
_update.lockedValue = nil state.update = nil
} }
} }
@@ -75,7 +83,7 @@ extension Updater {
let latestVersion = SemVer(release.name) let latestVersion = SemVer(release.name)
if latestVersion > currentVersion { if latestVersion > currentVersion {
await MainActor.run { await MainActor.run {
_update.lockedValue = release state.update = release
} }
} }
} }

View File

@@ -1,13 +1,13 @@
import Foundation import Foundation
import os
/// A protocol for retreiving the latest available version of an app. /// A protocol for retreiving the latest available version of an app.
public protocol UpdaterProtocol: Observable { public protocol UpdaterProtocol: Observable, Sendable {
/// The latest update /// The latest update
var update: Release? { get } @MainActor var update: Release? { get }
/// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build) /// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
var testBuild: Bool { get } var testBuild: Bool { get }
func ignore(release: Release) async
} }

View File

@@ -1,14 +0,0 @@
import os
public extension OSAllocatedUnfairLock where State: Sendable {
var lockedValue: State {
get {
withLock { $0 }
}
nonmutating set {
withLock { $0 = newValue }
}
}
}

View File

@@ -0,0 +1 @@

View File

@@ -22,7 +22,9 @@ public final class Agent: Sendable {
logger.debug("Agent is running") logger.debug("Agent is running")
self.storeList = storeList self.storeList = storeList
self.witness = witness self.witness = witness
certificateHandler.reloadCertificates(for: storeList.allSecrets) Task { @MainActor in
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
}
} }
} }
@@ -60,7 +62,7 @@ extension Agent {
switch requestType { switch requestType {
case .requestIdentities: case .requestIdentities:
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data) response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
response.append(identities()) response.append(await identities())
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)") logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
case .signRequest: case .signRequest:
let provenance = requestTracer.provenance(from: reader) let provenance = requestTracer.provenance(from: reader)
@@ -83,9 +85,9 @@ extension Agent {
/// Lists the identities available for signing operations /// Lists the identities available for signing operations
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() -> Data { func identities() async -> Data {
let secrets = storeList.allSecrets let secrets = await storeList.allSecrets
certificateHandler.reloadCertificates(for: secrets) await certificateHandler.reloadCertificates(for: secrets)
var count = secrets.count var count = secrets.count
var keyData = Data() var keyData = Data()
@@ -95,7 +97,7 @@ extension Agent {
keyData.append(writer.lengthAndData(of: keyBlob)) keyData.append(writer.lengthAndData(of: keyBlob))
keyData.append(writer.lengthAndData(of: curveData)) keyData.append(writer.lengthAndData(of: curveData))
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) { if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(writer.lengthAndData(of: certificateData)) keyData.append(writer.lengthAndData(of: certificateData))
keyData.append(writer.lengthAndData(of: name)) keyData.append(writer.lengthAndData(of: name))
count += 1 count += 1
@@ -117,13 +119,13 @@ extension Agent {
let payloadHash = reader.readNextChunk() let payloadHash = reader.readNextChunk()
let hash: Data let hash: Data
// Check if hash is actually an openssh certificate and reconstruct the public key if it is // Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) { if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
hash = certificatePublicKey hash = certificatePublicKey
} else { } else {
hash = payloadHash hash = payloadHash
} }
guard let (store, secret) = secret(matching: hash) else { guard let (store, secret) = await secret(matching: hash) else {
logger.debug("Agent did not have a key matching \(hash as NSData)") logger.debug("Agent did not have a key matching \(hash as NSData)")
throw AgentError.noMatchingKey throw AgentError.noMatchingKey
} }
@@ -189,9 +191,10 @@ extension Agent {
/// Gives any store with no loaded secrets a chance to reload. /// Gives any store with no loaded secrets a chance to reload.
func reloadSecretsIfNeccessary() async { func reloadSecretsIfNeccessary() async {
for store in storeList.stores { for store in await storeList.stores {
if store.secrets.isEmpty { if await store.secrets.isEmpty {
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.") let name = await store.name
logger.debug("Store \(name, privacy: .public) has no loaded secrets. Reloading.")
await store.reloadSecrets() await store.reloadSecrets()
} }
} }
@@ -200,16 +203,16 @@ extension Agent {
/// Finds a ``Secret`` matching a specified hash whos signature was requested. /// Finds a ``Secret`` matching a specified hash whos signature was requested.
/// - Parameter hash: The hash to match against. /// - Parameter hash: The hash to match against.
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found. /// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
func secret(matching hash: Data) -> (AnySecretStore, AnySecret)? { func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in for store in await storeList.stores {
let allMatching = store.secrets.filter { secret in let allMatching = await store.secrets.filter { secret in
hash == writer.data(secret: secret) hash == writer.data(secret: secret)
} }
if let matching = allMatching.first { if let matching = allMatching.first {
return (store, matching) return (store, matching)
} }
}
return nil return nil
}.first
} }
} }

View File

@@ -1,11 +0,0 @@
import Foundation
struct UncheckedSendable<T>: @unchecked Sendable {
let value: T
init(_ value: T) {
self.value = value
}
}

View File

@@ -82,12 +82,12 @@ public final class SocketController {
logger.debug("Socket controller has new data available") logger.debug("Socket controller has new data available")
guard let new = notification.object as? FileHandle else { return } guard let new = notification.object as? FileHandle else { return }
logger.debug("Socket controller received new file handle") logger.debug("Socket controller received new file handle")
Task { [handler, logger = UncheckedSendable(logger)] in Task { [handler, logger = logger] in
if((await handler?(new, new)) == true) { if((await handler?(new, new)) == true) {
logger.value.debug("Socket controller handled data, wait for more data") logger.debug("Socket controller handled data, wait for more data")
await new.waitForDataInBackgroundAndNotifyOnMainActor() await new.waitForDataInBackgroundAndNotifyOnMainActor()
} else { } else {
logger.value.debug("Socket controller called with empty data, socked closed") logger.debug("Socket controller called with empty data, socked closed")
} }
} }
} }

View File

@@ -4,13 +4,12 @@ import Combine
/// Type eraser for SecretStore. /// Type eraser for SecretStore.
public class AnySecretStore: SecretStore, @unchecked Sendable { public class AnySecretStore: SecretStore, @unchecked Sendable {
let base: Any let base: any Sendable
private let _isAvailable: @Sendable () -> Bool private let _isAvailable: @MainActor @Sendable () -> Bool
private let _id: @Sendable () -> UUID private let _id: @Sendable () -> UUID
private let _name: @Sendable () -> String private let _name: @MainActor @Sendable () -> String
private let _secrets: @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) async throws -> Data
private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext? private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: @Sendable () async -> Void private let _reloadSecrets: @Sendable () async -> Void
@@ -22,13 +21,12 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
_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) }
_verify = { try await secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) }
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) } _existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) } _persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { await secretStore.reloadSecrets() } _reloadSecrets = { await secretStore.reloadSecrets() }
} }
public var isAvailable: Bool { @MainActor public var isAvailable: Bool {
return _isAvailable() return _isAvailable()
} }
@@ -36,11 +34,11 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
return _id() return _id()
} }
public var name: String { @MainActor public var name: String {
return _name() return _name()
} }
public var secrets: [AnySecret] { @MainActor public var secrets: [AnySecret] {
return _secrets() return _secrets()
} }
@@ -48,10 +46,6 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
try await _sign(data, secret, provenance) try await _sign(data, secret, provenance)
} }
public func verify(signature: Data, for data: Data, with secret: AnySecret) async throws -> Bool {
try await _verify(signature, data, secret)
}
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
await _existingPersistedAuthenticationContext(secret) await _existingPersistedAuthenticationContext(secret)
} }

View File

@@ -1,14 +1,13 @@
import Foundation import Foundation
import OSLog import OSLog
import os
/// Manages storage and lookup for OpenSSH certificates. /// Manages storage and lookup for OpenSSH certificates.
public final class OpenSSHCertificateHandler: Sendable { public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter() private let writer = OpenSSHKeyWriter()
private let keyBlobsAndNames: OSAllocatedUnfairLock<[AnySecret: (Data, Data)]> = .init(uncheckedState: [:]) private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler. /// Initializes an OpenSSHCertificateHandler.
public init() { public init() {
@@ -21,20 +20,10 @@ public final class OpenSSHCertificateHandler: Sendable {
logger.log("No certificates, short circuiting") logger.log("No certificates, short circuiting")
return return
} }
keyBlobsAndNames.withLock { keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
$0 = secrets.reduce(into: [:]) { partialResult, next in
partialResult[next] = try? loadKeyblobAndName(for: next) partialResult[next] = try? loadKeyblobAndName(for: next)
} }
} }
}
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
/// - Parameter secret: The secret to check for a certificate.
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames.lockedValue[AnySecret(secret)] != nil
}
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported /// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from /// - Parameter certBlock: The openssh certificate to extract the public key from
@@ -64,7 +53,7 @@ public final class OpenSSHCertificateHandler: Sendable {
/// - Parameter secret: The secret to search for a certificate with /// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? { public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames.lockedValue[AnySecret(secret)] keyBlobsAndNames[AnySecret(secret)]
} }
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``

View File

@@ -1,50 +1,39 @@
import Foundation import Foundation
import Observation import Observation
import os
import Common
/// A "Store Store," which holds a list of type-erased stores. /// A "Store Store," which holds a list of type-erased stores.
@Observable public final class SecretStoreList: Sendable { @Observable @MainActor public final class SecretStoreList: Sendable {
/// The Stores managed by the SecretStoreList. /// The Stores managed by the SecretStoreList.
public var stores: [AnySecretStore] { public var stores: [AnySecretStore] = []
__stores.lockedValue
}
private let __stores: OSAllocatedUnfairLock<[AnySecretStore]> = .init(uncheckedState: [])
/// A modifiable store, if one is available. /// A modifiable store, if one is available.
public var modifiableStore: AnySecretStoreModifiable? { public var modifiableStore: AnySecretStoreModifiable? = nil
__modifiableStore.withLock { $0 }
}
private let __modifiableStore: OSAllocatedUnfairLock<AnySecretStoreModifiable?> = .init(uncheckedState: nil)
/// Initializes a SecretStoreList. /// Initializes a SecretStoreList.
public init() { public nonisolated init() {
} }
/// Adds a non-type-erased SecretStore to the list. /// Adds a non-type-erased SecretStore to the list.
public func add<SecretStoreType: SecretStore>(store: SecretStoreType) { public func add<SecretStoreType: SecretStore>(store: SecretStoreType) {
__stores.withLock { stores.append(AnySecretStore(store))
$0.append(AnySecretStore(store))
}
} }
/// Adds a non-type-erased modifiable SecretStore. /// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) { public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store) let modifiable = AnySecretStoreModifiable(modifiable: store)
__modifiableStore.lockedValue = modifiable if modifiableStore == nil {
__stores.withLock { modifiableStore = modifiable
$0.append(modifiable)
} }
stores.append(modifiable)
} }
/// A boolean describing whether there are any Stores available. /// A boolean describing whether there are any Stores available.
public var anyAvailable: Bool { public var anyAvailable: Bool {
__stores.lockedValue.contains(where: \.isAvailable) stores.contains(where: \.isAvailable)
} }
public var allSecrets: [AnySecret] { public var allSecrets: [AnySecret] {
__stores.lockedValue.flatMap(\.secrets) stores.flatMap(\.secrets)
} }
} }

View File

@@ -7,13 +7,13 @@ public protocol SecretStore: Identifiable, Sendable {
associatedtype SecretType: Secret associatedtype SecretType: Secret
/// A boolean indicating whether or not the store is available. /// A boolean indicating whether or not the store is available.
var isAvailable: Bool { get } @MainActor var isAvailable: Bool { get }
/// A unique identifier for the store. /// A unique identifier for the store.
var id: UUID { get } var id: UUID { get }
/// A user-facing name for the store. /// A user-facing name for the store.
var name: String { get } @MainActor var name: String { get }
/// The secrets the store manages. /// The secrets the store manages.
var secrets: [SecretType] { get } @MainActor var secrets: [SecretType] { get }
/// Signs a data payload with a specified Secret. /// Signs a data payload with a specified Secret.
/// - Parameters: /// - Parameters:
@@ -23,14 +23,6 @@ public protocol SecretStore: Identifiable, Sendable {
/// - 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) async throws -> Data
/// Verifies that a signature is valid over a specified payload.
/// - Parameters:
/// - signature: The signature over the data.
/// - data: The data to verify the signature of.
/// - secret: The secret whose signature to verify.
/// - Returns: Whether the signature was verified.
func verify(signature: Data, for data: Data, with secret: SecretType) async throws -> Bool
/// Checks to see if there is currently a valid persisted authentication for a given secret. /// Checks to see if there is currently a valid persisted authentication for a given secret.
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for. /// - secret: The ``Secret`` to check if there is a persisted authentication for.

View File

@@ -0,0 +1,72 @@
import LocalAuthentication
import SecretKit
extension SecureEnclave {
/// A context describing a persisted authentication.
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
/// The LAContext used to authorize the persistent context.
nonisolated(unsafe) let context: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let monotonicExpiration: UInt64
/// Initializes a context.
/// - Parameters:
/// - secret: The Secret to persist authentication for.
/// - context: The LAContext used to authorize the persistent context.
/// - duration: The duration of the authorization context, in seconds.
init(secret: Secret, context: LAContext, duration: TimeInterval) {
self.secret = secret
self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
/// A boolean describing whether or not the context is still valid.
var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
var expiration: Date {
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
return Date(timeIntervalSinceNow: remainingInSeconds)
}
}
actor PersistentAuthenticationHandler: Sendable {
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
}
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
if let durationString = formatter.string(from: duration) {
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
} else {
newContext.localizedReason = String(localized: .authContextPersistForDurationUnknown(secretName: secret.name))
}
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
}
}
}

View File

@@ -2,36 +2,30 @@ import Foundation
import Observation import Observation
import Security import Security
import CryptoKit import CryptoKit
@preconcurrency import LocalAuthentication import LocalAuthentication
import SecretKit import SecretKit
import os
import Common
extension SecureEnclave { extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave. /// An implementation of Store backed by the Secure Enclave.
@Observable public final class Store: SecretStoreModifiable { @Observable public final class Store: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
public var isAvailable: Bool { public var isAvailable: Bool {
CryptoKit.SecureEnclave.isAvailable CryptoKit.SecureEnclave.isAvailable
} }
public let id = UUID() public let id = UUID()
public let name = String(localized: "secure_enclave") public let name = String(localized: .secureEnclave)
public var secrets: [Secret] { private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
_secrets.lockedValue
}
private let _secrets: OSAllocatedUnfairLock<[Secret]> = .init(uncheckedState: [])
private let persistedAuthenticationContexts: OSAllocatedUnfairLock<[Secret: PersistentAuthenticationContext]> = .init(uncheckedState: [:])
/// Initializes a Store. /// Initializes a Store.
public init() { @MainActor public init() {
loadSecrets()
Task { Task {
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false) await reloadSecretsInternal(notifyAgent: false)
} }
} }
loadSecrets()
} }
// MARK: Public API // MARK: Public API
@@ -105,16 +99,16 @@ extension SecureEnclave {
await reloadSecretsInternal() await reloadSecretsInternal()
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
var context: LAContext let context: LAContext
if let existing = persistedAuthenticationContexts.lockedValue[secret], existing.valid { if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context context = existing.context
} else { } else {
let newContext = LAContext() let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button") newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext context = newContext
} }
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@@ -142,67 +136,12 @@ extension SecureEnclave {
return signature as Data return signature as Data
} }
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool { public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
let context = LAContext() await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
context.localizedReason = String(localized: "auth_context_request_verify_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var verifyError: SecurityError?
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
}
}
return verified
} }
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
guard let persisted = persistedAuthenticationContexts.lockedValue[secret], persisted.valid else { return nil } try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
return persisted
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
if let durationString = formatter.string(from: duration) {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
} else {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
}
newContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) { [weak self] success, _ in
guard success, let self else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
self.persistedAuthenticationContexts.withLock {
$0[secret] = context
}
}
} }
public func reloadSecrets() async { public func reloadSecrets() async {
@@ -217,11 +156,9 @@ extension SecureEnclave.Store {
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well. /// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecretsInternal(notifyAgent: Bool = true) async { @MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
let before = secrets let before = secrets
_secrets.withLock { secrets.removeAll()
$0.removeAll()
}
loadSecrets() loadSecrets()
if secrets != before { if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self) NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
@@ -232,7 +169,7 @@ extension SecureEnclave.Store {
} }
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { @MainActor private func loadSecrets() {
let publicAttributes = KeychainDictionary([ let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrKeyType: SecureEnclave.Constants.keyType,
@@ -268,7 +205,7 @@ extension SecureEnclave.Store {
nil)! nil)!
let wrapped: [SecureEnclave.Secret] = publicTyped.map { let wrapped: [SecureEnclave.Secret] = publicTyped.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = $0[kSecAttrApplicationLabel] as! Data let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any] let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
@@ -283,9 +220,7 @@ extension SecureEnclave.Store {
} }
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey) return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
} }
_secrets.withLock { secrets.append(contentsOf: wrapped)
$0.append(contentsOf: wrapped)
}
} }
/// Saves a public key. /// Saves a public key.
@@ -320,42 +255,3 @@ extension SecureEnclave {
} }
} }
extension SecureEnclave {
/// A context describing a persisted authentication.
private final class PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
/// The LAContext used to authorize the persistent context.
nonisolated(unsafe) let context: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let monotonicExpiration: UInt64
/// Initializes a context.
/// - Parameters:
/// - secret: The Secret to persist authentication for.
/// - context: The LAContext used to authorize the persistent context.
/// - duration: The duration of the authorization context, in seconds.
init(secret: Secret, context: LAContext, duration: TimeInterval) {
self.secret = secret
self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
/// A boolean describing whether or not the context is still valid.
var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
var expiration: Date {
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
return Date(timeIntervalSinceNow: remainingInSeconds)
}
}
}

View File

@@ -1,5 +1,4 @@
import Foundation import Foundation
import os
import Observation import Observation
import Security import Security
import CryptoTokenKit import CryptoTokenKit
@@ -8,37 +7,39 @@ import SecretKit
extension SmartCard { extension SmartCard {
private struct State { @MainActor @Observable fileprivate final class State {
var isAvailable = false var isAvailable = false
var name = String(localized: "smart_card") var name = String(localized: .smartCard)
var secrets: [Secret] = [] var secrets: [Secret] = []
let watcher = TKTokenWatcher() let watcher = TKTokenWatcher()
var tokenID: String? = nil var tokenID: String? = nil
nonisolated init() {}
} }
/// An implementation of Store backed by a Smart Card. /// An implementation of Store backed by a Smart Card.
@Observable public final class Store: SecretStore { @Observable public final class Store: SecretStore {
private let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init()) private let state = State()
public var isAvailable: Bool { public var isAvailable: Bool {
state.withLock { $0.isAvailable } state.isAvailable
} }
public let id = UUID() public let id = UUID()
public var name: String { @MainActor public var name: String {
state.withLock { $0.name } state.name
} }
public var secrets: [Secret] { public var secrets: [Secret] {
state.withLock { $0.secrets } state.secrets
} }
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
state.withLock { state in Task { @MainActor in
if let tokenID = state.tokenID { if let tokenID = state.tokenID {
state.isAvailable = true state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID) state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
} }
loadSecrets()
state.watcher.setInsertionHandler { id in state.watcher.setInsertionHandler { id in
// Setting insertion handler will cause it to be called immediately. // Setting insertion handler will cause it to be called immediately.
// Make a thread jump so we don't hit a recursive lock attempt. // Make a thread jump so we don't hit a recursive lock attempt.
@@ -47,7 +48,6 @@ extension SmartCard {
} }
} }
} }
loadSecrets()
} }
// MARK: Public API // MARK: Public API
@@ -60,11 +60,11 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.") fatalError("Keys must be deleted on the smart card.")
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext() let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@@ -89,29 +89,6 @@ extension SmartCard {
return signature as Data return signature as Data
} }
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
}
}
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
nil nil
} }
@@ -120,7 +97,7 @@ extension SmartCard {
} }
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
public func reloadSecrets() { @MainActor public func reloadSecrets() {
reloadSecretsInternal() reloadSecretsInternal()
} }
@@ -130,14 +107,11 @@ extension SmartCard {
extension SmartCard.Store { extension SmartCard.Store {
private func reloadSecretsInternal() { @MainActor private func reloadSecretsInternal() {
let before = state.withLock { let before = state.secrets
$0.isAvailable = $0.tokenID != nil state.isAvailable = state.tokenID != nil
let before = $0.secrets state.secrets.removeAll()
$0.secrets.removeAll() loadSecrets()
return before
}
self.loadSecrets()
if self.secrets != before { if self.secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self) NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
} }
@@ -145,8 +119,7 @@ extension SmartCard.Store {
/// Resets the token ID and reloads secrets. /// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was inserted. /// - Parameter tokenID: The ID of the token that was inserted.
private func smartcardInserted(for tokenID: String? = nil) { @MainActor private func smartcardInserted(for tokenID: String? = nil) {
state.withLock { state in
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return } guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
guard state.tokenID == nil else { return } guard state.tokenID == nil else { return }
guard !string.contains("setoken") else { return } guard !string.contains("setoken") else { return }
@@ -154,28 +127,23 @@ extension SmartCard.Store {
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string) state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
state.tokenID = string state.tokenID = string
} }
}
/// Resets the token ID and reloads secrets. /// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was removed. /// - Parameter tokenID: The ID of the token that was removed.
private func smartcardRemoved(for tokenID: String? = nil) { @MainActor private func smartcardRemoved(for tokenID: String? = nil) {
state.withLock { state.tokenID = nil
$0.tokenID = nil
}
reloadSecrets() reloadSecrets()
} }
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { @MainActor private func loadSecrets() {
guard let tokenID = state.withLock({ $0.tokenID }) else { return } guard let tokenID = state.tokenID else { return }
let fallbackName = String(localized: "smart_card") let fallbackName = String(localized: .smartCard)
state.withLock { if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
if let driverName = $0.watcher.tokenInfo(forTokenID: tokenID)?.driverName { state.name = driverName
$0.name = driverName
} else { } else {
$0.name = fallbackName state.name = fallbackName
}
} }
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
@@ -189,7 +157,7 @@ extension SmartCard.Store {
SecItemCopyMatching(attributes, &untyped) SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return } guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped = typed.map { let wrapped = typed.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let tokenID = $0[kSecAttrApplicationLabel] as! Data let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber) let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
let keySize = $0[kSecAttrKeySizeInBits] as! Int let keySize = $0[kSecAttrKeySizeInBits] as! Int
@@ -199,9 +167,7 @@ extension SmartCard.Store {
let publicKey = publicKeyAttributes[kSecValueData] as! Data let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey) return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
} }
state.withLock { state.secrets.append(contentsOf: wrapped)
$0.secrets.append(contentsOf: wrapped)
}
} }
} }
@@ -218,8 +184,8 @@ extension SmartCard.Store {
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged. /// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func encrypt(data: Data, with secret: SecretType) throws -> Data { public func encrypt(data: Data, with secret: SecretType) throws -> Data {
let context = LAContext() let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)") context.localizedReason = String(localized: .authContextRequestEncryptDescription(secretName: secret.name))
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType, kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize, kSecAttrKeySizeInBits: secret.keySize,
@@ -244,11 +210,11 @@ extension SmartCard.Store {
/// - secret: The secret to decrypt with. /// - secret: The secret to decrypt with.
/// - Returns: The decrypted data. /// - Returns: The decrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged. /// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func decrypt(data: Data, with secret: SecretType) throws -> Data { public func decrypt(data: Data, with secret: SecretType) async throws -> Data {
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext() let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)") context.localizedReason = String(localized: .authContextRequestDecryptDescription(secretName: secret.name))
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,

View File

@@ -59,7 +59,7 @@ import Foundation
} }
@Test @Test
func greatestSelectedIfOldPatchIsPublishedLater() async throws { @MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws {
// If 2.x.x series has been published, and a patch for 1.x.x is issued // If 2.x.x series has been published, and a patch for 1.x.x is issued
// 2.x.x should still be selected if user can run it. // 2.x.x should still be selected if user can run it.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0")) let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
@@ -77,7 +77,7 @@ import Foundation
} }
@Test @Test
func latestVersionIsRunnable() async throws { @MainActor func latestVersionIsRunnable() async throws {
// If the 2.x.x series has been published but the user can't run it // If the 2.x.x series has been published but the user can't run it
// the last version the user can run should be selected. // the last version the user can run should be selected.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0")) let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))

View File

@@ -1,10 +1,8 @@
import Foundation import Foundation
import os
import Testing import Testing
import CryptoKit import CryptoKit
@testable import SecretKit @testable import SecretKit
@testable import SecretAgentKit @testable import SecretAgentKit
import Common
@Suite struct AgentTests { @Suite struct AgentTests {
@@ -21,7 +19,7 @@ import Common
@Test func identitiesList() async { @Test func identitiesList() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple) #expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
@@ -31,7 +29,7 @@ import Common
@Test func noMatchingIdentities() async { @Test func noMatchingIdentities() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure) #expect(stubWriter.data == Constants.Responses.requestFailure)
@@ -42,7 +40,7 @@ import Common
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...]) let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
_ = requestReader.readNextChunk() _ = requestReader.readNextChunk()
let dataToSign = requestReader.readNextChunk() let dataToSign = requestReader.readNextChunk()
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
let outer = OpenSSHReader(data: stubWriter.data[5...]) let outer = OpenSSHReader(data: stubWriter.data[5...])
@@ -62,25 +60,17 @@ import Common
} }
var rs = r var rs = r
rs.append(s) rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs) let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign) // Correct signature
let store = list.stores.first! #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret)) .isValidSignature(signature, for: dataToSign))
let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidWrongKey = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa384Secret))
#expect(referenceValid)
#expect(derVerifies)
#expect(invalidRandomSignature == false)
#expect(invalidRandomData == false)
#expect(invalidWrongKey == false)
} }
// MARK: Witness protocol // MARK: Witness protocol
@Test func witnessObjectionStopsRequest() async { @Test func witnessObjectionStopsRequest() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let witness = StubWitness(speakNow: { _,_ in let witness = StubWitness(speakNow: { _,_ in
return true return true
}, witness: { _, _ in }) }, witness: { _, _ in })
@@ -91,44 +81,43 @@ import Common
@Test func witnessSignature() async { @Test func witnessSignature() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let witnessed: OSAllocatedUnfairLock<Bool> = .init(uncheckedState: false) nonisolated(unsafe) var witnessed = false
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
return false return false
}, witness: { _, trace in }, witness: { _, trace in
witnessed.lockedValue = true witnessed = true
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
let value = witnessed.lockedValue #expect(witnessed)
#expect(value)
} }
@Test func requestTracing() async { @Test func requestTracing() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let speakNowTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil) nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
let witnessTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil) nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
speakNowTrace.lockedValue = trace speakNowTrace = trace
return false return false
}, witness: { _, trace in }, witness: { _, trace in
witnessTrace.lockedValue = trace witnessTrace = trace
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(witnessTrace.lockedValue == speakNowTrace.lockedValue) #expect(witnessTrace == speakNowTrace)
#expect(witnessTrace.lockedValue?.origin.displayName == "Finder") #expect(witnessTrace?.origin.displayName == "Finder")
#expect(witnessTrace.lockedValue?.origin.validSignature == true) #expect(witnessTrace?.origin.validSignature == true)
#expect(witnessTrace.lockedValue?.origin.parentPID == 1) #expect(witnessTrace?.origin.parentPID == 1)
} }
// MARK: Exception Handling // MARK: Exception Handling
@Test func signatureException() async { @Test func signatureException() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = 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 = await list.stores.first?.base as! Stub.Store
store.shouldThrow = true store.shouldThrow = true
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
@@ -148,7 +137,7 @@ import Common
extension AgentTests { extension AgentTests {
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList { @MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
let store = Stub.Store() let store = Stub.Store()
store.secrets.append(contentsOf: secrets) store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList() let storeList = SecretStoreList()

View File

@@ -61,29 +61,6 @@ extension Stub {
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
} }
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: Unmanaged<CFError>?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError)
if let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
}
return verified
}
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
nil nil
} }

View File

@@ -11,13 +11,13 @@ import Observation
@main @main
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
private let storeList: SecretStoreList = { @MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
list.add(store: SecureEnclave.Store()) list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()
private let updater = Updater(checkOnLaunch: false) private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier() private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private lazy var agent: Agent = { private lazy var agent: Agent = {
@@ -47,8 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
_ = withObservationTracking { _ = withObservationTracking {
updater.update updater.update
} onChange: { [updater, notifier] in } onChange: { [updater, notifier] in
notifier.notify(update: updater.update!) { release in
Task { Task {
await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release) await updater.ignore(release: release)
} }
} }

View File

@@ -1,60 +0,0 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "Mac Icon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "Mac Icon@0.25x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -4,15 +4,14 @@ import AppKit
import SecretKit import SecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
import os
final class Notifier: Sendable { final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate() private let notificationDelegate = NotificationDelegate()
init() { init() {
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: "update_notification_update_button"), options: []) let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: .updateNotificationUpdateButton), options: [])
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: "update_notification_ignore_button"), options: []) let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: .updateNotificationIgnoreButton), options: [])
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: [])
@@ -23,33 +22,32 @@ final class Notifier: Sendable {
Measurement(value: 24, unit: UnitDuration.hours) Measurement(value: 24, unit: UnitDuration.hours)
] ]
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: "persist_authentication_decline_button"), 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] = [:]
for duration in rawDurations { for duration in rawDurations {
let seconds = duration.converted(to: .seconds).value let seconds = duration.converted(to: .seconds).value
guard let string = formatter.string(from: seconds)?.capitalized else { continue } guard let string = formatter.string(from: seconds)?.capitalized else { continue }
let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)") let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)")
let action = UNNotificationAction(identifier: identifier, title: string, options: []) let action = UNNotificationAction(identifier: identifier, title: string, options: [])
notificationDelegate.state.withLock { state in identifiers[identifier] = seconds
state.persistOptions[identifier] = seconds
}
allPersistenceActions.append(action) allPersistenceActions.append(action)
} }
let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: []) let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: [])
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) { if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
persistAuthenticationCategory.setValue(String(localized: "persist_authentication_accept_button"), forKey: "_actionsMenuTitle") persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
} }
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate UNUserNotificationCenter.current().delegate = notificationDelegate
notificationDelegate.state.withLock { state in Task {
state.persistAuthentication = { secret, store, duration in await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
guard let duration = duration else { return } guard let duration = duration else { return }
try? await store.persistAuthentication(secret: secret, forDuration: duration) try? await store.persistAuthentication(secret: secret, forDuration: duration)
} }
@@ -63,14 +61,11 @@ final class Notifier: Sendable {
} }
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async { func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
notificationDelegate.state.withLock { state in await notificationDelegate.state.setPending(secret: secret, store: store)
state.pendingPersistableSecrets[secret.id.description] = secret
state.pendingPersistableStores[store.id.description] = store
}
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)") notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
notificationContent.subtitle = String(localized: "signed_notification_description_\(secret.name)") 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
@@ -84,24 +79,21 @@ final class Notifier: Sendable {
try? await notificationCenter.add(request) try? await notificationCenter.add(request)
} }
func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) { func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
notificationDelegate.state.withLock { [update] state in await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
state.release = update
state.ignore = ignore
}
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
if update.critical { if update.critical {
notificationContent.interruptionLevel = .critical notificationContent.interruptionLevel = .critical
notificationContent.title = String(localized: "update_notification_update_critical_title_\(update.name)") notificationContent.title = String(localized: .updateNotificationUpdateCriticalTitle(updateName: update.name))
} else { } else {
notificationContent.title = String(localized: "update_notification_update_normal_title_\(update.name)") notificationContent.title = String(localized: .updateNotificationUpdateNormalTitle(updateName: update.name))
} }
notificationContent.subtitle = String(localized: "update_notification_update_description") notificationContent.subtitle = String(localized: .updateNotificationUpdateDescription)
notificationContent.body = update.body notificationContent.body = update.body
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
notificationCenter.add(request, withCompletionHandler: nil) try? await notificationCenter.add(request)
} }
} }
@@ -140,18 +132,45 @@ extension Notifier {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
struct State { fileprivate actor State {
typealias PersistAuthentication = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void) typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias Ignore = ((Release) -> Void) typealias IgnoreAction = (@Sendable (Release) async -> Void)
fileprivate var release: Release? fileprivate var release: Release?
fileprivate var ignore: Ignore? fileprivate var ignoreAction: IgnoreAction?
fileprivate var persistAuthentication: PersistAuthentication? fileprivate var persistAction: PersistAction?
fileprivate var persistOptions: [String: TimeInterval] = [:] fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:] fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
func setPending(secret: AnySecret, store: AnySecretStore) {
pendingPersistableSecrets[secret.id.description] = secret
pendingPersistableStores[store.id.description] = store
} }
fileprivate let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init()) func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
guard let secret = pendingPersistableSecrets[secretID],
let store = pendingPersistableStores[storeID],
let options = persistOptions[optionID] else {
return nil
}
pendingPersistableSecrets.removeValue(forKey: secretID)
return (secret, store, options)
}
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
self.persistOptions = options
self.persistAction = action
}
func prepareForNotification(release: Release, ignoreAction: IgnoreAction?) {
self.release = release
self.ignoreAction = ignoreAction
}
}
fileprivate let state = State()
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
@@ -161,7 +180,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
let category = response.notification.request.content.categoryIdentifier let category = response.notification.request.content.categoryIdentifier
switch category { switch category {
case Notifier.Constants.updateCategoryIdentitifier: case Notifier.Constants.updateCategoryIdentitifier:
handleUpdateResponse(response: response) await handleUpdateResponse(response: response)
case Notifier.Constants.persistAuthenticationCategoryIdentitifier: case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
await handlePersistAuthenticationResponse(response: response) await handlePersistAuthenticationResponse(response: response)
default: default:
@@ -169,37 +188,27 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
} }
} }
func handleUpdateResponse(response: UNNotificationResponse) { func handleUpdateResponse(response: UNNotificationResponse) async {
let id = response.actionIdentifier let id = response.actionIdentifier
state.withLock { state in guard let update = await state.release else { return }
guard let update = state.release else { return }
switch id { switch id {
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
NSWorkspace.shared.open(update.html_url) NSWorkspace.shared.open(update.html_url)
case Notifier.Constants.ignoreActionIdentitifier: case Notifier.Constants.ignoreActionIdentitifier:
state.ignore?(update) await state.ignoreAction?(update)
default: default:
fatalError() fatalError()
} }
} }
}
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,
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else { let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
return return
} }
let id = response.actionIdentifier let optionID = response.actionIdentifier
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in await state.persistAction?(secret, store, persistOptions)
guard let secret = state.pendingPersistableSecrets[secretID],
let store = state.pendingPersistableStores[storeID]
else { return (nil, nil, nil, nil) }
state.pendingPersistableSecrets[secretID] = nil
return (secret, store, state.persistOptions[id], state.persistAuthentication)
}
guard let secret, let store, let persistOptions else { return }
await callback?(secret, store, persistOptions)
} }
@@ -208,3 +217,4 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
} }
} }

View File

@@ -18,7 +18,9 @@
5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; }; 5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; };
5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; }; 5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; };
5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; }; 5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; };
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; }; 5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; };
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8623FCE48E0099B055 /* Assets.xcassets */; };
5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; };
501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; }; 501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; };
501421652781268000BBAA70 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 501421652781268000BBAA70 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; }; 50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; };
@@ -45,13 +47,11 @@
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; }; 508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; };
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */; }; 5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */; };
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; }; 5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; };
50A3B79124026B7600D209EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79024026B7600D209EA /* Assets.xcassets */; };
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; }; 50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; };
50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; }; 50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; };
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; }; 50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; }; 50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; }; 50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -102,7 +102,7 @@
50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; }; 50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; };
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; }; 5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; };
500B93C22B478D8400E157DE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Packages/Localizable.xcstrings; sourceTree = SOURCE_ROOT; };
50153E1F250AFCB200525160 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; }; 50153E1F250AFCB200525160 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; };
50153E21250DECA300525160 /* SecretListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretListItemView.swift; sourceTree = "<group>"; }; 50153E21250DECA300525160 /* SecretListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretListItemView.swift; sourceTree = "<group>"; };
5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; }; 5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
@@ -133,7 +133,6 @@
5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDirectoryController.swift; sourceTree = "<group>"; }; 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDirectoryController.swift; sourceTree = "<group>"; };
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = "<group>"; }; 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = "<group>"; };
50A3B78A24026B7500D209EA /* SecretAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecretAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; 50A3B78A24026B7500D209EA /* SecretAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecretAgent.app; sourceTree = BUILT_PRODUCTS_DIR; };
50A3B79024026B7600D209EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
50A3B79324026B7600D209EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; 50A3B79324026B7600D209EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
50A3B79624026B7600D209EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 50A3B79624026B7600D209EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
50A3B79824026B7600D209EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 50A3B79824026B7600D209EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -211,7 +210,7 @@
508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */, 508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */,
50617D8F23FCE48E0099B055 /* Secretive.entitlements */, 50617D8F23FCE48E0099B055 /* Secretive.entitlements */,
506772C62424784600034DED /* Credits.rtf */, 506772C62424784600034DED /* Credits.rtf */,
500B93C22B478D8400E157DE /* Localizable.xcstrings */, 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */,
50617D8823FCE48E0099B055 /* Preview Content */, 50617D8823FCE48E0099B055 /* Preview Content */,
); );
path = Secretive; path = Secretive;
@@ -281,7 +280,6 @@
children = ( children = (
50020BAF24064869003D4025 /* AppDelegate.swift */, 50020BAF24064869003D4025 /* AppDelegate.swift */,
5018F54E24064786002EB505 /* Notifier.swift */, 5018F54E24064786002EB505 /* Notifier.swift */,
50A3B79024026B7600D209EA /* Assets.xcassets */,
50A3B79524026B7600D209EA /* Main.storyboard */, 50A3B79524026B7600D209EA /* Main.storyboard */,
50A3B79824026B7600D209EA /* Info.plist */, 50A3B79824026B7600D209EA /* Info.plist */,
508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */, 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */,
@@ -386,6 +384,8 @@
fi, fi,
ko, ko,
ca, ca,
ru,
pl,
); );
mainGroup = 50617D7623FCE48D0099B055; mainGroup = 50617D7623FCE48D0099B055;
productRefGroup = 50617D8023FCE48E0099B055 /* Products */; productRefGroup = 50617D8023FCE48E0099B055 /* Products */;
@@ -404,7 +404,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */, 50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */,
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */, 5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */,
50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */, 50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */,
506772C72424784600034DED /* Credits.rtf in Resources */, 506772C72424784600034DED /* Credits.rtf in Resources */,
508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */, 508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */,
@@ -416,10 +416,10 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
50A3B79724026B7600D209EA /* Main.storyboard in Resources */, 50A3B79724026B7600D209EA /* Main.storyboard in Resources */,
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */, 5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */,
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */, 50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */,
50A3B79124026B7600D209EA /* Assets.xcassets in Resources */,
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */, 508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */,
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -526,6 +526,8 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -598,7 +600,9 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -637,8 +641,10 @@
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
DEVELOPMENT_TEAM = Z72PRUAWF6; DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
INFOPLIST_FILE = Secretive/Info.plist; INFOPLIST_FILE = Secretive/Info.plist;
@@ -667,8 +673,10 @@
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
DEVELOPMENT_TEAM = Z72PRUAWF6; DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
INFOPLIST_FILE = Secretive/Info.plist; INFOPLIST_FILE = Secretive/Info.plist;
@@ -723,6 +731,8 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -760,14 +770,17 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements;
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
INFOPLIST_FILE = Secretive/Info.plist; INFOPLIST_FILE = Secretive/Info.plist;

View File

@@ -0,0 +1,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">
<plist version="1.0">
<dict>
<key>iOSPackagesShouldBuildARM64e</key>
<true/>
</dict>
</plist>

View File

@@ -6,28 +6,33 @@ import SmartCardSecretKit
import Brief import Brief
extension EnvironmentValues { extension EnvironmentValues {
@Entry var secretStoreList: SecretStoreList = {
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
list.add(store: SecureEnclave.Store()) list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = AgentStatusChecker()
@Entry var updater: any UpdaterProtocol = Updater(checkOnLaunch: false) private static let _agentStatusChecker = AgentStatusChecker()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
private static let _updater: any UpdaterProtocol = {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
return Updater(checkOnLaunch: hasRunSetup)
}()
@Entry var updater: any UpdaterProtocol = _updater
@MainActor var secretStoreList: SecretStoreList {
EnvironmentValues._secretStoreList
}
} }
@main @main
struct Secretive: App { struct Secretive: App {
private let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
private let agentStatusChecker = AgentStatusChecker()
private let justUpdatedChecker = JustUpdatedChecker() private let justUpdatedChecker = JustUpdatedChecker()
@Environment(\.agentStatusChecker) var agentStatusChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false @AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false @State private var showingSetup = false
@State private var showingCreation = false @State private var showingCreation = false
@@ -35,9 +40,7 @@ struct Secretive: App {
@SceneBuilder var body: some Scene { @SceneBuilder var body: some Scene {
WindowGroup { WindowGroup {
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup) ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environment(storeList) .environment(EnvironmentValues._secretStoreList)
.environment(Updater(checkOnLaunch: hasRunSetup))
.environment(agentStatusChecker)
.onAppear { .onAppear {
if !hasRunSetup { if !hasRunSetup {
showingSetup = true showingSetup = true
@@ -56,18 +59,18 @@ struct Secretive: App {
} }
.commands { .commands {
CommandGroup(after: CommandGroupPlacement.newItem) { CommandGroup(after: CommandGroupPlacement.newItem) {
Button("app_menu_new_secret_button") { Button(.appMenuNewSecretButton) {
showingCreation = true showingCreation = true
} }
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift])) .keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
} }
CommandGroup(replacing: .help) { CommandGroup(replacing: .help) {
Button("app_menu_help_button") { Button(.appMenuHelpButton) {
NSWorkspace.shared.open(Constants.helpURL) NSWorkspace.shared.open(Constants.helpURL)
} }
} }
CommandGroup(after: .help) { CommandGroup(after: .help) {
Button("app_menu_setup_button") { Button(.appMenuSetupButton) {
showingSetup = true showingSetup = true
} }
} }

View File

@@ -1,53 +1,61 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "Icon-macOS-ClearDark-16x16@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "Icon-macOS-ClearDark-16x16@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "Icon-macOS-ClearDark-32x32@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "Icon-macOS-ClearDark-32x32@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "Icon-macOS-ClearDark-128x128@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "Icon-macOS-ClearDark-128x128@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "Mac Icon.png", "filename" : "Icon-macOS-ClearDark-256x256@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "Mac Icon@0.25x.png", "filename" : "Icon-macOS-ClearDark-256x256@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "Icon-macOS-ClearDark-512x512@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "512x512" "size" : "512x512"
}, },
{ {
"filename" : "Icon-macOS-ClearDark-1024x1024@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "512x512" "size" : "512x512"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,6 +1,6 @@
{ {
"info" : { "info" : {
"version" : 1, "author" : "xcode",
"author" : "xcode" "version" : 1
} }
} }

View File

@@ -4,18 +4,21 @@ import AppKit
import SecretKit import SecretKit
import Observation import Observation
protocol AgentStatusCheckerProtocol: Observable { @MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
var running: Bool { get } var running: Bool { get }
var developmentBuild: Bool { get } var developmentBuild: Bool { get }
func check()
} }
@Observable class AgentStatusChecker: AgentStatusCheckerProtocol { @Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
var running: Bool = false var running: Bool = false
init() { nonisolated init() {
Task { @MainActor in
check() check()
} }
}
func check() { func check() {
running = instanceSecretAgentProcess != nil running = instanceSecretAgentProcess != nil

View File

@@ -2,13 +2,13 @@ import Foundation
import Combine import Combine
import AppKit import AppKit
protocol JustUpdatedCheckerProtocol: ObservableObject { protocol JustUpdatedCheckerProtocol: Observable {
var justUpdated: Bool { get } var justUpdated: Bool { get }
} }
class JustUpdatedChecker: ObservableObject, JustUpdatedCheckerProtocol { @Observable class JustUpdatedChecker: JustUpdatedCheckerProtocol {
@Published var justUpdated: Bool = false var justUpdated: Bool = false
init() { init() {
check() check()

View File

@@ -10,4 +10,7 @@ class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
self.running = running self.running = running
} }
func check() {
}
} }

View File

@@ -20,7 +20,7 @@ extension Preview {
extension Preview { extension Preview {
final class Store: SecretStore, ObservableObject { @Observable final class Store: SecretStore {
let isAvailable = true let isAvailable = true
let id = UUID() let id = UUID()
@@ -40,10 +40,6 @@ extension Preview {
return data return data
} }
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
true
}
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil nil
} }
@@ -76,10 +72,6 @@ extension Preview {
return data return data
} }
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
true
}
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil nil
} }
@@ -104,7 +96,7 @@ extension Preview {
extension Preview { extension Preview {
static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList { @MainActor static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
let list = SecretStoreList() let list = SecretStoreList()
for store in stores { for store in stores {
list.add(store: store) list.add(store: store)

View File

@@ -1,28 +1,27 @@
import Foundation import Foundation
import os
import Observation import Observation
import Brief import Brief
@Observable class PreviewUpdater: UpdaterProtocol { @Observable @MainActor final class PreviewUpdater: UpdaterProtocol {
var update: Release? { var update: Release? = nil
_update.lockedValue
}
let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil)
let testBuild = false let testBuild = false
init(update: Update = .none) { init(update: Update = .none) {
switch update { switch update {
case .none: case .none:
_update.lockedValue = nil self.update = nil
case .advisory: case .advisory:
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update") self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
case .critical: case .critical:
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
} }
} }
func ignore(release: Release) async {
}
} }
extension PreviewUpdater { extension PreviewUpdater {

View File

@@ -2,6 +2,16 @@
<!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>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version</key>
<integer>1</integer>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.hardened-process.platform-restrictions</key>
<integer>2</integer>
<key>com.apple.security.smartcard</key> <key>com.apple.security.smartcard</key>
<true/> <true/>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>

View File

@@ -13,7 +13,7 @@ struct ContentView: View {
@State var activeSecret: AnySecret? @State var activeSecret: AnySecret?
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.secretStoreList) private var storeList: SecretStoreList @Environment(\.secretStoreList) private var storeList
@Environment(\.updater) private var updater: any UpdaterProtocol @Environment(\.updater) private var updater: any UpdaterProtocol
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol @Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
@@ -30,7 +30,7 @@ struct ContentView: View {
} }
.frame(minWidth: 640, minHeight: 320) .frame(minWidth: 640, minHeight: 320)
.toolbar { .toolbar {
toolbarItem(updateNoticeView, id: "update") // toolbarItem(updateNoticeView, id: "update")
toolbarItem(runningOrRunSetupView, id: "setup") toolbarItem(runningOrRunSetupView, id: "setup")
toolbarItem(appPathNoticeView, id: "appPath") toolbarItem(appPathNoticeView, id: "appPath")
toolbarItem(newItemView, id: "new") toolbarItem(newItemView, id: "new")
@@ -45,8 +45,14 @@ struct ContentView: View {
extension ContentView { extension ContentView {
func toolbarItem(_ view: some View, id: String) -> ToolbarItem<String, some View> { @ToolbarContentBuilder
func toolbarItem(_ view: some View, id: String) -> some ToolbarContent {
if #available(macOS 26.0, *) {
ToolbarItem(id: id) { view } ToolbarItem(id: id) { view }
.sharedBackgroundVisibility(.hidden)
} else {
ToolbarItem(id: id) { view }
}
} }
var needsSetup: Bool { var needsSetup: Bool {
@@ -64,15 +70,15 @@ extension ContentView {
} }
} }
var updateNoticeContent: (LocalizedStringKey, Color)? { var updateNoticeContent: (LocalizedStringResource, Color)? {
guard let update = updater.update else { return nil } guard let update = updater.update else { return nil }
if update.critical { if update.critical {
return ("update_critical_notice_title", .red) return (.updateCriticalNoticeTitle, .red)
} else { } else {
if updater.testBuild { if updater.testBuild {
return ("update_test_notice_title", .blue) return (.updateTestNoticeTitle, .blue)
} else { } else {
return ("update_normal_notice_title", .orange) return (.updateNormalNoticeTitle, .orange)
} }
} }
} }
@@ -121,13 +127,13 @@ extension ContentView {
}, label: { }, label: {
Group { Group {
if hasRunSetup && !agentStatusChecker.running { if hasRunSetup && !agentStatusChecker.running {
Text("agent_not_running_notice_title") Text(.agentNotRunningNoticeTitle)
} else { } else {
Text("agent_setup_notice_title") Text(.agentSetupNoticeTitle)
} }
} }
.font(.headline) .font(.headline)
.foregroundColor(.white)
}) })
.buttonStyle(ToolbarButtonStyle(color: .orange)) .buttonStyle(ToolbarButtonStyle(color: .orange))
} }
@@ -138,7 +144,7 @@ extension ContentView {
showingAgentInfo = true showingAgentInfo = true
}, label: { }, label: {
HStack { HStack {
Text("agent_running_notice_title") Text(.agentRunningNoticeTitle)
.font(.headline) .font(.headline)
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white) .foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
Circle() Circle()
@@ -149,10 +155,10 @@ extension ContentView {
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05))) .buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { .popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
VStack { VStack {
Text("agent_running_notice_detail_title") Text(.agentRunningNoticeDetailTitle)
.font(.title) .font(.title)
.padding(5) .padding(5)
Text("agent_running_notice_detail_description") Text(.agentRunningNoticeDetailDescription)
.frame(width: 300) .frame(width: 300)
} }
.padding() .padding()
@@ -166,7 +172,7 @@ extension ContentView {
showingAppPathNotice = true showingAppPathNotice = true
}, label: { }, label: {
Group { Group {
Text("app_not_in_applications_notice_title") Text(.appNotInApplicationsNoticeTitle)
} }
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundColor(.white)
@@ -178,7 +184,7 @@ extension ContentView {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 64) .frame(width: 64)
Text("app_not_in_applications_notice_detail_description") Text(.appNotInApplicationsNoticeDetailDescription)
.frame(maxWidth: 300) .frame(maxWidth: 300)
} }
.padding() .padding()

View File

@@ -3,14 +3,13 @@ import UniformTypeIdentifiers
struct CopyableView: View { struct CopyableView: View {
var title: LocalizedStringKey var title: LocalizedStringResource
var image: Image var image: Image
var text: String var text: String
@State private var interactionState: InteractionState = .normal @State private var interactionState: InteractionState = .normal
@Environment(\.colorScheme) private var colorScheme
var body: some View { var content: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
image image
@@ -22,7 +21,7 @@ struct CopyableView: View {
.foregroundColor(primaryTextColor) .foregroundColor(primaryTextColor)
Spacer() Spacer()
if interactionState != .normal { if interactionState != .normal {
Text(hoverText) hoverIcon
.bold() .bold()
.textCase(.uppercase) .textCase(.uppercase)
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
@@ -39,17 +38,23 @@ struct CopyableView: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
} }
.background(backgroundColor) ._background(interactionState: interactionState)
.frame(minWidth: 150, maxWidth: .infinity) .frame(minWidth: 150, maxWidth: .infinity)
.cornerRadius(10) }
var body: some View {
content
.onHover { hovering in .onHover { hovering in
withAnimation { withAnimation {
interactionState = hovering ? .hovering : .normal interactionState = hovering ? .hovering : .normal
} }
} }
.onDrag { .onDrag({
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier) NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier)
} }, preview: {
content
._background(interactionState: .dragging)
})
.onTapGesture { .onTapGesture {
copy() copy()
withAnimation { withAnimation {
@@ -66,31 +71,23 @@ struct CopyableView: View {
) )
} }
var hoverText: LocalizedStringKey { @ViewBuilder
var hoverIcon: some View {
switch interactionState { switch interactionState {
case .hovering: case .hovering:
return "copyable_click_to_copy_button" Image(systemName: "document.on.document")
.accessibilityLabel(String(localized: "copyable_click_to_copy_button"))
case .clicking: case .clicking:
return "copyable_copied" Image(systemName: "checkmark.circle.fill")
case .normal: .accessibilityLabel(String(localized: "copyable_copied"))
fatalError() case .normal, .dragging:
} EmptyView()
}
var backgroundColor: Color {
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)
case .clicking:
return .accentColor
} }
} }
var primaryTextColor: Color { var primaryTextColor: Color {
switch interactionState { switch interactionState {
case .normal, .hovering: case .normal, .hovering, .dragging:
return Color(.textColor) return Color(.textColor)
case .clicking: case .clicking:
return .white return .white
@@ -99,7 +96,7 @@ struct CopyableView: View {
var secondaryTextColor: Color { var secondaryTextColor: Color {
switch interactionState { switch interactionState {
case .normal, .hovering: case .normal, .hovering, .dragging:
return Color(.secondaryLabelColor) return Color(.secondaryLabelColor)
case .clicking: case .clicking:
return .white return .white
@@ -111,10 +108,59 @@ struct CopyableView: View {
NSPasteboard.general.setString(text, forType: .string) NSPasteboard.general.setString(text, forType: .string)
} }
private enum InteractionState {
case normal, hovering, clicking
} }
fileprivate enum InteractionState {
case normal, hovering, clicking, dragging
}
extension View {
fileprivate func _background(interactionState: InteractionState) -> some View {
modifier(BackgroundViewModifier(interactionState: interactionState))
}
}
fileprivate struct BackgroundViewModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appearsActive) private var appearsActive
let interactionState: InteractionState
func body(content: Content) -> some View {
if interactionState == .dragging {
content
.background(backgroundColor(interactionState: interactionState), in: RoundedRectangle(cornerRadius: 15))
} else {
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: 15))
} else {
content
.background(backgroundColor(interactionState: interactionState))
.cornerRadius(10)
}
}
}
func backgroundColor(interactionState: InteractionState) -> Color {
guard appearsActive else { return Color.clear }
switch interactionState {
case .normal:
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
case .hovering, .dragging:
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
case .clicking:
return .accentColor
}
}
} }
#if DEBUG #if DEBUG

View File

@@ -14,30 +14,30 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
HStack { HStack {
VStack { VStack {
HStack { HStack {
Text("create_secret_title") Text(.createSecretTitle)
.font(.largeTitle) .font(.largeTitle)
Spacer() Spacer()
} }
HStack { HStack {
Text("create_secret_name_label") Text(.createSecretNameLabel)
TextField("create_secret_name_placeholder", text: $name) TextField(String(localized: .createSecretNamePlaceholder), text: $name)
.focusable() .focusable()
} }
ThumbnailPickerView(items: [ ThumbnailPickerView(items: [
ThumbnailPickerView.Item(value: true, name: "create_secret_require_authentication_title", description: "create_secret_require_authentication_description", thumbnail: AuthenticationView()), ThumbnailPickerView.Item(value: true, name: .createSecretRequireAuthenticationTitle, description: .createSecretRequireAuthenticationDescription, thumbnail: AuthenticationView()),
ThumbnailPickerView.Item(value: false, name: "create_secret_notify_title", ThumbnailPickerView.Item(value: false, name: .createSecretNotifyTitle,
description: "create_secret_notify_description", description: .createSecretNotifyDescription,
thumbnail: NotificationView()) thumbnail: NotificationView())
], selection: $requiresAuthentication) ], selection: $requiresAuthentication)
} }
} }
HStack { HStack {
Spacer() Spacer()
Button("create_secret_cancel_button") { Button(.createSecretCancelButton) {
showing = false showing = false
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
Button("create_secret_create_button", action: save) Button(.createSecretCreateButton, action: save)
.disabled(name.isEmpty) .disabled(name.isEmpty)
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
} }
@@ -98,11 +98,11 @@ extension ThumbnailPickerView {
struct Item<InnerValueType: Hashable>: Identifiable { struct Item<InnerValueType: Hashable>: Identifiable {
let id = UUID() let id = UUID()
let value: InnerValueType let value: InnerValueType
let name: LocalizedStringKey let name: LocalizedStringResource
let description: LocalizedStringKey let description: LocalizedStringResource
let thumbnail: AnyView let thumbnail: AnyView
init<ViewType: View>(value: InnerValueType, name: LocalizedStringKey, description: LocalizedStringKey, thumbnail: ViewType) { init<ViewType: View>(value: InnerValueType, name: LocalizedStringResource, description: LocalizedStringResource, thumbnail: ViewType) {
self.value = value self.value = value
self.name = name self.name = name
self.description = description self.description = description
@@ -112,10 +112,10 @@ extension ThumbnailPickerView {
} }
@MainActor class SystemBackground: ObservableObject { @MainActor @Observable class SystemBackground {
static let shared = SystemBackground() static let shared = SystemBackground()
@Published var image: NSImage? var image: NSImage?
private init() { private init() {
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) { if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {

View File

@@ -18,24 +18,24 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
.padding() .padding()
VStack { VStack {
HStack { HStack {
Text("delete_confirmation_title_\(secret.name)").bold() Text(.deleteConfirmationTitle(secretName: secret.name)).bold()
Spacer() Spacer()
} }
HStack { HStack {
Text("delete_confirmation_description_\(secret.name)_\(secret.name)") Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
Spacer() Spacer()
} }
HStack { HStack {
Text("delete_confirmation_confirm_name_label") Text(.deleteConfirmationConfirmNameLabel)
TextField(secret.name, text: $confirm) TextField(secret.name, text: $confirm)
} }
} }
} }
HStack { HStack {
Spacer() Spacer()
Button("delete_confirmation_delete_button", action: delete) Button(.deleteConfirmationDeleteButton, action: delete)
.disabled(confirm != secret.name) .disabled(confirm != secret.name)
Button("delete_confirmation_cancel_button") { Button(.deleteConfirmationCancelButton) {
dismissalBlock(false) dismissalBlock(false)
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)

View File

@@ -18,9 +18,9 @@ struct EmptyStoreImmutableView: View {
var body: some View { var body: some View {
VStack { VStack {
Text("empty_store_nonmodifiable_title").bold() Text(.emptyStoreNonmodifiableTitle).bold()
Text("empty_store_nonmodifiable_description") Text(.emptyStoreNonmodifiableDescription)
Text("empty_store_nonmodifiable_supported_key_types") Text(.emptyStoreNonmodifiableSupportedKeyTypes)
}.frame(maxWidth: .infinity, maxHeight: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@@ -49,8 +49,8 @@ struct EmptyStoreModifiableView: View {
path.addLine(to: CGPoint(x: g.size.width - 3, y: 0)) path.addLine(to: CGPoint(x: g.size.width - 3, y: 0))
}.fill() }.fill()
}.frame(height: (windowGeometry.size.height/2) - 20).padding() }.frame(height: (windowGeometry.size.height/2) - 20).padding()
Text("empty_store_modifiable_click_here_title").bold() Text(.emptyStoreModifiableClickHereTitle).bold()
Text("empty_store_modifiable_click_here_description") Text(.emptyStoreModifiableClickHereDescription)
Spacer() Spacer()
}.frame(maxWidth: .infinity, maxHeight: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} }

View File

@@ -4,10 +4,10 @@ struct NoStoresView: View {
var body: some View { var body: some View {
VStack { VStack {
Text("no_secure_storage_title") Text(.noSecureStorageTitle)
.bold() .bold()
Text("no_secure_storage_description") Text(.noSecureStorageDescription)
Link("no_secure_storage_yubico_link", destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!) Link(.noSecureStorageYubicoLink, destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
}.padding() }.padding()
} }

View File

@@ -18,7 +18,7 @@ struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
.padding() .padding()
VStack { VStack {
HStack { HStack {
Text("rename_title_\(secret.name)") Text(.renameTitle(secretName: secret.name))
Spacer() Spacer()
} }
HStack { HStack {
@@ -28,10 +28,10 @@ struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
} }
HStack { HStack {
Spacer() Spacer()
Button("rename_rename_button", action: rename) Button(.renameRenameButton, action: rename)
.disabled(newName.count == 0) .disabled(newName.count == 0)
.keyboardShortcut(.return) .keyboardShortcut(.return)
Button("rename_cancel_button") { Button(.renameCancelButton) {
dismissalBlock(false) dismissalBlock(false)
}.keyboardShortcut(.cancelAction) }.keyboardShortcut(.cancelAction)
} }

View File

@@ -12,16 +12,16 @@ struct SecretDetailView<SecretType: Secret>: View {
ScrollView { ScrollView {
Form { Form {
Section { Section {
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret)) CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
CopyableView(title: "secret_detail_md5_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret)) CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
CopyableView(title: "secret_detail_public_key_label", image: Image(systemName: "key"), text: keyString) CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString)
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
CopyableView(title: "secret_detail_public_key_path_label", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret)) CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
Spacer() Spacer()
} }
} }

View File

@@ -39,10 +39,10 @@ struct SecretListItemView: View {
.contextMenu { .contextMenu {
if store is AnySecretStoreModifiable { if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) { Button(action: { isRenaming = true }) {
Text("secret_list_rename_button") Text(.secretListRenameButton)
} }
Button(action: { isDeleting = true }) { Button(action: { isDeleting = true }) {
Text("secret_list_delete_button") Text(.secretListDeleteButton)
} }
} }
} }

View File

@@ -2,124 +2,6 @@ import SwiftUI
struct SetupView: View { struct SetupView: View {
@Binding var visible: Bool
@Binding var setupComplete: Bool
@State var installed = false
@State var updates = false
@State var sshConfig = false
var body: some View {
VStack(spacing: 0) {
NewStepView(title: "setup_agent_title", description: "setup_agent_description") {
OnboardingButton("setup_agent_install_button", installed) {
Task {
await LaunchAgentController().install()
installed = true
}
}
}
Divider()
NewStepView(title: "setup_updates_title", description: "setup_updates_description") {
OnboardingButton("setup_updates_ok", false) {
Task {
updates = true
}
}
}
Divider()
NewStepView(title: "setup_ssh_title", description: "setup_ssh_description") {
HStack {
OnboardingButton("setup_ssh_added_manually_button", false) {
sshConfig = true
}
OnboardingButton("Add Automatically", false) {
// let controller = ShellConfigurationController()
// if controller.addToShell(shellInstructions: selectedShellInstruction) {
// }
sshConfig = true
}
}
}
}
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500)
.padding()
}
}
struct OnboardingButton: View {
let label: LocalizedStringResource
let complete: Bool
let action: () -> Void
init(_ label: LocalizedStringResource, _ complete: Bool, action: @escaping () -> Void) {
self.label = label
self.complete = complete
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text(label)
if complete {
Image(systemName: "checkmark.circle.fill")
}
}
.padding(.vertical, 2)
}
.disabled(complete)
.styled
}
}
extension View {
@ViewBuilder
var styled: some View {
if #available(macOS 26.0, *) {
buttonStyle(.glassProminent)
} else {
buttonStyle(.borderedProminent)
}
}
}
struct NewStepView<Content: View>: View {
let title: LocalizedStringResource
let description: LocalizedStringResource
let actions: Content
init(title: LocalizedStringResource, description: LocalizedStringResource, actions: () -> Content) {
self.title = title
self.description = description
self.actions = actions()
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.bold()
Text(description)
}
Spacer(minLength: 20)
actions
}
.padding(20)
}
}
struct OldSetupView: View {
@State var stepIndex = 0 @State var stepIndex = 0
@Binding var visible: Bool @Binding var visible: Bool
@Binding var setupComplete: Bool @Binding var setupComplete: Bool
@@ -179,7 +61,7 @@ struct StepView: View {
Circle() Circle()
.foregroundColor(.green) .foregroundColor(.green)
.frame(width: Constants.circleWidth, height: Constants.circleWidth) .frame(width: Constants.circleWidth, height: Constants.circleWidth)
Text("setup_step_complete_symbol") Text(.setupStepCompleteSymbol)
.foregroundColor(.white) .foregroundColor(.white)
.bold() .bold()
} else { } else {
@@ -219,14 +101,14 @@ extension StepView {
struct SetupStepView<Content> : View where Content : View { struct SetupStepView<Content> : View where Content : View {
let title: LocalizedStringKey let title: LocalizedStringResource
let image: Image let image: Image
let bodyText: LocalizedStringKey let bodyText: LocalizedStringResource
let buttonTitle: LocalizedStringKey let buttonTitle: LocalizedStringResource
let buttonAction: () -> Void let buttonAction: () -> Void
let content: Content let content: Content
init(title: LocalizedStringKey, image: Image, bodyText: LocalizedStringKey, buttonTitle: LocalizedStringKey, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) { init(title: LocalizedStringResource, image: Image, bodyText: LocalizedStringResource, buttonTitle: LocalizedStringResource, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
self.title = title self.title = title
self.image = image self.image = image
self.bodyText = bodyText self.bodyText = bodyText
@@ -263,12 +145,12 @@ struct SecretAgentSetupView: View {
let buttonAction: () -> Void let buttonAction: () -> Void
var body: some View { var body: some View {
SetupStepView(title: "setup_agent_title", SetupStepView(title: .setupAgentTitle,
image: Image(nsImage: NSApplication.shared.applicationIconImage), image: Image(nsImage: NSApplication.shared.applicationIconImage),
bodyText: "setup_agent_description", bodyText: .setupAgentDescription,
buttonTitle: "setup_agent_install_button", buttonTitle: .setupAgentInstallButton,
buttonAction: install) { buttonAction: install) {
Text("setup_agent_activity_monitor_description") Text(.setupAgentActivityMonitorDescription)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
} }
@@ -290,12 +172,12 @@ struct SSHAgentSetupView: View {
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first! @State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
var body: some View { var body: some View {
SetupStepView(title: "setup_ssh_title", SetupStepView(title: .setupSshTitle,
image: Image(systemName: "terminal"), image: Image(systemName: "terminal"),
bodyText: "setup_ssh_description", bodyText: .setupSshDescription,
buttonTitle: "setup_ssh_added_manually_button", buttonTitle: .setupSshAddedManuallyButton,
buttonAction: buttonAction) { buttonAction: buttonAction) {
Link("setup_third_party_faq_link", destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!) Link(.setupThirdPartyFaqLink, destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
Picker(selection: $selectedShellInstruction, label: EmptyView()) { Picker(selection: $selectedShellInstruction, label: EmptyView()) {
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
Text(instruction.shell) Text(instruction.shell)
@@ -303,8 +185,8 @@ struct SSHAgentSetupView: View {
.padding() .padding()
} }
}.pickerStyle(SegmentedPickerStyle()) }.pickerStyle(SegmentedPickerStyle())
CopyableView(title: "setup_ssh_add_to_config_button_\(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text) CopyableView(title: .setupSshAddToConfigButton(configPath: selectedShellInstruction.shellConfigPath), image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
Button("setup_ssh_add_for_me_button") { Button(.setupSshAddForMeButton) {
let controller = ShellConfigurationController() let controller = ShellConfigurationController()
if controller.addToShell(shellInstructions: selectedShellInstruction) { if controller.addToShell(shellInstructions: selectedShellInstruction) {
buttonAction() buttonAction()
@@ -334,12 +216,12 @@ struct UpdaterExplainerView: View {
let buttonAction: () -> Void let buttonAction: () -> Void
var body: some View { var body: some View {
SetupStepView(title: "setup_updates_title", SetupStepView(title: .setupUpdatesTitle,
image: Image(systemName: "dot.radiowaves.left.and.right"), image: Image(systemName: "dot.radiowaves.left.and.right"),
bodyText: "setup_updates_description", bodyText: .setupUpdatesDescription,
buttonTitle: "setup_updates_ok", buttonTitle: .setupUpdatesOk,
buttonAction: buttonAction) { buttonAction: buttonAction) {
Link("setup_updates_readmore", destination: SetupView.Constants.updaterFAQURL) Link(.setupUpdatesReadmore, destination: SetupView.Constants.updaterFAQURL)
} }
} }

View File

@@ -6,7 +6,7 @@ struct StoreListView: View {
@Binding var activeSecret: AnySecret? @Binding var activeSecret: AnySecret?
@Environment(SecretStoreList.self) private var storeList: SecretStoreList @Environment(\.secretStoreList) private var storeList
private func secretDeleted(secret: AnySecret) { private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret activeSecret = nextDefaultSecret
@@ -22,9 +22,6 @@ struct StoreListView: View {
ForEach(storeList.stores) { store in ForEach(storeList.stores) { store in
if store.isAvailable { if store.isAvailable {
Section(header: Text(store.name)) { Section(header: Text(store.name)) {
if store.secrets.isEmpty {
EmptyStoreView(store: store)
} else {
ForEach(store.secrets) { secret in ForEach(store.secrets) { secret in
SecretListItemView( SecretListItemView(
store: store, store: store,
@@ -37,12 +34,15 @@ struct StoreListView: View {
} }
} }
} }
}
} detail: { } detail: {
if let activeSecret { if let activeSecret {
SecretDetailView(secret: activeSecret) SecretDetailView(secret: activeSecret)
} else if let nextDefaultSecret {
// This just means onAppear hasn't executed yet.
// Do this to avoid a blip.
SecretDetailView(secret: nextDefaultSecret)
} else { } else {
EmptyStoreView(store: storeList.stores.first) EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
} }
} }
.navigationSplitViewStyle(.balanced) .navigationSplitViewStyle(.balanced)
@@ -57,7 +57,7 @@ struct StoreListView: View {
extension StoreListView { extension StoreListView {
private var nextDefaultSecret: AnySecret? { private var nextDefaultSecret: AnySecret? {
return storeList.stores.compactMap(\.secrets.first).first return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
} }
} }

View File

@@ -17,9 +17,28 @@ struct ToolbarButtonStyle: ButtonStyle {
self.darkColor = darkColor self.darkColor = darkColor
} }
@available(macOS 26.0, *)
private var glassTint: Color {
if !hovering {
colorScheme == .light ? lightColor : darkColor
} else {
colorScheme == .light ? lightColor.exposureAdjust(1) : darkColor.exposureAdjust(1)
}
}
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label if #available(macOS 26.0, *) {
configuration
.label
.foregroundColor(.white)
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8)) .padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
.glassEffect(.regular.tint(glassTint), in: .capsule)
.onHover { hovering in
self.hovering = hovering
}
} else {
configuration
.label
.background(colorScheme == .light ? lightColor : darkColor) .background(colorScheme == .light ? lightColor : darkColor)
.foregroundColor(.white) .foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 5)) .clipShape(RoundedRectangle(cornerRadius: 5))
@@ -35,3 +54,4 @@ struct ToolbarButtonStyle: ButtonStyle {
} }
} }
} }
}

View File

@@ -1,30 +1,30 @@
import SwiftUI import SwiftUI
import Brief import Brief
struct UpdateDetailView<UpdaterType: Updater>: View { struct UpdateDetailView: View {
@Environment(UpdaterType.self) var updater: UpdaterType @Environment(\.updater) var updater: any UpdaterProtocol
let update: Release let update: Release
var body: some View { var body: some View {
VStack { VStack {
Text("update_version_name_\(update.name)").font(.title) Text(.updateVersionName(updateName: update.name)).font(.title)
GroupBox(label: Text("update_release_notes_title")) { GroupBox(label: Text(.updateReleaseNotesTitle)) {
ScrollView { ScrollView {
attributedBody attributedBody
} }
} }
HStack { HStack {
if !update.critical { if !update.critical {
Button("update_ignore_button") { Button(.updateIgnoreButton) {
Task { Task {
await updater.ignore(release: update) await updater.ignore(release: update)
} }
} }
Spacer() Spacer()
} }
Button("update_update_button") { Button(.updateUpdateButton) {
NSWorkspace.shared.open(update.html_url) NSWorkspace.shared.open(update.html_url)
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)