Compare commits

..

21 Commits

Author SHA1 Message Date
Max Goedjen
f6f5e98302 POC 2025-09-01 20:28:47 -07:00
Max Goedjen
c352ed4cc9 Merge branch 'newsetup' into menubar 2025-09-01 20:19:05 -07:00
Max Goedjen
ea71993801 WIP 2025-09-01 20:07:58 -07:00
Max Goedjen
df2b7881c4 WIP 2025-09-01 19:37:59 -07:00
Max Goedjen
74ddb9595b WIP 2025-09-01 19:31:16 -07:00
Max Goedjen
0980cdffcd WIP 2025-09-01 19:25:14 -07:00
Max Goedjen
90d55726bb WIP 2025-09-01 18:00:58 -07:00
Max Goedjen
a640d11b00 WIP 2025-09-01 17:50:40 -07:00
Max Goedjen
f3ce6b9d0f WIP 2025-09-01 17:43:33 -07:00
Max Goedjen
ea96dd88eb Cleanup 2025-09-01 16:27:15 -07:00
Max Goedjen
4d84621b3d WIP 2025-09-01 16:10:27 -07:00
Max Goedjen
2d05a7b0f3 WIP 2025-09-01 15:22:52 -07:00
Max Goedjen
c8d90ba455 WIP 2025-09-01 15:09:27 -07:00
Max Goedjen
9299bf343f WIP 2025-09-01 14:52:17 -07:00
Max Goedjen
fa658646d7 WIP 2025-08-31 13:24:37 -07:00
Max Goedjen
cd76bb95ec Tweaks. 2025-08-31 00:58:16 -07:00
Max Goedjen
b949d846c1 WIP 2025-08-30 18:56:52 -07:00
Max Goedjen
19760f1e02 Merge branch 'main' of github.com:maxgoedjen/secretive into newsetup 2025-08-30 15:40:52 -07:00
Max Goedjen
f60a44c599 WIP 2025-08-30 13:55:19 -07:00
Max Goedjen
260e63341d Merge branch 'main' into newsetup 2025-08-27 23:50:06 -07:00
Max Goedjen
cbf903deb7 WIP 2025-08-25 00:48:07 -07:00
139 changed files with 7889 additions and 30344 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 519 KiB

BIN
.github/readme/localize_add.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
.github/readme/localize_sidebar.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
.github/readme/localize_translate.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 259 KiB

View File

@@ -1,47 +0,0 @@
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '26 15 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
# Disable this until CodeQL supports Xcode 26 builds.
# - language: swift
# build-mode: manual
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
name: "Select Xcode"
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- if: matrix.build-mode == 'manual'
name: "Build"
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -3,15 +3,10 @@ name: Nightly
on:
schedule:
- cron: "0 8 * * *"
jobs:
build:
runs-on: macos-26
permissions:
id-token: write
contents: write
attestations: write
actions: read
# runs-on: macOS-latest
runs-on: macos-15
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
@@ -25,33 +20,20 @@ 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.4.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Update Build Number
env:
RUN_ID: ${{ github.run_id }}
run: |
DATE=$(date "+%Y-%m-%d")
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_nightly-$DATE/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4
with:
name: Secretive
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create ZIPs
run: |
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
@@ -61,5 +43,9 @@ jobs:
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
subject-path: 'Secretive.zip'
- name: Upload App to Artifacts
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Secretive.zip

View File

@@ -1,64 +0,0 @@
name: One-Off Build
on:
workflow_dispatch:
jobs:
build:
runs-on: macos-26
permissions:
id-token: write
contents: write
attestations: write
actions: read
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
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.4.app
- name: Update Build Number
env:
RUN_ID: ${{ github.run_id }}
run: |
DATE=$(date "+%Y-%m-%d")
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_oneoff-$DATE/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4
with:
name: Secretive
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.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-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}

View File

@@ -6,9 +6,8 @@ on:
- '*'
jobs:
test:
permissions:
contents: read
runs-on: macos-26
# runs-on: macOS-latest
runs-on: macos-15
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
@@ -22,18 +21,16 @@ 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.4.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Test
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently?
# run: swift test --build-system swiftbuild --package-path Sources/Packages
run: swift test --build-system swiftbuild --package-path Sources/Packages
build:
# runs-on: macOS-latest
runs-on: macos-15
permissions:
id-token: write
contents: write
attestations: write
actions: read
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
@@ -47,7 +44,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.4.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Update Build Number
env:
TAG_NAME: ${{ github.ref }}
@@ -56,25 +53,13 @@ jobs:
export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//')
sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create ZIPs
run: |
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Xcode_Archive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
@@ -84,15 +69,26 @@ jobs:
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-path: "Secretive.zip"
subject-path: 'Secretive.zip, Xcode_Archive.zip'
- name: Create Release
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref }}
RUN_ID: ${{ github.run_id }}
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
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 $TAG_NAME Secretive.zip
- name: Upload App to Artifacts
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Secretive.zip
- name: Upload Archive to Artifacts
uses: actions/upload-artifact@v4
with:
name: Xcode_Archive.zip
path: Xcode_Archive.zip

View File

@@ -3,17 +3,14 @@ name: Test
on: [push, pull_request]
jobs:
test:
permissions:
contents: read
runs-on: macos-26
# runs-on: macOS-latest
runs-on: macos-15
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Test Main Packages
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently?
# run: swift test --build-system swiftbuild --package-path Sources/Packages
run: swift test --build-system swiftbuild --package-path Sources/Packages
- name: Test SecretKit Packages
run: swift test --build-system swiftbuild

4
.gitignore vendored
View File

