Compare commits

..

22 Commits

Author SHA1 Message Date
Max Goedjen
52a351d75e Fix toolbar appearance 2025-08-10 14:58:44 -07:00
Max Goedjen
53a23b265a Update to Xcode 26. 2025-08-10 14:56:42 -07:00
Max Goedjen
e0c2775971 Merge branch 'main' into swift6 2025-08-10 14:48:46 -07:00
Max Goedjen
dc714f9b38 Bump back down to 6.1 2025-08-10 14:41:43 -07:00
Max Goedjen
7413d78558 Merge branch 'xcode_bumps' into swift6 2025-08-10 14:40:07 -07:00
Max Goedjen
163d38c12e Bump to latest public macOS and Xcode 2025-08-10 14:38:16 -07:00
Max Goedjen
f9e512e6c6 Update package. 2025-08-10 14:34:33 -07:00
Max Goedjen
f8de78210b Fix preview. 2025-08-10 14:34:29 -07:00
Max Goedjen
81f5b41d6a Reenable 2025-08-10 14:27:04 -07:00
Max Goedjen
998f4b9bf4 WIP 2025-08-10 14:23:57 -07:00
Max Goedjen
bab76da2ab Revert "Backport mutex"
This reverts commit 9b02afb20c.
2025-01-05 16:27:41 -08:00
Max Goedjen
9b02afb20c Backport mutex 2025-01-05 16:25:16 -08:00
Max Goedjen
576e625b8f More 2025-01-05 16:07:11 -08:00
Max Goedjen
304741e019 Tweak async for updater 2025-01-05 00:37:03 -08:00
Max Goedjen
8e707545d1 Finish Testing migration 2025-01-05 00:22:11 -08:00
Max Goedjen
e332b7cb9d Base 2025-01-04 23:16:47 -08:00
Max Goedjen
c09ad3ecc1 Bump runners 2025-01-04 15:26:49 -08:00
Max Goedjen
28a4dafad4 Switch to SMAppService 2025-01-04 01:10:57 -08:00
Max Goedjen
c2563be404 Fix concurrency issues in SmartCardStore 2025-01-04 01:06:54 -08:00
Max Goedjen
970e407e29 WIP 2024-12-26 19:28:30 -05:00
Max Goedjen
2dc317d398 WIP 2024-12-25 18:25:01 -05:00
Max Goedjen
8ea8f0510c Enable language mode 2024-12-25 11:59:24 -05:00
77 changed files with 977 additions and 1975 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

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

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: macos-15
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -21,19 +21,18 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
- name: Test
run: swift build --build-system swiftbuild --package-path Sources/Packages
run: |
pushd Sources/Packages
swift test
popd
build:
# runs-on: macOS-latest
runs-on: macos-15
permissions:
id-token: write
contents: write
attestations: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -44,7 +43,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
- name: Update Build Number
env:
TAG_NAME: ${{ github.ref }}
@@ -59,29 +58,54 @@ jobs:
- name: Create ZIPs
run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Xcode_Archive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
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
- name: Attest
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-path: 'Secretive.zip, Xcode_Archive.zip'
- name: Create Release
- name: Document SHAs
run: |
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
echo "sha-512:"
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
echo "sha-256:"
shasum -a 256 Secretive.zip
shasum -a 256 Archive.zip
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref }}
RUN_ID: ${{ github.run_id }}
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
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
uses: actions/upload-artifact@v4
with:
@@ -91,4 +115,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: Xcode_Archive.zip
path: Xcode_Archive.zip
path: Archive.zip

View File

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

View File

@@ -110,15 +110,6 @@ 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)
## 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!
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
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).
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).
## Collaborator Status

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,59 +0,0 @@
{
"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"
]
}
}

View File

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

View File

@@ -49,7 +49,7 @@ There's a [FAQ here](FAQ.md).
### Auditable Build Process
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).
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.
### A Note Around Code Signing and Keychains

View File