@@ -93,7 +93,3 @@ iOSInjectionProject/
Archive.xcarchive
.DS_Store
contents.xcworkspacedata
# Per-User Configs
Sources/Config/OpenSource.xcconfig

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -2,35 +2,36 @@
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
## Crowdin
## Getting Started
[Secretive uses Crowdin for localization](https://crowdin.com/project/secretive/). Open the link and select your language to translate!
### Download Xcode
### Manual Translation
Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/).
Crowdin is the easiest way to translate Secretive, but I'm happy to accept Pull Requests directly as well.
### Clone Secretive
Clone Secretive using [these instructions from GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository).
### Open Secretive
Open [Sources/Secretive.xcodeproj](Sources/Secretive.xcodeproj) in Xcode.
### Translate
Navigate to [Secretive/Localizable](Sources/Secretive/Localizable.xcstrings).
<img src="/.github/readme/localize_sidebar.png" alt="Screenshot of Xcode navigating to the Localizable file" width="300">
If your language already has an in-progress localization, select it from the list. If it isn't there, hit the "+" button and choose your language from the list.
<img src="/.github/readme/localize_add.png" alt="Screenshot of Xcode adding a new language" width="600">
Start translating! You'll see a list of english phrases, and a space to add a translation of your language.
### Create a Pull Request
Push your changes and open a pull request.
### Questions
Please open an issue if you have a question about translating the app. I'm more than happy to clarify any terms that are ambiguous or confusing. Thanks for contributing!
### Thank You
Thanks to all the folks who have contributed localizations so far!
- @mtardy for the French localization
- @GravityRyu for the Chinese localization
- @Saeger for the Portuguese (Brazil) localization
- @moritzsternemann for the German localization
- @RoboRich00A16 for the Italian localization
- @akx for the Finnish localization
- @mog422 for the Korean localization
- @niw for the Japanese localization
- @truita for the Catalan localization
- @Adimac93 for the Polish localization
- @alongotv for the Russian localization
A special thanks to [Crowdin](https://crowdin.com) for their [generous support of open source projects](https://crowdin.com/page/open-source-project-setup-request).

View File

@@ -22,9 +22,6 @@ let package = Package(
.library(
name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]),
.library(
name: "SSHProtocolKit",
targets: ["SSHProtocolKit"]),
],
dependencies: [
],
@@ -56,24 +53,11 @@ let package = Package(
resources: [localization],
swiftSettings: swiftSettings
),
.target(
name: "SSHProtocolKit",
dependencies: ["SecretKit"],
path: "Sources/Packages/Sources/SSHProtocolKit",
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SSHProtocolKitTests",
dependencies: ["SSHProtocolKit"],
path: "Sources/Packages/Tests/SSHProtocolKitTests",
swiftSettings: swiftSettings,
),
]
)
var localization: Resource {
.process("../../Resources/Localizable.xcstrings")
.process("../../Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {

View File

@@ -1,11 +1,11 @@
# Secretive [![Test](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg)
Secretive is an app for protecting and managing SSH keys with the Secure Enclave.
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png">
<source media="(prefers-color-scheme: light)" srcset="/.github/readme/app-light.png">
<img src="/.github/readme/app-dark.png" alt="Screenshot of Secretive" width="600">
<img src="/.github/readme/app-light.png" alt="Screenshot of Secretive" width="600">
</picture>
@@ -13,7 +13,7 @@ Secretive is an app for protecting and managing SSH keys with the Secure Enclave
### Safer Storage
The most common setup for SSH keys is just keeping them on disk, guarded by proper permissions. This is fine in most cases, but it's not super hard for malicious users or malware to copy your private key. If you protect your keys with the Secure Enclave, it's impossible to export them, by design.
The most common setup for SSH keys is just keeping them on disk, guarded by proper permissions. This is fine in most cases, but it's not super hard for malicious users or malware to copy your private key. If you store your keys in the Secure Enclave, it's impossible to export them, by design.
### Access Control
@@ -53,7 +53,7 @@ Builds are produced by GitHub Actions with an auditable build and release genera
### A Note Around Code Signing and Keychains
While Secretive uses the Secure Enclave to protect keys, it still relies on Keychain APIs to store and access them. Keychain restricts reads of keys to the app (and specifically, the bundle ID) that created them. If you build Secretive from source, make sure you are consistent in which bundle ID you use so that the Keychain is able to locate your keys.
While Secretive uses the Secure Enclave for key storage, it still relies on Keychain APIs to access them. Keychain restricts reads of keys to the app (and specifically, the bundle ID) that created them. If you build Secretive from source, make sure you are consistent in which bundle ID you use so that the Keychain is able to locate your keys.
### Backups and Transfers to New Machines
@@ -62,11 +62,3 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
## Security
Secretive's security policy is detailed in [SECURITY.md](SECURITY.md). To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
## Acknowledgements
### sekey
Secretive was inspired by the [sekey project](https://github.com/sekey/sekey).
### Localization
Secretive is localized to many languages by a generous team of volunteers. To learn more, see [LOCALIZING.md](LOCALIZING.md). Secretive's localization workflow is generously provided by [Crowdin](https://crowdin.com).

View File

@@ -1,8 +1,2 @@
CI_VERSION = GITHUB_CI_VERSION
CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER
CI_BUILD_LINK = GITHUB_BUILD_URL
#include? "OpenSource.xcconfig"
SECRETIVE_BASE_BUNDLE_ID = $(SECRETIVE_BASE_BUNDLE_ID_OSS:default=com.maxgoedjen.Secretive)
SECRETIVE_DEVELOPMENT_TEAM = $(SECRETIVE_DEVELOPMENT_TEAM_OSS:default=Z72PRUAWF6)

View File

@@ -13,24 +13,12 @@
},
"testTargets" : [
{
"enabled" : false,
"parallelizable" : true,
"target" : {
"containerPath" : "container:Packages",
"identifier" : "BriefTests",
"name" : "BriefTests"
}
},
{
"target" : {
"containerPath" : "container:Packages",
"identifier" : "SecretKitTests",
"name" : "SecretKitTests"
}
},
{
"target" : {
"containerPath" : "container:Packages",
"identifier" : "SecretAgentKitTests",
"name" : "SecretAgentKitTests"
"containerPath" : "container:Secretive.xcodeproj",
"identifier" : "50617D9323FCE48E0099B055",
"name" : "SecretiveTests"
}
}
],

File diff suppressed because it is too large Load Diff

View File

@@ -23,17 +23,11 @@ let package = Package(
name: "SecretAgentKit",
targets: ["SecretAgentKit"]),
.library(
name: "Common",
targets: ["Common"]),
name: "SecretAgentKitHeaders",
targets: ["SecretAgentKitHeaders"]),
.library(
name: "Brief",
targets: ["Brief"]),
.library(
name: "XPCWrappers",
targets: ["XPCWrappers"]),
.library(
name: "SSHProtocolKit",
targets: ["SSHProtocolKit"]),
],
dependencies: [
],
@@ -46,7 +40,7 @@ let package = Package(
),
.testTarget(
name: "SecretKitTests",
dependencies: ["SecretKit", "SecretAgentKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
swiftSettings: swiftSettings,
),
.target(
@@ -63,34 +57,20 @@ let package = Package(
),
.target(
name: "SecretAgentKit",
dependencies: ["SecretKit", "SSHProtocolKit", "Common"],
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
resources: [localization],
swiftSettings: swiftSettings,
),
.systemLibrary(
name: "SecretAgentKitHeaders",
),
.testTarget(
name: "SecretAgentKitTests",
dependencies: ["SecretAgentKit"],
),
.target(
name: "SSHProtocolKit",
dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SSHProtocolKitTests",
dependencies: ["SSHProtocolKit"],
swiftSettings: swiftSettings,
),
.target(
name: "Common",
dependencies: ["SSHProtocolKit", "SecretKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "Brief",
dependencies: ["XPCWrappers", "SSHProtocolKit"],
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
@@ -98,21 +78,16 @@ let package = Package(
name: "BriefTests",
dependencies: ["Brief"],
),
.target(
name: "XPCWrappers",
swiftSettings: swiftSettings,
),
]
)
var localization: Resource {
.process("../../Resources/Localizable.xcstrings")
.process("../../Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error),
.strictMemorySafety()
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
import Foundation
import SwiftUI
/// A release is a representation of a downloadable update.
public struct Release: Codable, Sendable, Hashable {
public struct Release: Codable, Sendable {
/// The user-facing name of the release. Typically "Secretive 1.2.3"
public let name: String
@@ -16,8 +15,6 @@ public struct Release: Codable, Sendable, Hashable {
/// A user-facing description of the contents of the update.
public let body: String
public let attributedBody: AttributedString
/// Initializes a Release.
/// - Parameters:
/// - name: The user-facing name of the release.
@@ -29,56 +26,6 @@ public struct Release: Codable, Sendable, Hashable {
self.prerelease = prerelease
self.html_url = html_url
self.body = body
self.attributedBody = AttributedString(_markdown: body)
}
public init(_ release: GitHubRelease) {
self.name = release.name
self.prerelease = release.prerelease
self.html_url = release.html_url
self.body = release.body
self.attributedBody = AttributedString(_markdown: release.body)
}
}
public struct GitHubRelease: Codable, Sendable {
let name: String
let prerelease: Bool
let html_url: URL
let body: String
}
fileprivate extension AttributedString {
init(_markdown markdown: String) {
let split = markdown.split(whereSeparator: \.isNewline)
let lines = split
.compactMap {
try? AttributedString(markdown: String($0), options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full))
}
.map { (string: AttributedString) in
guard case let .header(level) = string.runs.first?.presentationIntent?.components.first?.kind else { return string }
return AttributedString("\n") + string
.transformingAttributes(\.font) { font in
font.value = switch level {
case 2: .headline.bold()
case 3: .headline
default: .subheadline
}
}
.transformingAttributes(\.underlineStyle) { underline in
underline.value = switch level {
case 2: .single
default: .none
}
}
+ AttributedString("\n")
}
self = lines.reduce(into: AttributedString()) { partialResult, next in
partialResult.append(next)
partialResult.append(AttributedString("\n"))
}
}
}

View File

@@ -5,20 +5,12 @@ public struct SemVer: Sendable {
/// The SemVer broken into an array of integers.
let versionNumbers: [Int]
public let previewDescription: String?
public var isTestBuild: Bool {
versionNumbers == [0, 0, 0]
}
/// Initializes a SemVer from a string representation.
/// - Parameter version: A string representation of the SemVer, formatted as "major.minor.patch".
public init(_ version: String) {
// Betas have the format 1.2.3_beta1
// Nightlies have the format 0.0.0_nightly-2025-09-03
let splitFull = version.split(separator: "_")
let strippedBeta = splitFull.first!
previewDescription = splitFull.count > 1 ? String(splitFull[1]) : nil
let strippedBeta = version.split(separator: "_").first!
var split = strippedBeta.split(separator: ".").compactMap { Int($0) }
while split.count < 3 {
split.append(0)
@@ -30,7 +22,6 @@ public struct SemVer: Sendable {
/// - Parameter version: An `OperatingSystemVersion` representation of the SemVer.
public init(_ version: OperatingSystemVersion) {
versionNumbers = [version.majorVersion, version.minorVersion, version.patchVersion]
previewDescription = nil
}
}

View File

@@ -1,6 +1,5 @@
import Foundation
import Observation
import XPCWrappers
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
@Observable public final class Updater: UpdaterProtocol, Sendable {
@@ -14,11 +13,12 @@ import XPCWrappers
state.update
}
/// The current version of the app that is running.
public let currentVersion: SemVer
public let testBuild: Bool
/// The current OS version.
private let osVersion: SemVer
/// The current version of the app that is running.
private let currentVersion: SemVer
/// Initializes an Updater.
/// - Parameters:
@@ -34,25 +34,28 @@ import XPCWrappers
) {
self.osVersion = osVersion
self.currentVersion = currentVersion
Task {
if checkOnLaunch {
try await checkForUpdates()
testBuild = currentVersion == SemVer("0.0.0")
if checkOnLaunch {
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
Task {
await checkForUpdates()
}
}
Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
try await checkForUpdates()
await checkForUpdates()
}
}
}
/// Manually trigger an update check.
public func checkForUpdates() async throws {
let session = try await XPCTypedSession<[Release], Never>(serviceName: "com.maxgoedjen.Secretive.SecretiveUpdater")
await evaluate(releases: try await session.send())
session.complete()
public func checkForUpdates() async {
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL) else { return }
guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
await evaluate(releases: releases)
}
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
/// - Parameter release: The release to ignore.
public func ignore(release: Release) async {
@@ -99,3 +102,11 @@ extension Updater {
}
}
extension Updater {
enum Constants {
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")!
}
}

View File

@@ -5,8 +5,8 @@ public protocol UpdaterProtocol: Observable, Sendable {
/// The latest update
@MainActor var update: Release? { get }
var currentVersion: SemVer { 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

@@ -1,46 +0,0 @@
import Foundation
import SSHProtocolKit
import SecretKit
extension URL {
public static var agentHomeURL: URL {
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
}
public static var socketPath: String {
#if DEBUG
URL.agentHomeURL.appendingPathComponent("socket-debug.ssh").path()
#else
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
#endif
}
public static var publicKeyDirectory: URL {
agentHomeURL.appending(component: "PublicKeys")
}
/// The path for a Secret's public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the Secret's public key.
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
public static func publicKeyPath<SecretType: Secret>(for secret: SecretType, in directory: URL) -> String {
let keyWriter = OpenSSHPublicKeyWriter()
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending(component: "\(minimalHex).pub").path()
}
}
extension String {
public var normalizedPathAndFolder: (String, String) {
// All foundation-based normalization methods replace this with the container directly.
let processedPath = replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
let url = URL(filePath: processedPath)
let folder = url.deletingLastPathComponent().path()
return (processedPath, folder)
}
}

View File

@@ -0,0 +1 @@

View File

@@ -1,37 +0,0 @@
import Foundation
import CryptoKit
public struct HexDataStyle<SequenceType: Sequence>: Hashable, Codable {
let separator: String
public init(separator: String) {
self.separator = separator
}
}
extension HexDataStyle: FormatStyle where SequenceType.Element == UInt8 {
public func format(_ value: SequenceType) -> String {
value
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: separator)
}
}
extension FormatStyle where Self == HexDataStyle<Data> {
public static func hex(separator: String = "") -> HexDataStyle<Data> {
HexDataStyle(separator: separator)
}
}
extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
public static func hex(separator: String = ":") -> HexDataStyle<Insecure.MD5Digest> {
HexDataStyle(separator: separator)
}
}

View File

@@ -1,54 +0,0 @@
import Foundation
/// Reads OpenSSH protocol data.
public final class OpenSSHReader {
var remaining: Data
var done = false
/// Initialize the reader with an OpenSSH data payload.
/// - Parameter data: The data to read.
public init(data: Data) {
remaining = Data(data)
}
/// Reads the next chunk of data from the playload.
/// - Returns: The next chunk of data.
public func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
let length = try readNextBytes(as: UInt32.self, convertEndianness: convertEndianness)
guard remaining.count >= length else { throw .beyondBounds }
let dataRange = 0..<Int(length)
let ret = Data(remaining[dataRange])
remaining.removeSubrange(dataRange)
if remaining.isEmpty {
done = true
}
return ret
}
public func readNextBytes<T: FixedWidthInteger>(as: T.Type, convertEndianness: Bool = true) throws(OpenSSHReaderError) -> T {
let size = MemoryLayout<T>.size
guard remaining.count >= size else { throw .beyondBounds }
let lengthRange = 0..<size
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
if remaining.isEmpty {
done = true
}
let value = unsafe lengthChunk.bytes.unsafeLoad(as: T.self)
return convertEndianness ? T(value.bigEndian) : T(value)
}
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
}
public func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness))
}
}
public enum OpenSSHReaderError: Error, Codable {
case beyondBounds
}

View File

@@ -1,99 +0,0 @@
import Foundation
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum SSHAgent {}
extension SSHAgent {
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum Request: CustomDebugStringConvertible, Codable, Sendable {
case requestIdentities
case signRequest(SignatureRequestContext)
case addIdentity
case removeIdentity
case removeAllIdentities
case addIDConstrained
case addSmartcardKey
case removeSmartcardKey
case lock
case unlock
case addSmartcardKeyConstrained
case protocolExtension
case unknown(UInt8)
public var protocolID: UInt8 {
switch self {
case .requestIdentities: 11
case .signRequest: 13
case .addIdentity: 17
case .removeIdentity: 18
case .removeAllIdentities: 19
case .addIDConstrained: 25
case .addSmartcardKey: 20
case .removeSmartcardKey: 21
case .lock: 22
case .unlock: 23
case .addSmartcardKeyConstrained: 26
case .protocolExtension: 27
case .unknown(let value): value
}
}
public var debugDescription: String {
switch self {
case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES"
case .signRequest: "SSH_AGENTC_SIGN_REQUEST"
case .addIdentity: "SSH_AGENTC_ADD_IDENTITY"
case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY"
case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES"
case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED"
case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY"
case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY"
case .lock: "SSH_AGENTC_LOCK"
case .unlock: "SSH_AGENTC_UNLOCK"
case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED"
case .protocolExtension: "SSH_AGENTC_EXTENSION"
case .unknown: "UNKNOWN_MESSAGE"
}
}
public struct SignatureRequestContext: Sendable, Codable {
public let keyBlob: Data
public let dataToSign: Data
public init(keyBlob: Data, dataToSign: Data) {
self.keyBlob = keyBlob
self.dataToSign = dataToSign
}
public static var empty: SignatureRequestContext {
SignatureRequestContext(keyBlob: Data(), dataToSign: Data())
}
}
}
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum Response: UInt8, CustomDebugStringConvertible {
case agentFailure = 5
case agentSuccess = 6
case agentIdentitiesAnswer = 12
case agentSignResponse = 14
case agentExtensionFailure = 28
case agentExtensionResponse = 29
public var debugDescription: String {
switch self {
case .agentFailure: "SSH_AGENT_FAILURE"
case .agentSuccess: "SSH_AGENT_SUCCESS"
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
}
}
}
}

View File

@@ -3,7 +3,6 @@ import CryptoKit
import OSLog
import SecretKit
import AppKit
import SSHProtocolKit
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
public final class Agent: Sendable {
@@ -14,7 +13,6 @@ public final class Agent: Sendable {
private let signatureWriter = OpenSSHSignatureWriter()
private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
private let authorizationCoordinator = AuthorizationCoordinator()
/// Initializes an agent with a store list and a witness.
/// - Parameters:
@@ -33,30 +31,46 @@ public final class Agent: Sendable {
extension Agent {
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
/// Handles an incoming request.
/// - Parameters:
/// - data: The data to handle.
/// - provenance: The origin of the request.
/// - Returns: A response data payload.
public func handle(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
logger.debug("Agent handling new data")
guard data.count > 4 else {
throw InvalidDataProvidedError()
}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return SSHAgent.ResponseType.agentFailure.data.lengthAndData
}
logger.debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...])
let response = await handle(requestType: requestType, data: subData, provenance: provenance)
return response
}
private func handle(requestType: SSHAgent.RequestType, data: Data, provenance: SigningRequestProvenance) async -> Data {
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
await reloadSecretsIfNeccessary()
var response = Data()
do {
switch request {
switch requestType {
case .requestIdentities:
response.append(SSHAgent.Response.agentIdentitiesAnswer.data)
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
response.append(await identities())
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
case .signRequest(let context):
response.append(SSHAgent.Response.agentSignResponse.data)
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .unknown(let value):
logger.error("Agent received unknown request of type \(value).")
throw UnhandledRequestError()
default:
logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.")
throw UnhandledRequestError()
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
case .signRequest:
response.append(SSHAgent.ResponseType.agentSignResponse.data)
response.append(try await sign(data: data, provenance: provenance))
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
}
} catch {
response = SSHAgent.Response.agentFailure.data
logger.debug("Agent returned \(SSHAgent.Response.agentFailure.debugDescription)")
response.removeAll()
response.append(SSHAgent.ResponseType.agentFailure.data)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
}
return response.lengthAndData
}
@@ -87,7 +101,7 @@ extension Agent {
}
logger.log("Agent enumerated \(count) identities")
var countBigEndian = UInt32(count).bigEndian
let countData = unsafe Data(bytes: &countBigEndian, count: MemoryLayout<UInt32>.size)
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
return countData + keyData
}
@@ -96,27 +110,27 @@ extension Agent {
/// - data: The data to sign.
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
guard let (secret, store) = await secret(matching: keyBlob) else {
let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined()
logger.debug("Agent did not have a key matching \(keyBlobHex)")
func sign(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
let reader = OpenSSHReader(data: data)
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) {
hash = certificatePublicKey
} else {
hash = payloadHash
}
guard let (secret, store) = await secret(matching: hash) else {
logger.debug("Agent did not have a key matching \(hash as NSData)")
throw NoMatchingKeyError()
}
let decision = try await authorizationCoordinator.waitForAccessIfNeeded(to: secret, provenance: provenance)
switch decision {
case .proceed:
break
case .promptForSharedAuth:
do {
try await store.persistAuthentication(secret: secret, forProvenance: provenance)
await authorizationCoordinator.completedPersistence(secret: secret, forProvenance: provenance)
} catch {
await authorizationCoordinator.didNotCompletePersistence(secret: secret, forProvenance: provenance)
}
}
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
let dataToSign = reader.readNextChunk()
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
try await witness?.witness(accessTo: secret, from: store, by: provenance)
@@ -155,16 +169,16 @@ extension Agent {
extension Agent {
struct InvalidDataProvidedError: Error {}
struct NoMatchingKeyError: Error {}
struct UnhandledRequestError: Error {}
}
extension SSHAgent.Response {
extension SSHAgent.ResponseType {
var data: Data {
var raw = self.rawValue
return unsafe Data(bytes: &raw, count: MemoryLayout<UInt8>.size)
return Data(bytes: &raw, count: UInt8.bitWidth/8)
}
}

View File

@@ -1,105 +0,0 @@
import Foundation
import SecretKit
import os
import LocalAuthentication
struct PendingRequest: Identifiable, Hashable, CustomStringConvertible {
let id: UUID = UUID()
let secret: AnySecret
let provenance: SigningRequestProvenance
var description: String {
"\(id.uuidString) - \(secret.name) \(provenance.origin.displayName)"
}
func batchable(with request: PendingRequest) -> Bool {
secret == request.secret &&
provenance.isSameProvenance(as: request.provenance)
}
}
enum Decision {
case proceed
case promptForSharedAuth
}
actor RequestHolder {
var pending: [PendingRequest] = []
var authorizing: PendingRequest?
var preauthorized: PendingRequest?
func addPending(_ request: PendingRequest) {
pending.append(request)
}
func advanceIfIdle() {
}
func shouldBlock(_ request: PendingRequest) -> Bool {
guard request != authorizing else { return false }
if let preauthorized, preauthorized.batchable(with: request) {
print("Batching: \(request)")
pending.removeAll(where: { $0 == request })
return false
}
return authorizing == nil && authorizing.
}
func clear() {
if let preauthorized, allBatchable(with: preauthorized).isEmpty {
self.preauthorized = nil
}
}
func allBatchable(with request: PendingRequest) -> [PendingRequest] {
pending.filter { $0.batchable(with: request) }
}
func completedPersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) {
self.preauthorized = PendingRequest(secret: secret, provenance: provenance)
}
func didNotCompletePersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) {
self.preauthorized = nil
}
}
final class AuthorizationCoordinator: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AuthorizationCoordinator")
private let holder = RequestHolder()
public func waitForAccessIfNeeded(to secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Decision {
// Block on unknown, since we don't really have any way to check.
if secret.authenticationRequirement == .unknown {
logger.warning("\(secret.name) has unknown authentication requirement.")
}
guard secret.authenticationRequirement != .notRequired else {
logger.debug("\(secret.name) does not require authentication, continuing.")
return .proceed
}
logger.debug("\(secret.name) requires authentication.")
let pending = PendingRequest(secret: secret, provenance: provenance)
await holder.addPending(pending)
while await holder.shouldBlock(pending) {
logger.debug("\(pending) waiting.")
try await Task.sleep(for: .milliseconds(100))
}
if await holder.preauthorized == nil, await holder.allBatchable(with: pending).count > 0 {
logger.debug("\(pending) batch suggestion.")
return .promptForSharedAuth
}
logger.debug("\(pending) continuing")
return .proceed
}
func completedPersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async {
await holder.completedPersistence(secret: secret, forProvenance: provenance)
}
func didNotCompletePersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async {
await holder.didNotCompletePersistence(secret: secret, forProvenance: provenance)
}
}

View File

@@ -1,12 +1,32 @@
import Foundation
extension FileHandle {
/// Protocol abstraction of the reading aspects of FileHandle.
public protocol FileHandleReader: Sendable {
/// Gets data that is available for reading.
var availableData: Data { get }
/// A file descriptor of the handle.
var fileDescriptor: Int32 { get }
/// The process ID of the process coonnected to the other end of the FileHandle.
var pidOfConnectedProcess: Int32 { get }
}
/// Protocol abstraction of the writing aspects of FileHandle.
public protocol FileHandleWriter: Sendable {
/// Writes data to the handle.
func write(_ data: Data)
}
extension FileHandle: FileHandleReader, FileHandleWriter {
public var pidOfConnectedProcess: Int32 {
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: MemoryLayout<Int32>.size, alignment: 1)
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1)
var len = socklen_t(MemoryLayout<Int32>.size)
unsafe getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
return unsafe pidPointer.load(as: Int32.self)
getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
return pidPointer.load(as: Int32.self)
}
}

View File

@@ -1,110 +0,0 @@
import Foundation
import OSLog
import SecretKit
import SSHProtocolKit
public protocol SSHAgentInputParserProtocol {
func parse(data: Data) async throws -> SSHAgent.Request
}
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
public init() {
}
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
logger.debug("Parsing new data")
guard data.count > 4 else {
throw .invalidData
}
let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4
let rawRequestInt = data[4]
let remainingDataRange = 5..<min(Int(specifiedLength), data.count)
lazy var body: Data = { Data(data[remainingDataRange]) }()
switch rawRequestInt {
case SSHAgent.Request.requestIdentities.protocolID:
return .requestIdentities
case SSHAgent.Request.signRequest(.empty).protocolID:
do {
return .signRequest(try signatureRequestContext(from: body))
} catch {
throw .openSSHReader(error)
}
case SSHAgent.Request.addIdentity.protocolID:
return .addIdentity
case SSHAgent.Request.removeIdentity.protocolID:
return .removeIdentity
case SSHAgent.Request.removeAllIdentities.protocolID:
return .removeAllIdentities
case SSHAgent.Request.addIDConstrained.protocolID:
return .addIDConstrained
case SSHAgent.Request.addSmartcardKey.protocolID:
return .addSmartcardKey
case SSHAgent.Request.removeSmartcardKey.protocolID:
return .removeSmartcardKey
case SSHAgent.Request.lock.protocolID:
return .lock
case SSHAgent.Request.unlock.protocolID:
return .unlock
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
return .addSmartcardKeyConstrained
case SSHAgent.Request.protocolExtension.protocolID:
return .protocolExtension
default:
return .unknown(rawRequestInt)
}
}
}
extension SSHAgentInputParser {
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
let reader = OpenSSHReader(data: data)
let rawKeyBlob = try reader.readNextChunk()
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
let dataToSign = try reader.readNextChunk()
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign)
}
func certificatePublicKeyBlob(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
do {
let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = try reader.readNextChunk() // nonce
let curveIdentifier = try reader.readNextChunk()
let publicKey = try reader.readNextChunk()
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return openSSHIdentifier.lengthAndData +
curveIdentifier.lengthAndData +
publicKey.lengthAndData
default:
return nil
}
} catch {
return nil
}
}
}
extension SSHAgentInputParser {
public enum AgentParsingError: Error, Codable {
case unknownRequest
case unhandledRequest
case invalidData
case openSSHReader(OpenSSHReaderError)
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum SSHAgent {}
extension SSHAgent {
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum RequestType: UInt8, CustomDebugStringConvertible {
case requestIdentities = 11
case signRequest = 13
public var debugDescription: String {
switch self {
case .requestIdentities:
return "RequestIdentities"
case .signRequest:
return "SignRequest"
}
}
}
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum ResponseType: UInt8, CustomDebugStringConvertible {
case agentFailure = 5
case agentSuccess = 6
case agentIdentitiesAnswer = 12
case agentSignResponse = 14
public var debugDescription: String {
switch self {
case .agentFailure:
return "AgentFailure"
case .agentSuccess:
return "AgentSuccess"
case .agentIdentitiesAnswer:
return "AgentIdentitiesAnswer"
case .agentSignResponse:
return "AgentSignResponse"
}
}
}
}

View File

@@ -2,6 +2,7 @@ import Foundation
import AppKit
import Security
import SecretKit
import SecretAgentKitHeaders
/// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects.
struct SigningRequestTracer {
@@ -9,11 +10,12 @@ struct SigningRequestTracer {
extension SigningRequestTracer {
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandle``.
/// - Parameter fileHandle: The reader involved in processing the request.
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandleReader``.
/// - Parameter fileHandleReader: The reader involved in processing the request.
/// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request.
func provenance(from fileHandle: FileHandle) -> SigningRequestProvenance {
let firstInfo = process(from: fileHandle.pidOfConnectedProcess)
func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance {
let firstInfo = process(from: fileHandleReader.pidOfConnectedProcess)
var provenance = SigningRequestProvenance(root: firstInfo)
while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil {
provenance.chain.append(process(from: provenance.origin.parentPID!))
@@ -25,30 +27,30 @@ extension SigningRequestTracer {
/// - Parameter pid: The process ID to look up.
/// - Returns: a `kinfo_proc` struct describing the process ID.
func pidAndNameInfo(from pid: Int32) -> kinfo_proc {
var len = unsafe MemoryLayout<kinfo_proc>.size
var len = MemoryLayout<kinfo_proc>.size
let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1)
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
unsafe sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
return unsafe infoPointer.load(as: kinfo_proc.self)
sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
return infoPointer.load(as: kinfo_proc.self)
}
/// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID.
/// - Parameter pid: The process ID to look up.
/// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process.
func process(from pid: Int32) -> SigningRequestProvenance.Process {
var pidAndNameInfo = unsafe self.pidAndNameInfo(from: pid)
let ppid = unsafe pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
unsafe String(cString: pointer)
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
String(cString: pointer)
}
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
let path = unsafe String(cString: pathPointer)
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
let path = String(cString: pathPointer)
var secCode: Unmanaged<SecCode>!
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
unsafe SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
let valid = unsafe SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
}
@@ -79,11 +81,3 @@ extension SigningRequestTracer {
}
}
// from libproc.h
@_silgen_name("proc_pidpath")
@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32
//// from SecTask.h
@_silgen_name("SecCodeCreateWithPID")
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus

View File

@@ -36,21 +36,16 @@ public struct SocketController {
logger.debug("Socket controller path is clear")
port = SocketPort(path: path)
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
Task { @MainActor [fileHandle, sessionsContinuation, logger] in
// Create the sequence before triggering the notification to
// ensure it will not be missed.
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted)
fileHandle.acceptConnectionInBackgroundAndNotify()
for await notification in connectionAcceptedNotifications {
Task { [fileHandle, sessionsContinuation, logger] in
for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) {
logger.debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
let session = Session(fileHandle: new)
sessionsContinuation.yield(session)
fileHandle.acceptConnectionInBackgroundAndNotify()
await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor()
}
}
fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
logger.debug("Socket listening at \(path)")
}
@@ -82,14 +77,8 @@ extension SocketController {
self.fileHandle = fileHandle
provenance = SigningRequestTracer().provenance(from: fileHandle)
(messages, messagesContinuation) = AsyncStream.makeStream()
Task { @MainActor [messagesContinuation, logger] in
// Create the sequence before triggering the notification to
// ensure it will not be missed.
let dataAvailableNotifications = NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle)
fileHandle.waitForDataInBackgroundAndNotify()
for await _ in dataAvailableNotifications {
Task { [messagesContinuation, logger] in
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) {
let data = fileHandle.availableData
guard !data.isEmpty else {
logger.debug("Socket controller received empty data, ending continuation.")
@@ -101,13 +90,16 @@ extension SocketController {
logger.debug("Socket controller yielded data.")
}
}
Task {
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
}
}
/// Writes new data to the socket.
/// - Parameter data: The data to write.
@MainActor public func write(_ data: Data) throws {
try fileHandle.write(contentsOf: data)
fileHandle.waitForDataInBackgroundAndNotify()
public func write(_ data: Data) async throws {
try fileHandle.write(contentsOf: data)
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
}
/// Closes the socket and cleans up resources.
@@ -121,25 +113,42 @@ extension SocketController {
}
private extension FileHandle {
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
waitForDataInBackgroundAndNotify()
}
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
/// - Parameter modes: the runloop modes to use.
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
acceptConnectionInBackgroundAndNotify(forModes: modes)
}
}
private extension SocketPort {
convenience init(path: String) {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
unsafe path.withCString { cstring in
let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len)
return len
var len: Int = 0
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
path.withCString { cstring in
len = strlen(cstring)
strncpy(pointer, cstring, len)
}
}
// This doesn't seem to be _strictly_ neccessary with SocketPort.
// but just for good form.
addr.sun_family = sa_family_t(AF_UNIX)
// This mirrors the SUN_LEN macro format.
addr.sun_len = UInt8(MemoryLayout<sockaddr_un>.size - MemoryLayout.size(ofValue: addr.sun_path) + length)
addr.sun_len = UInt8(len+2)
var data: Data!
withUnsafePointer(to: &addr) { pointer in
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
}
let data = unsafe Data(bytes: &addr, count: MemoryLayout<sockaddr_un>.size)
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,19 @@
#import <Foundation/Foundation.h>
#import <Security/Security.h>
// Forward declarations
// from libproc.h
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
// from SecTask.h
OSStatus SecCodeCreateWithPID(int32_t, SecCSFlags, SecCodeRef *);
//! Project version number for SecretAgentKit.
FOUNDATION_EXPORT double SecretAgentKitVersionNumber;
//! Project version string for SecretAgentKit.
FOUNDATION_EXPORT const unsigned char SecretAgentKitVersionString[];

View File

@@ -0,0 +1,4 @@
module SecretAgentKitHeaders [system] {
header "include/SecretAgentKit.h"
export *
}

View File

@@ -1,95 +0,0 @@
import LocalAuthentication
/// A context describing a persisted authentication.
package struct PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: SecretType
/// The LAContext used to authorize the persistent context.
package nonisolated(unsafe) let context: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let monotonicExpiration: UInt64
/// Initializes a context.
/// - Parameters:
/// - secret: The Secret to persist authentication for.
/// - context: The LAContext used to authorize the persistent context.
/// - duration: The duration of the authorization context, in seconds.
init(secret: SecretType, context: LAContext, duration: TimeInterval) {
self.secret = secret
unsafe self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
/// A boolean describing whether or not the context is still valid.
package var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
package var expiration: Date {
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
return Date(timeIntervalSinceNow: remainingInSeconds)
}
}
struct ScopedPersistentAuthenticationContext<SecretType: Secret>: Hashable {
let provenance: SigningRequestProvenance
let secret: SecretType
}
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
private var unscopedPersistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
private var scopedPersistedAuthenticationContexts: [ScopedPersistentAuthenticationContext<SecretType>: PersistentAuthenticationContext<SecretType>] = [:]
package init() {
}
package func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) -> PersistentAuthenticationContext<SecretType>? {
if let unscopedPersistence = unscopedPersistedAuthenticationContexts[secret], unscopedPersistence.valid {
return unscopedPersistence
}
if let scopedPersistence = scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)], scopedPersistence.valid {
return scopedPersistence
}
return nil
}
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
let durationString = formatter.string(from: duration)!
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
unscopedPersistedAuthenticationContexts[secret] = context
}
package func persistAuthentication(secret: SecretType, provenance: SigningRequestProvenance) async throws {
let newContext = LAContext()
// FIXME: TEMPORARY
let duration: TimeInterval = 10000
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
newContext.localizedReason = "Batch requests"
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)] = context
}
}

View File

@@ -9,9 +9,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
private let _name: @MainActor @Sendable () -> String
private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret, SigningRequestProvenance) async -> PersistedAuthenticationContext?
private let _persistAuthenticationForDuration: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _persistAuthenticationForProvenance: @Sendable (AnySecret, SigningRequestProvenance) async throws -> Void
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: @Sendable () async -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
@@ -21,9 +20,8 @@ open 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) }
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType, provenance: $1) }
_persistAuthenticationForDuration = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_persistAuthenticationForProvenance = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forProvenance: $1) }
_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() }
}
@@ -47,16 +45,12 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
try await _sign(data, secret, provenance)
}
public func existingPersistedAuthenticationContext(secret: AnySecret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
await _existingPersistedAuthenticationContext(secret, provenance)
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
await _existingPersistedAuthenticationContext(secret)
}
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
try await _persistAuthenticationForDuration(secret, duration)
}
public func persistAuthentication(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async throws {
try await _persistAuthenticationForProvenance(secret, provenance)
try await _persistAuthentication(secret, duration)
}
public func reloadSecrets() async {
@@ -70,7 +64,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
private let _delete: @Sendable (AnySecret) async throws -> Void
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
private let _supportedKeyTypes: @Sendable () -> KeyAvailability
private let _supportedKeyTypes: @Sendable () -> [KeyType]
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
@@ -93,7 +87,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
try await _update(secret, name, attributes)
}
public var supportedKeyTypes: KeyAvailability {
public var supportedKeyTypes: [KeyType] {
_supportedKeyTypes()
}

View File

@@ -36,12 +36,12 @@ public struct KeychainError: Error {
/// A signing-related error.
public struct SigningError: Error {
/// The underlying error reported by the API, if one was returned.
public let error: CFError?
public let error: SecurityError?
/// Initializes a SigningError with an optional SecurityError.
/// - Parameter statusCode: The SecurityError, if one is applicable.
public init(error: SecurityError?) {
self.error = unsafe error?.takeRetainedValue()
self.error = error
}
}

View File

@@ -7,7 +7,7 @@ extension Data {
package var lengthAndData: Data {
let rawLength = UInt32(count)
var endian = rawLength.bigEndian
return unsafe Data(bytes: &endian, count: MemoryLayout<UInt32>.size) + self
return Data(bytes: &endian, count: UInt32.bitWidth/8) + self
}
}

View File

@@ -1,12 +1,10 @@
import Foundation
import OSLog
import SecretKit
import SSHProtocolKit
/// Manages storage and lookup for OpenSSH certificates.
public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHPublicKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
@@ -27,6 +25,29 @@ public actor OpenSSHCertificateHandler: Sendable {
}
}
/// 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.
public func publicKeyHash(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return openSSHIdentifier.lengthAndData +
curveIdentifier.lengthAndData +
publicKey.lengthAndData
default:
return nil
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.

View File

@@ -1,6 +1,5 @@
import Foundation
import CryptoKit
import SecretKit
/// Generates OpenSSH representations of the public key sof secrets.
public struct OpenSSHPublicKeyWriter: Sendable {
@@ -50,7 +49,9 @@ public struct OpenSSHPublicKeyWriter: Sendable {
/// Generates an OpenSSH MD5 fingerprint string.
/// - Returns: OpenSSH MD5 fingerprint string.
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
Insecure.MD5.hash(data: data(secret: secret)).formatted(.hex(separator: ":"))
Insecure.MD5.hash(data: data(secret: secret))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
}
public func comment<SecretType: Secret>(secret: SecretType) -> String {
@@ -96,7 +97,7 @@ extension OpenSSHPublicKeyWriter {
extension OpenSSHPublicKeyWriter {
func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
public func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
// Keychain stores it as a thin ASN.1 wrapper with this format:
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]

View File

@@ -0,0 +1,28 @@
import Foundation
/// Reads OpenSSH protocol data.
public final class OpenSSHReader {
var remaining: Data
/// Initialize the reader with an OpenSSH data payload.
/// - Parameter data: The data to read.
public init(data: Data) {
remaining = Data(data)
}
/// Reads the next chunk of data from the playload.
/// - Returns: The next chunk of data.
public func readNextChunk() -> Data {
let lengthRange = 0..<(UInt32.bitWidth/8)
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
let littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self)
let length = Int(littleEndianLength.bigEndian)
let dataRange = 0..<length
let ret = Data(remaining[dataRange])
remaining.removeSubrange(dataRange)
return ret
}
}

View File

@@ -1,6 +1,5 @@
import Foundation
import CryptoKit
import SecretKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHSignatureWriter: Sendable {
@@ -30,28 +29,19 @@ public struct OpenSSHSignatureWriter: Sendable {
extension OpenSSHSignatureWriter {
/// Converts a fixed-width big-endian integer (e.g. r/s from CryptoKit rawRepresentation) into an SSH mpint.
/// Strips unnecessary leading zeros and prefixes `0x00` if needed to keep the value positive.
private func mpint(fromFixedWidthPositiveBytes bytes: Data) -> Data {
// mpint zero is encoded as a string with zero bytes of data.
guard let firstNonZeroIndex = bytes.firstIndex(where: { $0 != 0x00 }) else {
return Data()
}
let trimmed = Data(bytes[firstNonZeroIndex...])
if let first = trimmed.first, first >= 0x80 {
var prefixed = Data([0x00])
prefixed.append(trimmed)
return prefixed
}
return trimmed
}
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
let rawLength = rawRepresentation.count/2
let r = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[0..<rawLength]))
let s = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[rawLength...]))
// Check if we need to pad with 0x00 to prevent certain
// ssh servers from thinking r or s is negative
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
var r = Data(rawRepresentation[0..<rawLength])
if paddingRange ~= r.first! {
r.insert(0x00, at: 0)
}
var s = Data(rawRepresentation[rawLength...])
if paddingRange ~= s.first! {
s.insert(0x00, at: 0)
}
var signatureChunk = Data()
signatureChunk.append(r.lengthAndData)

View File

@@ -1,8 +1,5 @@
import Foundation
import OSLog
import SecretKit
import SSHProtocolKit
import Common
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
public final class PublicKeyFileStoreController: Sendable {
@@ -12,8 +9,8 @@ public final class PublicKeyFileStoreController: Sendable {
private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(directory: URL) {
self.directory = directory
public init(homeDirectory: URL) {
directory = homeDirectory.appending(component: "PublicKeys")
}
/// Writes out the keys specified to disk.
@@ -22,27 +19,33 @@ public final class PublicKeyFileStoreController: Sendable {
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
logger.log("Writing public keys to disk")
if clear {
let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: directory) })
.union(Set(secrets.map { sshCertificatePath(for: $0) }))
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
let fullPathContents = contentsOfDirectory.map { directory.appending(path: $0).path() }
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
let untracked = Set(fullPathContents)
.subtracting(validPaths)
for path in untracked {
// string instead of fileURLWithPath since we're already using fileURL format.
try? FileManager.default.removeItem(at: URL(string: path)!)
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
}
}
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
let path = URL.publicKeyPath(for: secret, in: directory)
let path = publicKeyPath(for: secret)
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
}
logger.log("Finished writing public keys")
}
/// The path for a Secret's public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the Secret's public key.
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending(component: "\(minimalHex).pub").path()
}
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
public var hasAnyCertificates: Bool {

View File

@@ -26,7 +26,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
/// - Returns: A persisted authentication context, if a valid one exists.
func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext?
func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
/// Persists user authorization for access to a secret.
/// - Parameters:
@@ -35,8 +35,6 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
func persistAuthentication(secret: SecretType, forProvenance provenance: SigningRequestProvenance) async throws
/// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() async
@@ -64,37 +62,10 @@ public protocol SecretStoreModifiable<SecretType>: SecretStore {
/// - attributes: The new attributes for the secret.
func update(secret: SecretType, name: String, attributes: Attributes) async throws
var supportedKeyTypes: KeyAvailability { get }
var supportedKeyTypes: [KeyType] { get }
}
public struct KeyAvailability: Sendable {
public let available: [KeyType]
public let unavailable: [UnavailableKeyType]
public init(available: [KeyType], unavailable: [UnavailableKeyType]) {
self.available = available
self.unavailable = unavailable
}
public struct UnavailableKeyType: Sendable {
public let keyType: KeyType
public let reason: Reason
public init(keyType: KeyType, reason: Reason) {
self.keyType = keyType
self.reason = reason
}
public enum Reason: Sendable {
case macOSUpdateRequired
}
}
}
extension NSNotification.Name {
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)

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, Hashable {
public struct SigningRequestProvenance: Equatable, Sendable {
/// A list of processes involved in the request.
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
@@ -25,16 +25,12 @@ extension SigningRequestProvenance {
chain.allSatisfy { $0.validSignature }
}
public func isSameProvenance(as other: SigningRequestProvenance) -> Bool {
zip(chain, other.chain).allSatisfy { $0.isSameProcess(as: $1) }
}
}
extension SigningRequestProvenance {
/// Describes a process in a `SigningRequestProvenance` chain.
public struct Process: Equatable, Sendable, Hashable {
public struct Process: Equatable, Sendable {
/// The pid of the process.
public let pid: Int32
@@ -75,15 +71,6 @@ extension SigningRequestProvenance {
appName ?? processName
}
// Whether the
public func isSameProcess(as other: Process) -> Bool {
processName == other.processName &&
appName == other.appName &&
iconURL == other.iconURL &&
path == other.path &&
validSignature == other.validSignature
}
}
}

View File

@@ -27,10 +27,10 @@ extension SecureEnclave {
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
unsafe SecItemCopyMatching(privateAttributes, &privateUntyped)
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
var migratedAny = false
var migrated = false
for key in privateTyped {
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = key[kSecAttrApplicationLabel] as! Data
@@ -40,39 +40,35 @@ extension SecureEnclave {
}
let ref = key[kSecValueRef] as! SecKey
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
let tokenObjectID = unsafe attributes[Constants.tokenObjectID] as! Data
let tokenObjectID = attributes[Constants.tokenObjectID] as! Data
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
// Best guess.
let auth: AuthenticationRequirement = String(describing: accessControl)
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
do {
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
logger.log("Skipping \(name), public key already present. Marking as migrated.")
markMigrated(secret: secret, oldID: id)
continue
}
logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).")
markMigrated(secret: secret, oldID: id)
migratedAny = true
} catch {
logger.error("Failed to migrate \(name): \(error.localizedDescription).")
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
logger.log("Skipping \(name), public key already present. Marking as migrated.")
try markMigrated(secret: secret, oldID: id)
continue
}
logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).")
try markMigrated(secret: secret, oldID: id)
migrated = true
}
if migratedAny {
if migrated {
store.reloadSecrets()
}
}
public func markMigrated(secret: Secret, oldID: Data) {
public func markMigrated(secret: Secret, oldID: Data) throws {
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: oldID
kSecAttrApplicationLabel: secret.id
])
let newID = oldID + Constants.migrationMagicNumber
@@ -82,7 +78,7 @@ extension SecureEnclave {
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
logger.warning("Failed to mark \(secret.name) as migrated: \(status).")
throw KeychainError(statusCode: status)
}
}

View File

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

View File

@@ -2,7 +2,7 @@ import Foundation
import Observation
import Security
import CryptoKit
import LocalAuthentication
@preconcurrency import LocalAuthentication
import SecretKit
import os
@@ -17,7 +17,7 @@ extension SecureEnclave {
}
public let id = UUID()
public let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
/// Initializes a Store.
@MainActor public init() {
@@ -26,7 +26,7 @@ extension SecureEnclave {
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
guard Constants.notificationToken != (note.object as? String) else {
// Don't reload if we're the ones triggering this by reloading.
continue
return
}
reloadSecrets()
}
@@ -39,8 +39,8 @@ extension SecureEnclave {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
var context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) {
context = unsafe existing.context
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
@@ -57,7 +57,7 @@ extension SecureEnclave {
kSecReturnData: true,
])
var untyped: CFTypeRef?
let status = unsafe SecItemCopyMatching(queryAttributes, &untyped)
let status = SecItemCopyMatching(queryAttributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
@@ -88,18 +88,14 @@ extension SecureEnclave {
}
public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
public func persistAuthentication(secret: SecureEnclave.Secret, forProvenance provenance: SigningRequestProvenance) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance)
}
@MainActor public func reloadSecrets() {
let before = secrets
secrets.removeAll()
@@ -125,12 +121,12 @@ extension SecureEnclave {
fatalError()
}
let access =
unsafe SecAccessControlCreateWithFlags(kCFAllocatorDefault,
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError)
if let error = unsafe accessError {
throw unsafe error.takeRetainedValue() as Error
if let error = accessError {
throw error.takeRetainedValue() as Error
}
let dataRep: Data
let publicKey: Data
@@ -190,22 +186,17 @@ extension SecureEnclave {
await reloadSecrets()
}
public let supportedKeyTypes: KeyAvailability = {
let macOS26Keys: [KeyType] = [.mldsa65, .mldsa87]
let isAtLeastMacOS26 = if #available(macOS 26, *) {
true
} else {
false
}
return KeyAvailability(
available: [
public var supportedKeyTypes: [KeyType] {
if #available(macOS 26, *) {
[
.ecdsa256,
] + (isAtLeastMacOS26 ? macOS26Keys : []),
unavailable: (isAtLeastMacOS26 ? [] : macOS26Keys).map {
KeyAvailability.UnavailableKeyType(keyType: $0, reason: .macOSUpdateRequired)
}
)
}()
.mldsa65,
.mldsa87,
]
} else {
[.ecdsa256]
}
}
}
}
@@ -223,7 +214,7 @@ extension SecureEnclave.Store {
kSecReturnAttributes: true
])
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(queryAttributes, &untyped)
SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
do {

View File

@@ -1,7 +1,7 @@
import Foundation
import Observation
import Security
@unsafe @preconcurrency import CryptoTokenKit
@preconcurrency import CryptoTokenKit
import LocalAuthentication
import SecretKit
@@ -34,7 +34,6 @@ extension SmartCard {
public var secrets: [Secret] {
state.secrets
}
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
/// Initializes a Store.
public init() {
@@ -59,15 +58,9 @@ extension SmartCard {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() }
var context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) {
context = unsafe existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
let context = LAContext()
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@@ -77,7 +70,7 @@ extension SmartCard {
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = unsafe SecItemCopyMatching(attributes, &untyped)
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
@@ -87,22 +80,17 @@ extension SmartCard {
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
guard let signature = unsafe SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
throw unsafe SigningError(error: signError)
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
nil
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
public func persistAuthentication(secret: Secret, forProvenance provenance: SigningRequestProvenance) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance)
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
}
/// Reloads all secrets from the store.
@@ -164,7 +152,7 @@ extension SmartCard.Store {
kSecReturnAttributes: true
])
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(attributes, &untyped)
SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped: [SecretType] = typed.compactMap {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
@@ -175,7 +163,7 @@ extension SmartCard.Store {
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .presenceRequired)
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
guard signatureAlgorithm(for: secret) != nil else { return nil }
return secret

View File

@@ -1,26 +0,0 @@
import Foundation
extension ProcessInfo {
private static let fallbackTeamID = "Z72PRUAWF6"
private static let teamID: String = {
#if DEBUG
guard let task = SecTaskCreateFromSelf(nil) else {
assertionFailure("SecTaskCreateFromSelf failed")
return fallbackTeamID
}
guard let value = SecTaskCopyValueForEntitlement(task, "com.apple.developer.team-identifier" as CFString, nil) as? String else {
// assertionFailure("SecTaskCopyValueForEntitlement(com.apple.developer.team-identifier) failed")
return fallbackTeamID
}
return value
#else
/// Always use hardcoded team ID for release builds, just in case.
return fallbackTeamID
#endif
}()
public var teamID: String { Self.teamID }
}

View File

@@ -1,14 +0,0 @@
import Foundation
@objc protocol _XPCProtocol: Sendable {
func process(_ data: Data, with reply: @Sendable @escaping (Data?, Error?) -> Void)
}
public protocol XPCProtocol<Input, Output>: Sendable {
associatedtype Input: Codable
associatedtype Output: Codable
func process(_ data: Input) async throws -> Output
}

View File

@@ -1,72 +0,0 @@
import Foundation
public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
private let exportedObject: ErasedXPCProtocol
public init<XPCProtocolType: XPCProtocol>(exportedObject: XPCProtocolType) {
self.exportedObject = ErasedXPCProtocol(exportedObject)
}
public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
let exportedObject = exportedObject
newConnection.exportedObject = exportedObject
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
newConnection.resume()
return true
}
}
@objc private final class ErasedXPCProtocol: NSObject, _XPCProtocol {
let _process: @Sendable (Data, @Sendable @escaping (Data?, (any Error)?) -> Void) -> Void
public init<XPCProtocolType: XPCProtocol>(_ exportedObject: XPCProtocolType) {
_process = { data, reply in
Task { [reply] in
do {
let decoded = try JSONDecoder().decode(XPCProtocolType.Input.self, from: data)
let result = try await exportedObject.process(decoded)
let encoded = try JSONEncoder().encode(result)
reply(encoded, nil)
} catch {
if let error = error as? Codable & Error {
reply(nil, NSError(error))
} else {
// Sending cast directly tries to serialize it and crashes XPCEncoder.
let cast = error as NSError
reply(nil, NSError(domain: cast.domain, code: cast.code, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]))
}
}
}
}
}
func process(_ data: Data, with reply: @Sendable @escaping (Data?, (any Error)?) -> Void) {
_process(data, reply)
}
}
extension NSError {
private enum Constants {
static let domain = "com.maxgoedjen.secretive.xpcwrappers"
static let code = -1
static let dataKey = "underlying"
}
@nonobjc convenience init<ErrorType: Codable & Error>(_ error: ErrorType) {
let encoded = try? JSONEncoder().encode(error)
self.init(domain: Constants.domain, code: Constants.code, userInfo: [Constants.dataKey: encoded as Any])
}
@nonobjc public func underlying<ErrorType: Codable & Error>(as errorType: ErrorType.Type) -> ErrorType? {
guard domain == Constants.domain && code == Constants.code, let data = userInfo[Constants.dataKey] as? Data else { return nil }
return try? JSONDecoder().decode(ErrorType.self, from: data)
}
}