@@ -1,13 +1,12 @@
// swift-tools-version:6.2
// swift-tools-version:6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "SecretivePackages",
defaultLocalization: "en",
platforms: [
.macOS(.v14)
.macOS(.v15)
],
products: [
.library(
@@ -35,7 +34,6 @@ let package = Package(
.target(
name: "SecretKit",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings
),
.testTarget(
@@ -46,19 +44,16 @@ let package = Package(
.target(
name: "SecureEnclaveSecretKit",
dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings
),
.target(
name: "SmartCardSecretKit",
dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings
),
.target(
name: "SecretAgentKit",
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
resources: [localization],
swiftSettings: swiftSettings
),
.systemLibrary(
@@ -71,7 +66,6 @@ let package = Package(
.target(
name: "Brief",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings
),
.testTarget(
@@ -81,13 +75,9 @@ let package = Package(
]
)
var localization: Resource {
.process("../../Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error),
.unsafeFlags(["-warnings-as-errors"])
]
}

View File

@@ -1,18 +1,14 @@
import Foundation
import Observation
import Synchronization
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
@Observable public final class Updater: UpdaterProtocol, Sendable {
@Observable public final class Updater: UpdaterProtocol, ObservableObject, Sendable {
private let state = State()
@MainActor @Observable public final class State {
var update: Release? = nil
nonisolated init() {}
}
public var update: Release? {
state.update
_update.withLock { $0 }
}
private let _update: Mutex<Release?> = .init(nil)
public let testBuild: Bool
/// The current OS version.
@@ -26,12 +22,7 @@ import Observation
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
/// - osVersion: The current OS version.
/// - 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.currentVersion = currentVersion
testBuild = currentVersion == SemVer("0.0.0")
@@ -62,7 +53,9 @@ import Observation
guard !release.critical else { return }
defaults.set(true, forKey: release.name)
await MainActor.run {
state.update = nil
_update.withLock { value in
value = nil
}
}
}
@@ -83,7 +76,9 @@ extension Updater {
let latestVersion = SemVer(release.name)
if latestVersion > currentVersion {
await MainActor.run {
state.update = release
_update.withLock { value in
value = release
}
}
}
}

View File

@@ -1,13 +1,13 @@
import Foundation
import Synchronization
/// A protocol for retreiving the latest available version of an app.
public protocol UpdaterProtocol: Observable, Sendable {
public protocol UpdaterProtocol: Observable {
/// The latest update
@MainActor var update: Release? { get }
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)
var testBuild: Bool { get }
func ignore(release: Release) async
}

View File

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

View File

@@ -0,0 +1,11 @@
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")
guard let new = notification.object as? FileHandle else { return }
logger.debug("Socket controller received new file handle")
Task { [handler, logger = logger] in
Task { [handler, logger = UncheckedSendable(logger)] in
if((await handler?(new, new)) == true) {
logger.debug("Socket controller handled data, wait for more data")
logger.value.debug("Socket controller handled data, wait for more data")
await new.waitForDataInBackgroundAndNotifyOnMainActor()
} else {
logger.debug("Socket controller called with empty data, socked closed")
logger.value.debug("Socket controller called with empty data, socked closed")
}
}
}

View File

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

View File

@@ -1,13 +1,14 @@
import Foundation
import OSLog
import Synchronization
/// Manages storage and lookup for OpenSSH certificates.
public actor OpenSSHCertificateHandler: Sendable {
public final class OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
private let keyBlobsAndNames: Mutex<[AnySecret: (Data, Data)]> = .init([:])
/// Initializes an OpenSSHCertificateHandler.
public init() {
@@ -20,11 +21,24 @@ public actor OpenSSHCertificateHandler: Sendable {
logger.log("No certificates, short circuiting")
return
}
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
partialResult[next] = try? loadKeyblobAndName(for: next)
keyBlobsAndNames.withLock {
$0 = secrets.reduce(into: [:]) { partialResult, next in
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.withLock {
$0[AnySecret(secret)] != nil
}
}
/// 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
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
@@ -53,7 +67,9 @@ public actor OpenSSHCertificateHandler: Sendable {
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames[AnySecret(secret)]
keyBlobsAndNames.withLock {
$0[AnySecret(secret)]
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``

View File

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

View File

@@ -7,13 +7,13 @@ public protocol SecretStore: Identifiable, Sendable {
associatedtype SecretType: Secret
/// A boolean indicating whether or not the store is available.
@MainActor var isAvailable: Bool { get }
var isAvailable: Bool { get }
/// A unique identifier for the store.
var id: UUID { get }
/// A user-facing name for the store.
@MainActor var name: String { get }
var name: String { get }
/// The secrets the store manages.
@MainActor var secrets: [SecretType] { get }
var secrets: [SecretType] { get }
/// Signs a data payload with a specified Secret.
/// - Parameters:
@@ -23,6 +23,14 @@ public protocol SecretStore: Identifiable, Sendable {
/// - Returns: The signed 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.
/// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for.

View File

@@ -2,7 +2,7 @@ import Foundation
import AppKit
/// Describes the chain of applications that requested a signature operation.
public struct SigningRequestProvenance: Equatable, Sendable {
public struct SigningRequestProvenance: Equatable {
/// A list of processes involved in the request.
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
@@ -30,7 +30,7 @@ extension SigningRequestProvenance {
extension SigningRequestProvenance {
/// Describes a process in a `SigningRequestProvenance` chain.
public struct Process: Equatable, Sendable {
public struct Process: Equatable {
/// The pid of the process.
public let pid: Int32

View File

@@ -1,72 +0,0 @@
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,30 +2,35 @@ import Foundation
import Observation
import Security
import CryptoKit
import LocalAuthentication
@preconcurrency import LocalAuthentication
import SecretKit
import Synchronization
extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave.
@Observable public final class Store: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
public var isAvailable: Bool {
CryptoKit.SecureEnclave.isAvailable
}
public let id = UUID()
public let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
public let name = String(localized: "secure_enclave")
public var secrets: [Secret] {
_secrets.withLock { $0 }
}
private let _secrets: Mutex<[Secret]> = .init([])
private let persistedAuthenticationContexts: Mutex<[Secret: PersistentAuthenticationContext]> = .init([:])
/// Initializes a Store.
@MainActor public init() {
loadSecrets()
public init() {
Task {
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false)
}
}
loadSecrets()
}
// MARK: Public API
@@ -99,16 +104,49 @@ extension SecureEnclave {
await reloadSecretsInternal()
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
let context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context
} else {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
let context: Mutex<LAContext>
// if let existing = persistedAuthenticationContexts.withLock({ $0 })[secret], existing.valid {
// context = existing.context
// } else {
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
context = .init(newContext)
// }
return try context.withLock { context in
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
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 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
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
}
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let context = LAContext()
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,
@@ -118,7 +156,8 @@ extension SecureEnclave {
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
])
var verifyError: SecurityError?
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
@@ -128,20 +167,43 @@ extension SecureEnclave {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
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 signature as Data
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts.withLock({ $0 })[secret], persisted.valid else { return nil }
return persisted
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
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 {
@@ -156,9 +218,11 @@ extension SecureEnclave.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.
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
private func reloadSecretsInternal(notifyAgent: Bool = true) async {
let before = secrets
secrets.removeAll()
_secrets.withLock {
$0.removeAll()
}
loadSecrets()
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
@@ -169,7 +233,7 @@ extension SecureEnclave.Store {
}
/// Loads all secrets from the store.
@MainActor private func loadSecrets() {
private func loadSecrets() {
let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
@@ -205,7 +269,7 @@ extension SecureEnclave.Store {
nil)!
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
@@ -220,7 +284,9 @@ extension SecureEnclave.Store {
}
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
_secrets.withLock {
$0.append(contentsOf: wrapped)
}
}
/// Saves a public key.
@@ -255,3 +321,42 @@ 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,4 +1,5 @@
import Foundation
import Synchronization
import Observation
import Security
import CryptoTokenKit
@@ -7,39 +8,37 @@ import SecretKit
extension SmartCard {
@MainActor @Observable fileprivate final class State {
private struct State {
var isAvailable = false
var name = String(localized: .smartCard)
var name = String(localized: "smart_card")
var secrets: [Secret] = []
let watcher = TKTokenWatcher()
var tokenID: String? = nil
nonisolated init() {}
}
/// An implementation of Store backed by a Smart Card.
@Observable public final class Store: SecretStore {
private let state = State()
private let state: Mutex<State> = .init(.init())
public var isAvailable: Bool {
state.isAvailable
state.withLock { $0.isAvailable }
}
public let id = UUID()
@MainActor public var name: String {
state.name
public var name: String {
state.withLock { $0.name }
}
public var secrets: [Secret] {
state.secrets
state.withLock { $0.secrets }
}
/// Initializes a Store.
public init() {
Task { @MainActor in
state.withLock { state in
if let tokenID = state.tokenID {
state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
}
loadSecrets()
state.watcher.setInsertionHandler { id in
// Setting insertion handler will cause it to be called immediately.
// Make a thread jump so we don't hit a recursive lock attempt.
@@ -48,6 +47,7 @@ extension SmartCard {
}
}
}
loadSecrets()
}
// MARK: Public API
@@ -60,11 +60,11 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() }
let context = LAContext()
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@@ -89,6 +89,29 @@ extension SmartCard {
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? {
nil
}
@@ -97,7 +120,7 @@ extension SmartCard {
}
/// Reloads all secrets from the store.
@MainActor public func reloadSecrets() {
public func reloadSecrets() {
reloadSecretsInternal()
}
@@ -107,11 +130,14 @@ extension SmartCard {
extension SmartCard.Store {
@MainActor private func reloadSecretsInternal() {
let before = state.secrets
state.isAvailable = state.tokenID != nil
state.secrets.removeAll()
loadSecrets()
private func reloadSecretsInternal() {
let before = state.withLock {
$0.isAvailable = $0.tokenID != nil
let before = $0.secrets
$0.secrets.removeAll()
return before
}
self.loadSecrets()
if self.secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
}
@@ -119,31 +145,37 @@ extension SmartCard.Store {
/// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was inserted.
@MainActor private func smartcardInserted(for tokenID: String? = nil) {
private func smartcardInserted(for tokenID: String? = nil) {
state.withLock { state in
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
guard state.tokenID == nil else { return }
guard !string.contains("setoken") else { return }
state.tokenID = string
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
state.tokenID = string
}
}
/// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was removed.
@MainActor private func smartcardRemoved(for tokenID: String? = nil) {
state.tokenID = nil
private func smartcardRemoved(for tokenID: String? = nil) {
state.withLock {
$0.tokenID = nil
}
reloadSecrets()
}
/// Loads all secrets from the store.
@MainActor private func loadSecrets() {
guard let tokenID = state.tokenID else { return }
private func loadSecrets() {
guard let tokenID = state.withLock({ $0.tokenID }) else { return }
let fallbackName = String(localized: .smartCard)
if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
state.name = driverName
} else {
state.name = fallbackName
let fallbackName = String(localized: "smart_card")
state.withLock {
if let driverName = $0.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
$0.name = driverName
} else {
$0.name = fallbackName
}
}
let attributes = KeychainDictionary([
@@ -157,7 +189,7 @@ extension SmartCard.Store {
SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped = typed.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
let keySize = $0[kSecAttrKeySizeInBits] as! Int
@@ -167,7 +199,9 @@ extension SmartCard.Store {
let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
}
state.secrets.append(contentsOf: wrapped)
state.withLock {
$0.secrets.append(contentsOf: wrapped)
}
}
}
@@ -184,8 +218,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.
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
let context = LAContext()
context.localizedReason = String(localized: .authContextRequestEncryptDescription(secretName: secret.name))
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
@@ -210,11 +244,11 @@ extension SmartCard.Store {
/// - secret: The secret to decrypt with.
/// - 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.
public func decrypt(data: Data, with secret: SecretType) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() }
public func decrypt(data: Data, with secret: SecretType) throws -> Data {
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() }
let context = LAContext()
context.localizedReason = String(localized: .authContextRequestDecryptDescription(secretName: secret.name))
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,

View File

@@ -2,6 +2,7 @@ import Testing
import Foundation
@testable import Brief
@Suite struct ReleaseParsingTests {
@Test
@@ -59,7 +60,7 @@ import Foundation
}
@Test
@MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws {
func greatestSelectedIfOldPatchIsPublishedLater() async throws {
// 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.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
@@ -77,7 +78,7 @@ import Foundation
}
@Test
@MainActor func latestVersionIsRunnable() async throws {
func latestVersionIsRunnable() async throws {
// 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.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))

View File

@@ -19,7 +19,7 @@ import CryptoKit
@Test func identitiesList() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
@@ -29,7 +29,7 @@ import CryptoKit
@Test func noMatchingIdentities() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
@@ -40,7 +40,7 @@ import CryptoKit
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
_ = requestReader.readNextChunk()
let dataToSign = requestReader.readNextChunk()
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
let outer = OpenSSHReader(data: stubWriter.data[5...])
@@ -60,17 +60,25 @@ import CryptoKit
}
var rs = r
rs.append(s)
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
// Correct signature
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
.isValidSignature(signature, for: dataToSign))
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
let store = list.stores.first!
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
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
@Test func witnessObjectionStopsRequest() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
let witness = StubWitness(speakNow: { _,_ in
return true
}, witness: { _, _ in })
@@ -81,8 +89,8 @@ import CryptoKit
@Test func witnessSignature() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
nonisolated(unsafe) var witnessed = false
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
var witnessed = false
let witness = StubWitness(speakNow: { _, trace in
return false
}, witness: { _, trace in
@@ -95,9 +103,9 @@ import CryptoKit
@Test func requestTracing() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
var speakNowTrace: SigningRequestProvenance! = nil
var witnessTrace: SigningRequestProvenance! = nil
let witness = StubWitness(speakNow: { _, trace in
speakNowTrace = trace
return false
@@ -107,17 +115,17 @@ import CryptoKit
let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(witnessTrace == speakNowTrace)
#expect(witnessTrace?.origin.displayName == "Finder")
#expect(witnessTrace?.origin.validSignature == true)
#expect(witnessTrace?.origin.parentPID == 1)
#expect(witnessTrace.origin.displayName == "Finder")
#expect(witnessTrace.origin.validSignature == true)
#expect(witnessTrace.origin.parentPID == 1)
}
// MARK: Exception Handling
@Test func signatureException() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = await list.stores.first?.base as! Stub.Store
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = list.stores.first?.base as! Stub.Store
store.shouldThrow = true
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
@@ -137,7 +145,7 @@ import CryptoKit
extension AgentTests {
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList {
let store = Stub.Store()
store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList()

View File

@@ -61,6 +61,29 @@ extension Stub {
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? {
nil
}

View File

@@ -3,8 +3,8 @@ import SecretAgentKit
struct StubWitness {
let speakNow: @Sendable (AnySecret, SigningRequestProvenance) -> Bool
let witness: @Sendable (AnySecret, SigningRequestProvenance) -> ()
let speakNow: (AnySecret, SigningRequestProvenance) -> Bool
let witness: (AnySecret, SigningRequestProvenance) -> ()
}

View File

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

View File

@@ -0,0 +1,60 @@
{
"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.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

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

View File

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

View File

@@ -18,9 +18,7 @@
5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; };
5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; };
5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; };
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 */; };
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; };
501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; };
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 */; };
@@ -47,11 +45,13 @@
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; };
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.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 */; };
50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; };
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.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 */
/* Begin PBXContainerItemProxy section */
@@ -102,7 +102,7 @@
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>"; };
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; };
5008C23D2E525D8200507AC2 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Packages/Localizable.xcstrings; sourceTree = SOURCE_ROOT; };
500B93C22B478D8400E157DE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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>"; };
5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
@@ -133,6 +133,7 @@
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>"; };
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>"; };
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>"; };
@@ -210,7 +211,7 @@
508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */,
50617D8F23FCE48E0099B055 /* Secretive.entitlements */,
506772C62424784600034DED /* Credits.rtf */,
5008C23D2E525D8200507AC2 /* Localizable.xcstrings */,
500B93C22B478D8400E157DE /* Localizable.xcstrings */,
50617D8823FCE48E0099B055 /* Preview Content */,
);
path = Secretive;
@@ -280,6 +281,7 @@
children = (
50020BAF24064869003D4025 /* AppDelegate.swift */,
5018F54E24064786002EB505 /* Notifier.swift */,
50A3B79024026B7600D209EA /* Assets.xcassets */,
50A3B79524026B7600D209EA /* Main.storyboard */,
50A3B79824026B7600D209EA /* Info.plist */,
508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */,
@@ -384,8 +386,6 @@
fi,
ko,
ca,
ru,
pl,
);
mainGroup = 50617D7623FCE48D0099B055;
productRefGroup = 50617D8023FCE48E0099B055 /* Products */;
@@ -404,7 +404,7 @@
buildActionMask = 2147483647;
files = (
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */,
5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */,
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */,
50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */,
506772C72424784600034DED /* Credits.rtf in Resources */,
508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */,
@@ -416,10 +416,10 @@
buildActionMask = 2147483647;
files = (
50A3B79724026B7600D209EA /* Main.storyboard in Resources */,
5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */,
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */,
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */,
50A3B79124026B7600D209EA /* Assets.xcassets in Resources */,
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */,
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -526,8 +526,6 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -600,9 +598,7 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -641,10 +637,8 @@
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
INFOPLIST_FILE = Secretive/Info.plist;
@@ -652,7 +646,6 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -673,10 +666,8 @@
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
INFOPLIST_FILE = Secretive/Info.plist;
@@ -684,7 +675,6 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -731,8 +721,6 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -770,17 +758,14 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
INFOPLIST_FILE = Secretive/Info.plist;
@@ -788,7 +773,6 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -812,7 +796,6 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -838,7 +821,6 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -865,7 +847,6 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -1,8 +0,0 @@
<?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,33 +6,28 @@ import SmartCardSecretKit
import Brief
extension EnvironmentValues {
// 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 = {
@Entry var secretStoreList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
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
}
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = AgentStatusChecker()
@Entry var updater: any UpdaterProtocol = Updater(checkOnLaunch: false)
}
@main
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()
@Environment(\.agentStatusChecker) var agentStatusChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@State private var showingCreation = false
@@ -40,7 +35,9 @@ struct Secretive: App {
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environment(EnvironmentValues._secretStoreList)
.environment(storeList)
.environment(Updater(checkOnLaunch: hasRunSetup))
.environment(agentStatusChecker)
.onAppear {
if !hasRunSetup {
showingSetup = true
@@ -59,18 +56,18 @@ struct Secretive: App {
}
.commands {
CommandGroup(after: CommandGroupPlacement.newItem) {
Button(.appMenuNewSecretButton) {
Button("app_menu_new_secret_button") {
showingCreation = true
}
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
}
CommandGroup(replacing: .help) {
Button(.appMenuHelpButton) {
Button("app_menu_help_button") {
NSWorkspace.shared.open(Constants.helpURL)
}
}
CommandGroup(after: .help) {
Button(.appMenuSetupButton) {
Button("app_menu_setup_button") {
showingSetup = true
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,35 @@
import Foundation
import Synchronization
import Observation
import Brief
@Observable @MainActor final class PreviewUpdater: UpdaterProtocol {
@Observable class PreviewUpdater: UpdaterProtocol {
var update: Release? = nil
var update: Release? {
_update.withLock { $0 }
}
let _update: Mutex<Release?> = .init(nil)
let testBuild = false
init(update: Update = .none) {
switch update {
case .none:
self.update = nil
_update.withLock {
$0 = nil
}
case .advisory:
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
_update.withLock {
$0 = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
}
case .critical:
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
_update.withLock {
$0 = 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 {

View File

@@ -2,16 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.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>
<true/>
<key>keychain-access-groups</key>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ struct StepView: View {
Circle()
.foregroundColor(.green)
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
Text(.setupStepCompleteSymbol)
Text("setup_step_complete_symbol")
.foregroundColor(.white)
.bold()
} else {
@@ -101,14 +101,14 @@ extension StepView {
struct SetupStepView<Content> : View where Content : View {
let title: LocalizedStringResource
let title: LocalizedStringKey
let image: Image
let bodyText: LocalizedStringResource
let buttonTitle: LocalizedStringResource
let bodyText: LocalizedStringKey
let buttonTitle: LocalizedStringKey
let buttonAction: () -> Void
let content: Content
init(title: LocalizedStringResource, image: Image, bodyText: LocalizedStringResource, buttonTitle: LocalizedStringResource, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
init(title: LocalizedStringKey, image: Image, bodyText: LocalizedStringKey, buttonTitle: LocalizedStringKey, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
self.title = title
self.image = image
self.bodyText = bodyText
@@ -145,12 +145,12 @@ struct SecretAgentSetupView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: .setupAgentTitle,
SetupStepView(title: "setup_agent_title",
image: Image(nsImage: NSApplication.shared.applicationIconImage),
bodyText: .setupAgentDescription,
buttonTitle: .setupAgentInstallButton,
bodyText: "setup_agent_description",
buttonTitle: "setup_agent_install_button",
buttonAction: install) {
Text(.setupAgentActivityMonitorDescription)
Text("setup_agent_activity_monitor_description")
.multilineTextAlignment(.center)
}
}
@@ -172,12 +172,12 @@ struct SSHAgentSetupView: View {
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
var body: some View {
SetupStepView(title: .setupSshTitle,
SetupStepView(title: "setup_ssh_title",
image: Image(systemName: "terminal"),
bodyText: .setupSshDescription,
buttonTitle: .setupSshAddedManuallyButton,
bodyText: "setup_ssh_description",
buttonTitle: "setup_ssh_added_manually_button",
buttonAction: buttonAction) {
Link(.setupThirdPartyFaqLink, destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
Link("setup_third_party_faq_link", destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
Text(instruction.shell)
@@ -185,8 +185,8 @@ struct SSHAgentSetupView: View {
.padding()
}
}.pickerStyle(SegmentedPickerStyle())
CopyableView(title: .setupSshAddToConfigButton(configPath: selectedShellInstruction.shellConfigPath), image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
Button(.setupSshAddForMeButton) {
CopyableView(title: "setup_ssh_add_to_config_button_\(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
Button("setup_ssh_add_for_me_button") {
let controller = ShellConfigurationController()
if controller.addToShell(shellInstructions: selectedShellInstruction) {
buttonAction()
@@ -216,12 +216,12 @@ struct UpdaterExplainerView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: .setupUpdatesTitle,
SetupStepView(title: "setup_updates_title",
image: Image(systemName: "dot.radiowaves.left.and.right"),
bodyText: .setupUpdatesDescription,
buttonTitle: .setupUpdatesOk,
bodyText: "setup_updates_description",
buttonTitle: "setup_updates_ok",
buttonAction: buttonAction) {
Link(.setupUpdatesReadmore, destination: SetupView.Constants.updaterFAQURL)
Link("setup_updates_readmore", destination: SetupView.Constants.updaterFAQURL)
}
}

View File

@@ -6,7 +6,7 @@ struct StoreListView: View {
@Binding var activeSecret: AnySecret?
@Environment(\.secretStoreList) private var storeList
@Environment(SecretStoreList.self) private var storeList: SecretStoreList
private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret
@@ -22,13 +22,17 @@ struct StoreListView: View {
ForEach(storeList.stores) { store in
if store.isAvailable {
Section(header: Text(store.name)) {
ForEach(store.secrets) { secret in
SecretListItemView(
store: store,
secret: secret,
deletedSecret: secretDeleted,
renamedSecret: secretRenamed
)
if store.secrets.isEmpty {
EmptyStoreView(store: store)
} else {
ForEach(store.secrets) { secret in
SecretListItemView(
store: store,
secret: secret,
deletedSecret: secretDeleted,
renamedSecret: secretRenamed
)
}
}
}
}
@@ -37,12 +41,8 @@ struct StoreListView: View {
} detail: {
if let 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 {
EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
EmptyStoreView(store: storeList.stores.first)
}
}
.navigationSplitViewStyle(.balanced)
@@ -57,7 +57,7 @@ struct StoreListView: View {
extension StoreListView {
private var nextDefaultSecret: AnySecret? {
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
return storeList.stores.compactMap(\.secrets.first).first
}
}

View File

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