View File

@@ -1,53 +0,0 @@
import Foundation
public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error & Codable>: ~Copyable {
private let connection: NSXPCConnection
private let proxy: _XPCProtocol
public init(serviceName: String, warmup: Bool = false) async throws {
let connection = NSXPCConnection(serviceName: serviceName)
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
connection.resume()
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
self.connection = connection
self.proxy = proxy
if warmup {
_ = try? await send()
}
}
public func send(_ message: some Encodable = Data()) async throws -> ResponseType {
let encoded = try JSONEncoder().encode(message)
return try await withCheckedThrowingContinuation { continuation in
proxy.process(encoded) { data, error in
do {
if let error {
throw error
}
guard let data else {
throw NoDataError()
}
let decoded = try JSONDecoder().decode(ResponseType.self, from: data)
continuation.resume(returning: decoded)
} catch {
if let typed = (error as NSError).underlying(as: ErrorType.self) {
continuation.resume(throwing: typed)
} else {
continuation.resume(throwing: error)
}
}
}
}
}
public func complete() {
connection.invalidate()
}
public struct NoDataError: Error {}
}

View File

@@ -1,83 +0,0 @@
import Foundation
import Testing
import SSHProtocolKit
@testable import SecretKit
@Suite struct OpenSSHSignatureWriterTests {
private let writer = OpenSSHSignatureWriter()
@Test func ecdsaMpintStripsUnnecessaryLeadingZeros() throws {
let secret = Constants.ecdsa256Secret
// r has a leading 0x00 followed by 0x01 (< 0x80): the mpint must not keep the leading zero.
let rBytes: [UInt8] = [0x00] + (1...31).map { UInt8($0) }
let r = Data(rBytes)
// s has two leading 0x00 bytes followed by 0x7f (< 0x80): the mpint must not keep the leading zeros.
let sBytes: [UInt8] = [0x00, 0x00, 0x7f] + Array(repeating: UInt8(0x01), count: 29)
let s = Data(sBytes)
let rawRepresentation = r + s
let response = writer.data(secret: secret, signature: rawRepresentation)
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
#expect(parsedR == Data((1...31).map { UInt8($0) }))
#expect(parsedS == Data([0x7f] + Array(repeating: UInt8(0x01), count: 29)))
}
@Test func ecdsaMpintPrefixesZeroWhenHighBitSet() throws {
let secret = Constants.ecdsa256Secret
// r starts with 0x80 (high bit set): mpint must be prefixed with 0x00.
let r = Data([UInt8(0x80)] + Array(repeating: UInt8(0x01), count: 31))
let s = Data([UInt8(0x01)] + Array(repeating: UInt8(0x02), count: 31))
let rawRepresentation = r + s
let response = writer.data(secret: secret, signature: rawRepresentation)
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
#expect(parsedR == Data([0x00, 0x80] + Array(repeating: UInt8(0x01), count: 31)))
#expect(parsedS == Data([0x01] + Array(repeating: UInt8(0x02), count: 31)))
}
}
private extension OpenSSHSignatureWriterTests {
enum Constants {
static let ecdsa256Secret = TestSecret(
id: Data(),
name: "Test Key (ECDSA 256)",
publicKey: Data(repeating: 0x01, count: 65),
attributes: Attributes(
keyType: KeyType(algorithm: .ecdsa, size: 256),
authentication: .notRequired,
publicKeyAttribution: "test@example.com"
)
)
}
enum ParseError: Error {
case eof
case invalidAlgorithm
}
func parseEcdsaSignatureMpints(from openSSHSignedData: Data) throws -> (r: Data, s: Data) {
let reader = OpenSSHReader(data: openSSHSignedData)
// Prefix
_ = try reader.readNextBytes(as: UInt32.self)
let algorithm = try reader.readNextChunkAsString()
guard algorithm == "ecdsa-sha2-nistp256" else {
throw ParseError.invalidAlgorithm
}
let sigReader = try reader.readNextChunkAsSubReader()
let r = try sigReader.readNextChunk()
let s = try sigReader.readNextChunk()
return (r, s)
}
}

View File

@@ -1,11 +0,0 @@
import Foundation
import SecretKit
public struct TestSecret: SecretKit.Secret {
public let id: Data
public let name: String
public let publicKey: Data
public var attributes: Attributes
}

View File

@@ -1,7 +1,6 @@
import Foundation
import Testing
import CryptoKit
@testable import SSHProtocolKit
@testable import SecretKit
@testable import SecretAgentKit
@@ -9,22 +8,19 @@ import CryptoKit
// MARK: Identity Listing
// let testProvenance = SigningRequestProvenance(root: .init(pid: 0, processName: "Test", appName: "Test", iconURL: nil, path: /, validSignature: true, parentPID: nil))
@Test func emptyStores() async throws {
let agent = Agent(storeList: SecretStoreList())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test)
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesEmpty)
}
@Test func identitiesList() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test)
let actual = OpenSSHReader(data: response)
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
print(actual, expected)
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesMultiple)
}
@@ -33,42 +29,40 @@ import CryptoKit
@Test func noMatchingIdentities() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
let response = await agent.handle(request: request, provenance: .test)
let response = try await agent.handle(data: Constants.Requests.requestSignatureWithNoneMatching, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
@Test func ecdsaSignature() async throws {
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
guard case SSHAgent.Request.signRequest(let context) = request else { return }
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
let response = await agent.handle(request: request, provenance: .test)
let responseReader = OpenSSHReader(data: response)
let length = try responseReader.readNextBytes(as: UInt32.self)
let type = try responseReader.readNextBytes(as: UInt8.self)
#expect(length == response.count - MemoryLayout<UInt32>.size)
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
let outer = OpenSSHReader(data: responseReader.remaining)
let inner = try outer.readNextChunkAsSubReader()
_ = try inner.readNextChunk()
let rsData = try inner.readNextChunkAsSubReader()
var r = try rsData.readNextChunk()
var s = try rsData.readNextChunk()
// This is fine IRL, but it freaks out CryptoKit
if r[0] == 0 {
r.removeFirst()
}
if s[0] == 0 {
s.removeFirst()
}
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: context.dataToSign))
}
// @Test func ecdsaSignature() async throws {
// let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
// 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 agent = Agent(storeList: list)
// await agent.handle(reader: stubReader, writer: stubWriter)
// let outer = OpenSSHReader(data: stubWriter.data[5...])
// let payload = outer.readNextChunk()
// let inner = OpenSSHReader(data: payload)
// _ = inner.readNextChunk()
// let signedData = inner.readNextChunk()
// let rsData = OpenSSHReader(data: signedData)
// var r = rsData.readNextChunk()
// var s = rsData.readNextChunk()
// // This is fine IRL, but it freaks out CryptoKit
// if r[0] == 0 {
// r.removeFirst()
// }
// if s[0] == 0 {
// s.removeFirst()
// }
// 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))
// }
// MARK: Witness protocol
@@ -78,7 +72,7 @@ import CryptoKit
return true
}, witness: { _, _ in })
let agent = Agent(storeList: list, witness: witness)
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
@@ -91,8 +85,7 @@ import CryptoKit
witnessed = true
})
let agent = Agent(storeList: list, witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test)
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(witnessed)
}
@@ -107,8 +100,7 @@ import CryptoKit
witnessTrace = trace
})
let agent = Agent(storeList: list, witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test)
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(witnessTrace == speakNowTrace)
#expect(witnessTrace == .test)
}
@@ -120,8 +112,7 @@ import CryptoKit
let store = await list.stores.first?.base as! Stub.Store
store.shouldThrow = true
let agent = Agent(storeList: list)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
let response = await agent.handle(request: request, provenance: .test)
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
@@ -129,7 +120,7 @@ import CryptoKit
@Test func unhandledAdd() async throws {
let agent = Agent(storeList: SecretStoreList())
let response = await agent.handle(request: .addIdentity, provenance: .test)
let response = try await agent.handle(data: Constants.Requests.addIdentity, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
@@ -155,13 +146,14 @@ extension AgentTests {
enum Requests {
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
static let addIdentity = Data(base64Encoded: "AAAAARE=")!
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
}
enum Responses {
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")!
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
}

View File

@@ -1,7 +1,6 @@
import Foundation
import SecretKit
import CryptoKit
import SSHProtocolKit
struct Stub {}
@@ -83,7 +82,7 @@ extension Stub {
let privateKey: Data
init(keySize: Int, publicKey: Data, privateKey: Data) {
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired, publicKeyAttribution: "ecdsa-\(keySize)@example.com")
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired)
self.publicKey = publicKey
self.privateKey = privateKey
}

View File

@@ -1,7 +1,8 @@
import Foundation
import Testing
@testable import SecretKit
import SSHProtocolKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHPublicKeyWriterTests {
@@ -46,8 +47,8 @@ import SSHProtocolKit
extension OpenSSHPublicKeyWriterTests {
enum Constants {
static let ecdsa256Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
}

View File

@@ -1,16 +1,18 @@
import Foundation
import Testing
import SSHProtocolKit
@testable import SecretKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHReaderTests {
@Test func signatureRequest() throws {
@Test func signatureRequest() {
let reader = OpenSSHReader(data: Constants.signatureRequest)
let hash = try reader.readNextChunk()
let hash = reader.readNextChunk()
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
let dataToSign = try reader.readNextChunk()
let dataToSign = reader.readNextChunk()
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
let empty = try reader.readNextChunk()
let empty = reader.readNextChunk()
#expect(empty.isEmpty)
}

View File

@@ -6,68 +6,85 @@ import SmartCardSecretKit
import SecretAgentKit
import Brief
import Observation
import Common
import SwiftUI
extension EnvironmentValues {
private static let _agentStatusChecker = AgentStatusChecker()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
}
@main
class AppDelegate: NSObject, NSApplicationDelegate {
struct SecretAgent: App {
@MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList()
let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store())
return list
}()
private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
private lazy var agent: Agent = {
Agent(storeList: storeList, witness: notifier)
}()
private lazy var socketController: SocketController = {
let path = URL.socketPath as String
return SocketController(path: path)
}()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
func applicationDidFinishLaunching(_ aNotification: Notification) {
logger.debug("SecretAgent finished launching")
Task {
for await session in socketController.sessions {
Task {
let inputParser = try await XPCAgentInputParser()
do {
for await message in session.messages {
let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance)
try session.write(agentResponse)
}
} catch {
try session.close()
}
}
}
}
Task {
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
}
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
notifier.prompt()
_ = withObservationTracking {
updater.update
} onChange: { [updater, notifier] in
Task {
guard !updater.currentVersion.isTestBuild else { return }
await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release)
}
}
@SceneBuilder var body: some Scene {
MenuBarExtra("Test", systemImage: "lock") {
AgentStatusView()
.fixedSize()
}
.menuBarExtraStyle(.window)
}
}
//@main
//class AppDelegate: NSObject, NSApplicationDelegate {
//
// @MainActor private let storeList: SecretStoreList = {
// let list = SecretStoreList()
// let cryptoKit = SecureEnclave.Store()
// let migrator = SecureEnclave.CryptoKitMigrator()
// try? migrator.migrate(to: cryptoKit)
// list.add(store: cryptoKit)
// list.add(store: SmartCard.Store())
// return list
// }()
// private let updater = Updater(checkOnLaunch: true)
// private let notifier = Notifier()
// private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
// private lazy var agent: Agent = {
// Agent(storeList: storeList, witness: notifier)
// }()
// private lazy var socketController: SocketController = {
// let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String
// return SocketController(path: path)
// }()
// private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
//
// func applicationDidFinishLaunching(_ aNotification: Notification) {
// logger.debug("SecretAgent finished launching")
// Task {
// for await session in socketController.sessions {
// Task {
// do {
// for await message in session.messages {
// let agentResponse = try await agent.handle(data: message, provenance: session.provenance)
// try await session.write(agentResponse)
// }
// } catch {
// try session.close()
// }
// }
// }
// }
// Task {
// for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
// try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
// }
// }
// try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
// notifier.prompt()
// _ = withObservationTracking {
// updater.update
// } onChange: { [updater, notifier] in
// Task {
// guard !updater.testBuild else { return }
// await notifier.notify(update: updater.update!) { release in
// await updater.ignore(release: release)
// }
// }
// }
// }
//
//}
//

View File

@@ -9,7 +9,22 @@
<key>Website</key>
<string>https://github.com/maxgoedjen/secretive</string>
<key>Connections</key>
<array/>
<array>
<dict>
<key>IsIncoming</key>
<false/>
<key>Host</key>
<string>api.github.com</string>
<key>NetworkProtocol</key>
<string>TCP</string>
<key>Port</key>
<string>443</string>
<key>Purpose</key>
<string>Secretive checks GitHub for new versions and security updates.</string>
<key>DenyConsequences</key>
<string>If you deny these connections, you will not be notified about new versions and critical security updates.</string>
</dict>
</array>
<key>Services</key>
<array/>
</dict>

View File

@@ -69,7 +69,7 @@ final class Notifier: Sendable {
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive
if await store.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) == nil && secret.authenticationRequirement.required {
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
}
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
@@ -79,25 +79,6 @@ final class Notifier: Sendable {
try? await notificationCenter.add(request)
}
func notify(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
await notificationDelegate.state.setPending(secret: secret, store: store)
let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "pending" //String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
notificationContent.subtitle = "pending" //String(localized: .signedNotificationDescription(secretName: secret.name))
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
notificationContent.threadIdentifier = "\(secret.id)_\(provenance.hashValue)"
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
notificationContent.attachments = [attachment]
}
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
try? await notificationCenter.add(request)
}
func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
let notificationCenter = UNUserNotificationCenter.current()
@@ -122,10 +103,6 @@ extension Notifier: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
}
func witness(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
await notify(pendingAccessTo: secret, from: store, by: provenance)
}
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
await notify(accessTo: secret, from: store, by: provenance)
}

View File

@@ -2,24 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.smartcard</key>
<true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>

View File

@@ -1,30 +0,0 @@
import Foundation
import SecretAgentKit
import Brief
import XPCWrappers
import OSLog
import SSHProtocolKit
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "XPCAgentInputParser")
private let session: XPCTypedSession<SSHAgent.Request, SSHAgentInputParser.AgentParsingError>
public init() async throws {
logger.debug("Creating XPCAgentInputParser")
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentInputParser", warmup: true)
logger.debug("XPCAgentInputParser is warmed up.")
}
public func parse(data: Data) async throws -> SSHAgent.Request {
logger.debug("Parsing input")
defer { logger.debug("Parsed input") }
return try await session.send(data)
}
deinit {
session.complete()
}
}

View File

@@ -1,11 +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>XPCService</key>
<dict>
<key>ServiceType</key>
<string>Application</string>
</dict>
</dict>
</plist>

View File

@@ -1,22 +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>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key>
<true/>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
</dict>
</plist>

View File

@@ -1,18 +0,0 @@
import Foundation
import OSLog
import XPCWrappers
import SecretAgentKit
import SSHProtocolKit
final class SecretAgentInputParser: NSObject, XPCProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretAgentInputParser", category: "SecretAgentInputParser")
func process(_ data: Data) async throws -> SSHAgent.Request {
let parser = SSHAgentInputParser()
let result = try parser.parse(data: data)
logger.log("Parser parsed message as type \(result.debugDescription)")
return result
}
}

View File

@@ -1,7 +0,0 @@
import Foundation
import XPCWrappers
let delegate = XPCServiceDelegate(exportedObject: SecretAgentInputParser())
let listener = NSXPCListener.service()
listener.delegate = delegate
listener.resume()

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:Config/Secretive.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -4,88 +4,6 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit
import Brief
@main
struct Secretive: App {
@Environment(\.agentLaunchController) var agentLaunchController
@Environment(\.justUpdatedChecker) var justUpdatedChecker
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView()
.environment(EnvironmentValues._secretStoreList)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@AppStorage("explicitlyDisabled") var explicitlyDisabled = false
guard hasRunSetup && !explicitlyDisabled else { return }
agentLaunchController.check()
guard !agentLaunchController.developmentBuild else { return }
if justUpdatedChecker.justUpdatedBuild || !agentLaunchController.running {
// Relaunch the agent, since it'll be running from earlier update still
try await agentLaunchController.forceLaunch()
}
}
}
}
.commands {
AppCommands()
}
WindowGroup(id: String(describing: IntegrationsView.self)) {
IntegrationsView()
}
.windowResizability(.contentMinSize)
WindowGroup(id: String(describing: AboutView.self)) {
AboutView()
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
}
extension Secretive {
struct AppCommands: Commands {
@Environment(\.openWindow) var openWindow
@Environment(\.openURL) var openURL
@FocusedValue(\.showCreateSecret) var showCreateSecret
var body: some Commands {
CommandGroup(replacing: .appInfo) {
Button(.aboutMenuBarTitle, systemImage: "info.circle") {
openWindow(id: String(describing: AboutView.self))
}
}
CommandGroup(before: CommandGroupPlacement.appSettings) {
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
openWindow(id: String(describing: IntegrationsView.self))
}
}
CommandGroup(after: CommandGroupPlacement.newItem) {
Button(.appMenuNewSecretButton, systemImage: "plus") {
showCreateSecret?()
}
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
.disabled(showCreateSecret?.isEnabled == false)
}
CommandGroup(replacing: .help) {
Button(.appMenuHelpButton) {
openURL(Constants.helpURL)
}
}
SidebarCommands()
}
}
}
private enum Constants {
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
}
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).
@@ -99,38 +17,102 @@ extension EnvironmentValues {
return list
}()
private static let _agentLaunchController = AgentLaunchController()
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
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
private static let _justUpdatedChecker = JustUpdatedChecker()
@Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker
@MainActor var secretStoreList: SecretStoreList {
EnvironmentValues._secretStoreList
}
}
extension FocusedValues {
@Entry var showCreateSecret: OpenSheet?
}
@main
struct Secretive: App {
private let justUpdatedChecker = JustUpdatedChecker()
@Environment(\.agentStatusChecker) var agentStatusChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@State private var showingIntegrations = false
@State private var showingCreation = false
final class OpenSheet {
let closure: () -> Void
let isEnabled: Bool
init(isEnabled: Bool = true, closure: @escaping () -> Void) {
self.isEnabled = isEnabled
self.closure = closure
}
func callAsFunction() {
closure()
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environment(EnvironmentValues._secretStoreList)
.onAppear {
if !hasRunSetup {
showingSetup = true
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
guard hasRunSetup else { return }
agentStatusChecker.check()
if agentStatusChecker.running && justUpdatedChecker.justUpdated {
// Relaunch the agent, since it'll be running from earlier update still
reinstallAgent()
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
forceLaunchAgent()
}
}
.sheet(isPresented: $showingIntegrations) {
IntegrationsView()
}
}
.commands {
CommandGroup(before: CommandGroupPlacement.appSettings) {
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
showingIntegrations = true
}
}
CommandGroup(after: CommandGroupPlacement.newItem) {
Button(.appMenuNewSecretButton) {
showingCreation = true
}
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
}
CommandGroup(replacing: .help) {
Button(.appMenuHelpButton) {
NSWorkspace.shared.open(Constants.helpURL)
}
}
SidebarCommands()
}
}
}
extension Secretive {
private func reinstallAgent() {
justUpdatedChecker.check()
Task {
_ = await LaunchAgentController().install()
try? await Task.sleep(for: .seconds(1))
agentStatusChecker.check()
if !agentStatusChecker.running {
forceLaunchAgent()
}
}
}
private func forceLaunchAgent() {
// We've run setup, we didn't just update, launchd is just not doing it's thing.
// Force a launch directly.
Task {
_ = await LaunchAgentController().forceLaunch()
agentStatusChecker.check()
}
}
}
private enum Constants {
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
}

View File

@@ -0,0 +1,68 @@
{
"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",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "Icon-macOS-ClearDark-256x256@2x.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"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

View File

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

View File

@@ -2,26 +2,18 @@ import Foundation
import AppKit
import SecretKit
import Observation
import OSLog
import ServiceManagement
import Common
@MainActor protocol AgentLaunchControllerProtocol: Observable, Sendable {
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
var running: Bool { get }
var developmentBuild: Bool { get }
var process: NSRunningApplication? { get }
func check()
func install() async throws
func uninstall() async throws
func forceLaunch() async throws
}
@Observable @MainActor final class AgentLaunchController: AgentLaunchControllerProtocol {
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
var running: Bool = false
var process: NSRunningApplication? = nil
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
private let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
nonisolated init() {
Task { @MainActor in
@@ -41,7 +33,7 @@ import Common
// The process corresponding to this instance of Secretive
var instanceSecretAgentProcess: NSRunningApplication? {
// TODO: CHECK VERSION
// FIXME: CHECK VERSION
let agents = allSecretAgentProcesses
for agent in agents {
guard let url = agent.bundleURL else { continue }
@@ -57,47 +49,6 @@ import Common
Bundle.main.bundleURL.isXcodeURL
}
func install() async throws {
logger.debug("Installing agent")
try? await service.unregister()
// This is definitely a bit of a "seems to work better" thing but:
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
// and start new?
try await Task.sleep(for: .seconds(1))
try service.register()
try await Task.sleep(for: .seconds(1))
check()
}
func uninstall() async throws {
logger.debug("Uninstalling agent")
try await Task.sleep(for: .seconds(1))
try await service.unregister()
try await Task.sleep(for: .seconds(1))
check()
}
func forceLaunch() async throws {
logger.debug("Agent is not running, attempting to force launch by reinstalling")
try await install()
if running {
logger.debug("Agent successfully force launched by reinstalling")
return
}
logger.debug("Agent is not running, attempting to force launch by launching directly")
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
let config = NSWorkspace.OpenConfiguration()
config.activates = false
do {
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
logger.debug("Agent force launched")
try await Task.sleep(for: .seconds(1))
} catch {
logger.error("Error force launching \(error.localizedDescription)")
}
check()
}
}
extension URL {

View File

@@ -1,33 +1,23 @@
import Foundation
import AppKit
@MainActor protocol JustUpdatedCheckerProtocol: Observable {
var justUpdatedBuild: Bool { get }
var justUpdatedOS: Bool { get }
protocol JustUpdatedCheckerProtocol: Observable {
var justUpdated: Bool { get }
}
@Observable @MainActor class JustUpdatedChecker: JustUpdatedCheckerProtocol {
@Observable class JustUpdatedChecker: JustUpdatedCheckerProtocol {
var justUpdatedBuild: Bool = false
var justUpdatedOS: Bool = false
var justUpdated: Bool = false
nonisolated init() {
Task { @MainActor in
check()
}
init() {
check()
}
private func check() {
let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String
let lastOS = UserDefaults.standard.object(forKey: Constants.previousOSVersionUserDefaultsKey) as? String
func check() {
let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String ?? "None"
let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
let osRaw = ProcessInfo.processInfo.operatingSystemVersion
let currentOS = "\(osRaw.majorVersion).\(osRaw.minorVersion).\(osRaw.patchVersion)"
UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey)
UserDefaults.standard.set(currentOS, forKey: Constants.previousOSVersionUserDefaultsKey)
justUpdatedBuild = lastBuild != currentBuild
// To prevent this showing on first lauch for every user, only show if lastBuild is non-nil.
justUpdatedOS = lastBuild != nil && lastOS != currentOS
justUpdated = lastBuild != currentBuild
}
@@ -38,7 +28,6 @@ extension JustUpdatedChecker {
enum Constants {
static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild"
static let previousOSVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastOS"
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
import ServiceManagement
import AppKit
import OSLog
import SecretKit
struct LaunchAgentController {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
func install() async -> Bool {
logger.debug("Installing agent")
_ = setEnabled(false)
// This is definitely a bit of a "seems to work better" thing but:
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
// and start new?
try? await Task.sleep(for: .seconds(1))
let result = await MainActor.run {
setEnabled(true)
}
try? await Task.sleep(for: .seconds(1))
return result
}
func uninstall() async -> Bool {
logger.debug("Uninstalling agent")
try? await Task.sleep(for: .seconds(1))
let result = await MainActor.run {
setEnabled(false)
}
try? await Task.sleep(for: .seconds(1))
return result
}
func forceLaunch() async -> Bool {
logger.debug("Agent is not running, attempting to force launch")
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
let config = NSWorkspace.OpenConfiguration()
config.activates = false
do {
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
logger.debug("Agent force launched")
try? await Task.sleep(for: .seconds(1))
return true
} catch {
logger.error("Error force launching \(error.localizedDescription)")
return false
}
}
private func setEnabled(_ enabled: Bool) -> Bool {
let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
return true
} catch {
return false
}
}
}

View File

@@ -0,0 +1,36 @@
{\rtf1\ansi\ansicpg1252\cocoartf2580
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\margl1440\margr1440\vieww9000\viewh8400\viewkind0
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6119\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
{\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive"}}{\fldrslt
\f0\fs24 \cf0 GitHub Repository}}
\f0\fs24 \
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\cf0 \
{\field{\*\fldinst{HYPERLINK "GITHUB_BUILD_URL"}}{\fldrslt Build Log}}\
\
Special Thanks To:\
\
{\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive/graphs/contributors"}}{\fldrslt Contributors}}:\
{\field{\*\fldinst{HYPERLINK "https://github.com/0xflotus"}}{\fldrslt 0xflotus}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/aaron-trout"}}{\fldrslt Aaron Trout}}\
\pard\pardeftab720\partightenfactor0
{\field{\*\fldinst{HYPERLINK "https://github.com/EppO"}}{\fldrslt \cf0 Florent Monbillard}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/vladimyr"}}{\fldrslt Dario Vladovi\uc0\u263 }}\
{\field{\*\fldinst{HYPERLINK "https://github.com/lavalleeale"}}{\fldrslt Alex Lavallee}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/joshheyse"}}{\fldrslt Josh}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/diesal11"}}{\fldrslt Dylan Lundy}}\
\
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\cf0 Testers:\
{\field{\*\fldinst{HYPERLINK "https://github.com/bdash"}}{\fldrslt Mark Rowe}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/danielctull"}}{\fldrslt Daniel Tull}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/davedelong"}}{\fldrslt Dave DeLong}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/esttorhe"}}{\fldrslt Esteban Torres}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/joeblau"}}{\fldrslt Joe Blau}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/marksands"}}{\fldrslt Mark Sands}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/mergesort"}}{\fldrslt Joe Fabisevich}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/phillco"}}{\fldrslt Phil Cohen}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/zackdotcomputer"}}{\fldrslt Zack Sheppard}}}

Some files were not shown because too many files have changed in this diff Show More