Порівняти коміти

..
9 Коміти
Автор SHA1 Повідомлення Дата
Max Goedjen 581e59f2e3 Pop two 2025-09-03 01:18:52 -07:00
Max Goedjen 6ede76a86f Abs 2025-09-03 01:15:20 -07:00
Max Goedjen 58f8680eea Tring 2025-09-03 01:03:32 -07:00
Max Goedjen 4bbf99f6fe Test 2025-09-03 01:00:28 -07:00
Max Goedjen 3d74ef39b9 No path 2025-09-03 00:55:23 -07:00
Max Goedjen 325606612e Try setting home. 2025-09-03 00:52:36 -07:00
Max Goedjen b8f349d1f0 Update path 2025-09-03 00:36:47 -07:00
Max Goedjen d3e1852b63 Merge branch 'main' into crowdin_config 2025-09-03 00:33:07 -07:00
Max Goedjen 612c7e1d71 Add Crowdin configuration file 2025-09-02 21:50:50 -07:00
173 змінених файлів з 3286 додано та 28053 видалено
+13
Переглянути файл
@@ -0,0 +1,13 @@
"project_id_env": "CROWDIN_PROJECT_ID"
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
"base_path": "../../"
"preserve_hierarchy": true
"files": [
{
"source": "Sources/Packages/Resources/Localizable.xcstrings",
"translation": "Sources/Packages/Resources/Localizable.xcstrings",
"multilingual": true
}
]
Бінарний файл не відображається.

До

Ширина:  |  Висота:  |  Розмір: 668 KiB

Після

Ширина:  |  Висота:  |  Розмір: 520 KiB

Бінарний файл не відображається.

До

Ширина:  |  Висота:  |  Розмір: 618 KiB

Після

Ширина:  |  Висота:  |  Розмір: 519 KiB

Бінарний файл не відображається.

Після

Ширина:  |  Висота:  |  Розмір: 1.3 MiB

Бінарний файл не відображається.

Після

Ширина:  |  Висота:  |  Розмір: 162 KiB

Бінарний файл не відображається.

Після

Ширина:  |  Висота:  |  Розмір: 1.7 MiB

Бінарний файл не відображається.

До

Ширина:  |  Висота:  |  Розмір: 47 KiB

Після

Ширина:  |  Висота:  |  Розмір: 1.4 MiB

Бінарний файл не відображається.

До

Ширина:  |  Висота:  |  Розмір: 230 KiB

Після

Ширина:  |  Висота:  |  Розмір: 259 KiB

-42
Переглянути файл
@@ -1,42 +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' && 'xcode-27') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
- 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}}"
+17 -30
Переглянути файл
@@ -3,16 +3,10 @@ name: Nightly
on:
schedule:
- cron: "0 8 * * *"
jobs:
build:
runs-on: xcode-27
permissions:
id-token: write
contents: write
attestations: write
artifact-metadata: write
actions: read
# runs-on: macOS-latest
runs-on: macos-15
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
@@ -25,32 +19,21 @@ jobs:
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.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: IDEEnableNewPackagePIFBuilder=NO 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@v7
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: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- 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 }}
@@ -58,7 +41,11 @@ jobs:
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@v4
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
-63
Переглянути файл
@@ -1,63 +0,0 @@
name: One-Off Build
on:
workflow_dispatch:
jobs:
build:
runs-on: xcode-27
permissions:
id-token: write
contents: write
attestations: write
artifact-metadata: 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: 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: IDEEnableNewPackagePIFBuilder=NO 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@v7
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@v4
with:
subject-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
+32 -33
Переглянути файл
@@ -6,9 +6,8 @@ on:
- '*'
jobs:
test:
permissions:
contents: read
runs-on: xcode-27
# runs-on: macOS-latest
runs-on: macos-15
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
@@ -21,18 +20,17 @@ jobs:
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.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
artifact-metadata: write
actions: read
runs-on: xcode-27
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
@@ -45,6 +43,8 @@ jobs:
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.0.app
- name: Update Build Number
env:
TAG_NAME: ${{ github.ref }}
@@ -53,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: IDEEnableNewPackagePIFBuilder=NO 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@v7
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 }}
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- 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 }}
@@ -79,17 +67,28 @@ jobs:
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@v4
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
+5 -6
Переглянути файл
@@ -3,15 +3,14 @@ name: Test
on: [push, pull_request]
jobs:
test:
permissions:
contents: read
runs-on: xcode-27
# 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.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
Переглянути файл
@@ -93,7 +93,3 @@ iOSInjectionProject/
Archive.xcarchive
.DS_Store
contents.xcworkspacedata
# Per-User Configs
Sources/Config/OpenSource.xcconfig
-4
Переглянути файл
@@ -10,10 +10,6 @@ Security is obviously paramount for a project like Secretive. As such, any contr
Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected.
### AI/LLM Policy
For security and auditing reasons similar to the policy Secretive has on dependencies, any code generated with AI or LLM tools will not be accepted.
## Code of Conduct
All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md)

До

Ширина:  |  Висота:  |  Розмір: 43 KiB

Після

Ширина:  |  Висота:  |  Розмір: 43 KiB

До

Ширина:  |  Висота:  |  Розмір: 26 KiB

Після

Ширина:  |  Висота:  |  Розмір: 26 KiB

До

Ширина:  |  Висота:  |  Розмір: 69 KiB

Після

Ширина:  |  Висота:  |  Розмір: 69 KiB

Переглянути файл
+26 -25
Переглянути файл
@@ -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).
-36
Переглянути файл
@@ -22,15 +22,6 @@ let package = Package(
.library(
name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]),
.library(
name: "CertificateKit",
targets: ["CertificateKit"]),
.library(
name: "SSHProtocolKit",
targets: ["SSHProtocolKit"]),
.library(
name: "Formatters",
targets: ["Formatters"]),
],
dependencies: [
],
@@ -62,33 +53,6 @@ let package = Package(
resources: [localization],
swiftSettings: swiftSettings
),
.target(
name: "CertificateKit",
dependencies: ["SecretKit", "Formatters"],
path: "Sources/Packages/Sources/CertificateKit",
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SSHProtocolKit",
dependencies: ["SecretKit", "CertificateKit"],
path: "Sources/Packages/Sources/SSHProtocolKit",
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SSHProtocolKitTests",
dependencies: ["SSHProtocolKit"],
path: "Sources/Packages/Tests/SSHProtocolKitTests",
swiftSettings: swiftSettings,
),
.target(
name: "Formatters",
dependencies: [],
path: "Sources/Packages/Sources/Formatters",
resources: [localization],
swiftSettings: swiftSettings,
),
]
)
+5 -13
Переглянути файл
@@ -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).
-6
Переглянути файл
@@ -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)
+5 -17
Переглянути файл
@@ -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"
}
}
],
-66
Переглянути файл
@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SecretAgentKit"
BuildableName = "SecretAgentKit"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnabled = "No">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SecretAgentKit"
BuildableName = "SecretAgentKit"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10 -71
Переглянути файл
@@ -1,4 +1,4 @@
// swift-tools-version:6.4
// swift-tools-version:6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -7,7 +7,7 @@ let package = Package(
name: "SecretivePackages",
defaultLocalization: "en",
platforms: [
.macOS(.v15)
.macOS(.v14)
],
products: [
.library(
@@ -19,33 +19,15 @@ let package = Package(
.library(
name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]),
.library(
name: "CertificateKit",
targets: ["CertificateKit"]),
.library(
name: "SettingsKit",
targets: ["SettingsKit"]),
.library(
name: "SecretAgentKit",
targets: ["SecretAgentKit"]),
.library(
name: "Formatters",
targets: ["Formatters"]),
.library(
name: "Common",
targets: ["Common"]),
.library(
name: "SharedXPCServices",
targets: ["SharedXPCServices"]),
name: "SecretAgentKitHeaders",
targets: ["SecretAgentKitHeaders"]),
.library(
name: "Brief",
targets: ["Brief"]),
.library(
name: "XPCWrappers",
targets: ["XPCWrappers"]),
.library(
name: "SSHProtocolKit",
targets: ["SSHProtocolKit"]),
],
dependencies: [
],
@@ -58,7 +40,7 @@ let package = Package(
),
.testTarget(
name: "SecretKitTests",
dependencies: ["SecretKit", "SecretAgentKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
swiftSettings: swiftSettings,
),
.target(
@@ -73,60 +55,22 @@ let package = Package(
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "CertificateKit",
dependencies: ["SecretKit", "Formatters"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SettingsKit",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SecretAgentKit",
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
resources: [localization],
swiftSettings: swiftSettings,
),
.systemLibrary(
name: "SecretAgentKitHeaders",
),
.testTarget(
name: "SecretAgentKitTests",
dependencies: ["SecretAgentKit"],
),
.target(
name: "SSHProtocolKit",
dependencies: ["SecretKit", "CertificateKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SSHProtocolKitTests",
dependencies: ["SSHProtocolKit"],
swiftSettings: swiftSettings,
),
.target(
name: "Formatters",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "Common",
dependencies: ["SSHProtocolKit", "SecretKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SharedXPCServices",
dependencies: ["XPCWrappers", "CertificateKit", "SSHProtocolKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "Brief",
dependencies: ["XPCWrappers", "SSHProtocolKit"],
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
@@ -134,10 +78,6 @@ let package = Package(
name: "BriefTests",
dependencies: ["Brief"],
),
.target(
name: "XPCWrappers",
swiftSettings: swiftSettings,
),
]
)
@@ -149,6 +89,5 @@ var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error),
.strictMemorySafety()
]
}
Різницю між файлами не показано, бо вона завелика Завантажити різницю
+1 -54
Переглянути файл
@@ -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"))
}
}
}
+1 -10
Переглянути файл
@@ -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
}
}
+24 -13
Переглянути файл
@@ -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 {
testBuild = currentVersion == SemVer("0.0.0")
if checkOnLaunch {
try await checkForUpdates()
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
Task {
await checkForUpdates()
}
while true {
try await Task.sleep(for: .seconds(Int(checkFrequency)))
try await checkForUpdates()
}
Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
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")!
}
}
+2 -2
Переглянути файл
@@ -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
}
-24
Переглянути файл
@@ -1,24 +0,0 @@
import Foundation
import CryptoKit
import Formatters
@dynamicMemberLookup
public struct Certificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible {
public var openSSHCertificate: OpenSSHCertificate
public let rawData: Data
public init(openSSHCertificate: OpenSSHCertificate, rawData: Data) {
self.openSSHCertificate = openSSHCertificate
self.rawData = rawData
}
public var id: String { Insecure.MD5.hash(data: rawData).formatted(.hex(separator: "")) }
public var debugDescription: String { openSSHCertificate.debugDescription }
public subscript<T>(dynamicMember keyPath: KeyPath<OpenSSHCertificate, T>) -> T {
openSSHCertificate[keyPath: keyPath]
}
}
-154
Переглянути файл
@@ -1,154 +0,0 @@
import Foundation
import Observation
import Security
import OSLog
import SecretKit
@Observable @MainActor public final class CertificateStore: Sendable {
public private(set) var certificates: [Certificate] = []
/// Initializes a Store.
public init() {
loadCertificates()
Task {
for await note in DistributedNotificationCenter.default().notifications(named: .certificateStoreUpdated) {
guard Constants.notificationToken != (note.object as? String) else {
// Don't reload if we're the ones triggering this by reloading.
continue
}
loadCertificates()
}
}
}
public func reloadCertificates() {
let before = certificates
certificates.removeAll()
loadCertificates()
if certificates != before {
NotificationCenter.default.post(name: .certificateStoreReloaded, object: self)
DistributedNotificationCenter.default().postNotificationName(.certificateStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
}
}
public func save(certificate: Certificate) throws {
let attributes = try JSONEncoder().encode(certificate.openSSHCertificate)
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: certificate.id,
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: certificate.rawData,
kSecAttrGeneric: attributes
])
let status = SecItemAdd(keychainAttributes, nil)
if status != errSecSuccess && status != errSecDuplicateItem {
throw KeychainError(statusCode: status)
}
reloadCertificates()
}
public func delete(certificate: Certificate) throws {
let deleteAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: certificate.id,
])
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadCertificates()
}
public func update(certificate: Certificate) throws {
let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: certificate.id,
])
let cert = try JSONEncoder().encode(certificate.openSSHCertificate)
let updatedAttributes = KeychainDictionary([
kSecAttrGeneric: cert,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadCertificates()
}
public func certificates(for secret: any Secret) -> [Certificate] {
certificates.filter { $0.openSSHCertificate.publicKey.data == secret.publicKey }
}
}
extension CertificateStore {
/// Loads all certificates from the store.
private func loadCertificates() {
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let decoder = JSONDecoder()
let wrapped: [Certificate] = typed.compactMap {
do {
guard let data = $0[kSecValueData] as? Data,
let attributesData = $0[kSecAttrGeneric] as? Data else {
throw MissingAttributesError()
}
return Certificate(openSSHCertificate: try decoder.decode(OpenSSHCertificate.self, from: attributesData), rawData: data)
} catch {
return nil
}
}
.filter {
if let validityRange = $0.validityRange {
validityRange.contains(Date())
} else {
true
}
}
certificates.append(contentsOf: wrapped)
}
}
extension CertificateStore {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = "com.maxgoedjen.certificatestore.opensshcertificate"
static let notificationToken = UUID().uuidString
}
struct UnsupportedAlgorithmError: Error {}
struct MissingAttributesError: Error {}
}
extension NSNotification.Name {
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed certificates)
public static let certificateStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.updated")
// Internal notification that certificates were reloaded from the backing store.
public static let certificateStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.reloaded")
}
-82
Переглянути файл
@@ -1,82 +0,0 @@
import Foundation
import Formatters
public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, CustomDebugStringConvertible {
public var type: CertificateType
public var name: String
public var data: Data
public var publicKey: PublicKey
public var principals: [String]
public var keyID: String
public var serial: UInt64
public var validityRange: Range<Date>?
public var criticalOptions: [String]
public var extensions: [String]
public var signingKey: PublicKey
public init(
type: OpenSSHCertificate.CertificateType,
name: String,
data: Data,
publicKey: PublicKey,
principals: [String],
keyID: String,
serial: UInt64,
validityRange: Range<Date>? = nil,
criticalOptions: [String],
extensions: [String],
signingKey: PublicKey,
) {
self.type = type
self.name = name
self.data = data
self.publicKey = publicKey
self.principals = principals
self.keyID = keyID
self.serial = serial
self.validityRange = validityRange
self.criticalOptions = criticalOptions
self.extensions = extensions
self.signingKey = signingKey
}
public var debugDescription: String {
"OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))"
}
}
extension OpenSSHCertificate {
public enum CertificateType: String, Sendable, Codable {
case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
public var keyIdentifier: String {
rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
}
}
}
extension OpenSSHCertificate {
public struct PublicKey: Hashable, Sendable, Codable {
public let keyType: String
public let curveName: String
public let data: Data
public init(keyType: String, curveName: String, data: Data) {
self.keyType = keyType
self.curveName = curveName
self.data = data
}
}
}
-262
Переглянути файл
@@ -1,262 +0,0 @@
import SwiftUI
import UniformTypeIdentifiers
public struct MultilineInfoView<TitleView: View, ItemView: View>: View {
var titleView: TitleView
var items: ItemView
public init(@ViewBuilder titleView: () -> TitleView, @ContentBuilder items: () -> ItemView) {
self.titleView = titleView()
self.items = items()
}
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource? = nil, image: Image, items: [String]) where TitleView == FixedTitleView, ItemView == FixedItemsView {
self.init {
FixedTitleView(title: title, subtitle: subtitle, image: image)
} items: {
FixedItemsView(items: items)
}
}
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource? = nil, image: Image, @ContentBuilder items: () -> ItemView) where TitleView == FixedTitleView {
self.init {
FixedTitleView(title: title, subtitle: subtitle, image: image)
} items: {
items()
}
}
public var body: some View {
VStack(alignment: .leading, spacing: 0) {
titleView
.bold()
.safeAreaPadding(20)
Group(subviews: items) { subviews in
ForEach(subviews: subviews) { subview in
Divider()
.ignoresSafeArea()
.opacity(subview.id == subviews.first?.id ? 1 : 0.5)
HoveringSubview {
subview
}
}
}
}
._background(interactionState: .normal)
.frame(minWidth: 150, maxWidth: .infinity)
}
}
struct HoveringSubview<Content: View>: View {
@State private var multilineAction: MultilineItemActionKey.Box?
private let content: Content
@State private var interactionState: InteractionState = .normal
init(@ViewBuilder _ content: () -> Content) {
self.multilineAction = nil
self.content = content()
}
var body: some View {
HStack {
content
Spacer()
if let image = multilineAction?.image {
image
}
}
.safeAreaPadding(20)
.onHover { hovering in
withAnimation {
guard multilineAction != nil else { return }
interactionState = hovering ? .hovering : .normal
}
}
.gesture(
TapGesture()
.onEnded {
multilineAction?.closure()
withAnimation {
interactionState = .normal
}
}
)
.backgroundStyle(.primary.opacity(interactionState == .hovering ? 0.3 : 0))
.onPreferenceChange(MultilineItemActionKey.self) {
multilineAction = $0
}
}
}
public struct FixedTitleView: View {
let title: LocalizedStringResource
let subtitle: LocalizedStringResource?
let image: Image
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource?, image: Image) {
self.title = title
self.subtitle = subtitle
self.image = image
}
public var body: some View {
HStack {
image
.renderingMode(.template)
.imageScale(.large)
VStack(alignment: .leading) {
Text(title)
.font(.headline)
if let subtitle {
Text(subtitle)
.font(.subheadline)
}
}
Spacer()
}
}
}
public struct FixedItemsView: View {
let items: [String]
public var body: some View {
ForEach(Array(items.enumerated()), id: \.offset) {
Text($0.element)
}
}
}
fileprivate enum InteractionState {
case normal, hovering
}
extension View {
fileprivate func _background(interactionState: InteractionState, cornerRadius: Double = 15) -> some View {
modifier(BackgroundViewModifier(interactionState: interactionState, cornerRadius: cornerRadius))
}
}
fileprivate struct BackgroundViewModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appearsActive) private var appearsActive
let interactionState: InteractionState
let cornerRadius: Double
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: cornerRadius))
.mask(RoundedRectangle(cornerRadius: cornerRadius))
.shadow(color: .black.opacity(0.1), radius: 5)
} else {
content
.background(backgroundColor(interactionState: interactionState))
.cornerRadius(10)
}
}
func backgroundColor(interactionState: InteractionState) -> Color {
guard appearsActive else { return Color.clear }
if #available(macOS 26.0, *) {
let base: Color
if #available(macOS 27.0, *) {
base = .clear
} else {
base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1)
}
switch interactionState {
case .normal:
return base
case .hovering:
return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1)
}
} else {
switch interactionState {
case .normal:
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
case .hovering:
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
}
}
}
}
public struct MultilineItemAction: ViewModifier {
let image: Image?
let action: () -> Void
public init(image: Image?, action: @escaping () -> Void) {
self.image = image
self.action = action
}
public func body(content: Content) -> some View {
content
.preference(key: MultilineItemActionKey.self, value: MultilineItemActionKey.Box(image: image, closure: action))
}
}
extension View {
public func multilineItemAction(image: Image? = nil, action: @escaping () -> Void) -> some View {
modifier(MultilineItemAction(image: image, action: action))
}
}
public struct MultilineItemActionKey : @MainActor PreferenceKey, ~Sendable {
public struct Box: Equatable {
let id: UUID = UUID()
let image: Image?
let closure: () -> Void
public static func == (lhs: borrowing MultilineItemActionKey.Box, rhs: borrowing MultilineItemActionKey.Box) -> Bool {
lhs.id == rhs.id
}
}
public typealias Value = Box?
@MainActor public static let defaultValue: Box? = nil
public static func reduce(value: inout Box?, nextValue: () -> Box?) {
value = nextValue()
}
}
#Preview {
MultilineInfoView {
Text("Hello")
} items: {
Text("World")
.multilineItemAction(image: Image(systemName: "person.wave")) {
print("Hello")
}
Text("World")
Text("World")
}
.padding()
}
#Preview {
MultilineInfoView(title: "One", image: Image(systemName: "figure.wave"), items: ["Hello world.", "Hello world."])
.padding()
}
-59
Переглянути файл
@@ -1,59 +0,0 @@
import Foundation
import SSHProtocolKit
import CertificateKit
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")
}
public static var certificatesDirectory: URL {
agentHomeURL.appending(component: "Certificates")
}
/// 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()
}
/// The path for a certificate.
/// - Parameter certificate: The Certificate to return the path for.
/// - Returns: The path to the Certificate.
/// - Warning: This method returning a path does not imply that a certificate has been written to disk already. This method only describes where it will be written to.
public static func certificatePath(for certificateID: String, in directory: URL) -> String {
return directory.appending(component: "\(certificateID)-cert.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)
}
}
-74
Переглянути файл
@@ -1,74 +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)
}
}
public struct Base64DataStyle<SequenceType: Sequence>: Hashable, Codable {
private let stripPadding: Bool
public init(stripPadding: Bool) {
self.stripPadding = stripPadding
}
}
extension Base64DataStyle: FormatStyle where SequenceType.Element == UInt8 {
public func format(_ value: SequenceType) -> String {
let base64 = Data(value).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
return base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
}
}
extension FormatStyle where Self == Base64DataStyle<Data> {
public static func base64(stripPadding: Bool) -> Base64DataStyle<Data> {
Base64DataStyle(stripPadding: stripPadding)
}
}
extension FormatStyle where Self == Base64DataStyle<SHA256.Digest> {
public static func base64(stripPadding: Bool) -> Base64DataStyle<SHA256.Digest> {
Base64DataStyle(stripPadding: stripPadding)
}
}
-30
Переглянути файл
@@ -1,30 +0,0 @@
import Foundation
import CryptoKit
import CertificateKit
import Formatters
/// Generates OpenSSH representations of Certificates.
public struct OpenSSHCertificateWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the certificate.
/// - Returns: OpenSSH data payload identifying the certificate.
public func data(publicKey: OpenSSHCertificate.PublicKey) -> Data {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
publicKey.keyType.lengthAndData +
publicKey.curveName.lengthAndData +
publicKey.data.lengthAndData
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256KeyFingerprint(publicKey: OpenSSHCertificate.PublicKey) -> String {
// OpenSSL format seems to strip the padding at the end.
let cleaned = SHA256.hash(data: data(publicKey: publicKey)).formatted(.base64(stripPadding: true))
return "SHA256:\(cleaned)"
}
}
-95
Переглянути файл
@@ -1,95 +0,0 @@
import Foundation
import CertificateKit
public protocol OpenSSHCertificateParserProtocol {
func parse(data: Data) async throws -> OpenSSHCertificate
}
public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable {
public init() {
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
}
public func parse(data: Data) throws(OpenSSHCertificateError) -> OpenSSHCertificate {
let string = String(decoding: data, as: UTF8.self)
var elements = string
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: " ")
guard elements.count >= 2 else {
throw OpenSSHCertificateError.parsingFailed
}
let typeString = elements.removeFirst()
guard let type = OpenSSHCertificate.CertificateType(rawValue: typeString) else { throw .unsupportedType }
let encodedKey = elements.removeFirst()
guard let decoded = Data(base64Encoded: encodedKey) else {
throw OpenSSHCertificateError.parsingFailed
}
let comment = elements.first
do {
let dataParser = OpenSSHReader(data: decoded)
let publicKeyType = try dataParser.readNextChunkAsString() // Theoretically the same as typeString, but
.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
_ = try dataParser.readNextChunk() // Nonce
let publicKeyCurveName = try dataParser.readNextChunkAsString()
let publicKeyData = try dataParser.readNextChunk()
let publicKey = OpenSSHCertificate.PublicKey(keyType: publicKeyType, curveName: publicKeyCurveName, data: publicKeyData)
let serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true)
_ = role
let keyIdentifier = try dataParser.readNextChunkAsString()
let principalsReader = try dataParser.readNextChunkAsSubReader()
var principals: [String] = []
while !principalsReader.done {
try principals.append(principalsReader.readNextChunkAsString())
}
let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let validBefore = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let validityRange = Date(timeIntervalSince1970: TimeInterval(validAfter))..<Date(timeIntervalSince1970: TimeInterval(validBefore))
let criticalOptionsReader = try dataParser.readNextChunkAsSubReader()
var criticalOptions: [String] = []
while !criticalOptionsReader.done {
let next = try criticalOptionsReader.readNextChunkAsString()
if !next.isEmpty {
criticalOptions.append(next)
}
}
let extensionsReader = try dataParser.readNextChunkAsSubReader()
var extensions: [String] = []
while !extensionsReader.done {
let next = try extensionsReader.readNextChunkAsString()
if !next.isEmpty {
extensions.append(next)
}
}
_ = try dataParser.readNextChunk() // reserved
let signingKeyReader = try dataParser.readNextChunkAsSubReader()
let signingKeyType = try signingKeyReader.readNextChunkAsString()
let signingKeyCurveName = try signingKeyReader.readNextChunkAsString()
let signingKeyData = try signingKeyReader.readNextChunk()
let signingKey = OpenSSHCertificate.PublicKey(keyType: signingKeyType, curveName: signingKeyCurveName, data: signingKeyData)
return OpenSSHCertificate(
type: type,
name: comment ?? keyIdentifier,
data: decoded,
publicKey: publicKey,
principals: principals,
keyID: keyIdentifier,
serial: serialNumber,
validityRange: validityRange,
criticalOptions: criticalOptions,
extensions: extensions,
signingKey: signingKey,
)
} catch {
throw .parsingFailed
}
}
}
public enum OpenSSHCertificateError: Error, Codable {
case unsupportedType
case parsingFailed
}
-70
Переглянути файл
@@ -1,70 +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)
if remaining.count == 0 {
done = true
}
}
/// 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 readNextByteAsBool() throws(OpenSSHReaderError) -> Bool {
let size = MemoryLayout<Bool>.size
guard remaining.count >= size else { throw .beyondBounds }
let lengthRange = 0..<size
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
if remaining.isEmpty {
done = true
}
return unsafe lengthChunk.bytes.unsafeLoad(as: Bool.self)
}
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
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 incorrectFormat
case beyondBounds
}
-250
Переглянути файл
@@ -1,250 +0,0 @@
import Foundation
import OSLog
import SecretKit
import CertificateKit
import CryptoKit
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() {
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
}
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(.empty).protocolID:
return .protocolExtension(try protocolExtension(from: body))
default:
return .unknown(rawRequestInt)
}
}
}
extension SSHAgentInputParser {
private enum Constants {
static let userAuthMagic: UInt8 = 50 // SSH2_MSG_USERAUTH_REQUEST
static let sshSigMagic = Data("SSHSIG".utf8)
}
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
let reader = OpenSSHReader(data: data)
let rawKeyBlob = try reader.readNextChunk()
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
let rawPayload = try reader.readNextChunk()
let payload: SSHAgent.Request.SignatureRequestContext.SignaturePayload
do {
if rawPayload.count > 6 && rawPayload[0..<6] == Constants.sshSigMagic {
payload = .init(raw: rawPayload, decoded: .sshSig(try sshSigPayload(from: rawPayload[6...])))
} else {
payload = .init(raw: rawPayload, decoded: .sshConnection(try sshConnectionPayload(from: rawPayload)))
}
} catch {
payload = .init(raw: rawPayload, decoded: nil)
}
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: payload)
}
func sshSigPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHSigPayload {
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L79
let payloadReader = OpenSSHReader(data: data)
let namespace = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunk() // reserved
let hashAlgorithm = try payloadReader.readNextChunkAsString()
let hash = try payloadReader.readNextChunk()
return .init(
namespace: namespace,
hashAlgorithm: hashAlgorithm,
hash: hash
)
}
func sshConnectionPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHConnectionPayload {
let payloadReader = OpenSSHReader(data: data)
_ = try payloadReader.readNextChunk()
let magic = try payloadReader.readNextBytes(as: UInt8.self, convertEndianness: false)
guard magic == Constants.userAuthMagic else { throw .incorrectFormat }
let username = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunkAsString() // "ssh-connection"
_ = try payloadReader.readNextChunkAsString() // "publickey-hostbound-v00@openssh.com"
let hasSignature = try payloadReader.readNextByteAsBool()
let algorithm = try payloadReader.readNextChunkAsString()
let publicKeyReader = try payloadReader.readNextChunkAsSubReader()
_ = try publicKeyReader.readNextChunk()
_ = try publicKeyReader.readNextChunk()
let publicKey = try publicKeyReader.readNextChunk()
let hostKeyReader = try payloadReader.readNextChunkAsSubReader()
_ = try hostKeyReader.readNextChunk()
let hostKey = try hostKeyReader.readNextChunk()
return .init(
username: username,
hasSignature: hasSignature,
publicKeyAlgorithm: algorithm,
publicKey: publicKey,
hostKey: hostKey,
)
}
func protocolExtension(from data: Data) throws(AgentParsingError) -> SSHAgent.ProtocolExtension {
do {
let reader = OpenSSHReader(data: data)
let nameRaw = try reader.readNextChunkAsString()
let nameSplit = nameRaw.split(separator: "@")
guard nameSplit.count == 2 else {
throw AgentParsingError.invalidData
}
let (name, domain) = (nameSplit[0], nameSplit[1])
switch domain {
case SSHAgent.ProtocolExtension.OpenSSHExtension.domain:
switch name {
case SSHAgent.ProtocolExtension.OpenSSHExtension.sessionBind(.empty).name:
do {
let hostkeyBlob = try reader.readNextChunkAsSubReader()
let hostKeyType = try hostkeyBlob.readNextChunkAsString()
let hostKeyData = try hostkeyBlob.readNextChunk()
let sessionID = try reader.readNextChunk()
let signatureBlob = try reader.readNextChunkAsSubReader()
_ = try signatureBlob.readNextChunk() // key type again
let signature = try signatureBlob.readNextChunk()
let forwarding = try reader.readNextByteAsBool()
switch hostKeyType {
case "ssh-ed25519":
let hostKey = try CryptoKit.Curve25519.Signing.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(signature, for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
case "ecdsa-sha2-nistp256":
let hostKey = try CryptoKit.P256.Signing.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
case "ecdsa-sha2-nistp384":
let hostKey = try CryptoKit.P384.Signing.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
case "ssh-mldsa-65":
if #available(macOS 26.0, *) {
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(signature, for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
} else {
throw AgentParsingError.unhandledRequest
}
case "ssh-mldsa-87":
if #available(macOS 26.0, *) {
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(signature, for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
} else {
throw AgentParsingError.unhandledRequest
}
case "ssh-rsa":
throw AgentParsingError.unhandledRequest
default:
throw AgentParsingError.unhandledRequest
}
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
hostKey: hostKeyData,
sessionID: sessionID,
signature: signature,
forwarding: forwarding
)
return .openSSH(.sessionBind(context))
} catch {
return .openSSH(.sessionBind(.empty))
}
default:
return .openSSH(.unknown(String(name)))
}
default:
return .unknown(nameRaw)
}
} catch let error as OpenSSHReaderError {
throw .openSSHReader(error)
} catch let error as AgentParsingError {
throw error
} catch {
throw .unknownRequest
}
}
func certificatePublicKeyBlob(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
do {
let certType = try reader.readNextChunkAsString()
guard let certType = OpenSSHCertificate.CertificateType(rawValue: certType) else { return nil }
_ = try reader.readNextChunk() // nonce
let curveIdentifier = try reader.readNextChunk()
let publicKey = try reader.readNextChunk()
let openSSHIdentifier = certType.keyIdentifier
return openSSHIdentifier.lengthAndData +
curveIdentifier.lengthAndData +
publicKey.lengthAndData
} catch {
return nil
}
}
}
extension SSHAgentInputParser {
public enum AgentParsingError: Error, Codable {
case unknownRequest
case unhandledRequest
case invalidData
case incorrectSignature
case openSSHReader(OpenSSHReaderError)
}
}
-163
Переглянути файл
@@ -1,163 +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(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: SignaturePayload
public init(keyBlob: Data, dataToSign: SignaturePayload) {
self.keyBlob = keyBlob
self.dataToSign = dataToSign
}
public static var empty: SignatureRequestContext {
SignatureRequestContext(keyBlob: Data(), dataToSign: SignaturePayload(raw: Data(), decoded: nil))
}
public struct SignaturePayload: Sendable, Codable {
public let raw: Data
public let decoded: DecodedPayload?
public init(
raw: Data,
decoded: DecodedPayload?
) {
self.raw = raw
self.decoded = decoded
}
public enum DecodedPayload: Sendable, Codable {
case sshConnection(SSHConnectionPayload)
case sshSig(SSHSigPayload)
public struct SSHConnectionPayload: Sendable, Codable {
public let username: String
public let hasSignature: Bool
public let publicKeyAlgorithm: String
public let publicKey: Data
public let hostKey: Data
public init(
username: String,
hasSignature: Bool,
publicKeyAlgorithm: String,
publicKey: Data,
hostKey: Data
) {
self.username = username
self.hasSignature = hasSignature
self.publicKeyAlgorithm = publicKeyAlgorithm
self.publicKey = publicKey
self.hostKey = hostKey
}
}
public struct SSHSigPayload: Sendable, Codable {
public let namespace: String
public let hashAlgorithm: String
public let hash: Data
public init(
namespace: String,
hashAlgorithm: String,
hash: Data,
) {
self.namespace = namespace
self.hashAlgorithm = hashAlgorithm
self.hash = hash
}
}
}
}
}
}
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
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: "SSH2_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH2_AGENT_SIGN_RESPONSE"
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
}
}
}
}
-76
Переглянути файл
@@ -1,76 +0,0 @@
import Foundation
// Extensions, as defined in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.agent
extension SSHAgent {
public enum ProtocolExtension: CustomDebugStringConvertible, Codable, Sendable {
case openSSH(OpenSSHExtension)
case unknown(String)
public var debugDescription: String {
switch self {
case let .openSSH(protocolExtension):
protocolExtension.debugDescription
case .unknown(let string):
"Unknown (\(string))"
}
}
public static var empty: ProtocolExtension {
.unknown("empty")
}
private struct ProtocolExtensionParsingError: Error {}
}
}
extension SSHAgent.ProtocolExtension {
public enum OpenSSHExtension: CustomDebugStringConvertible, Codable, Sendable {
case sessionBind(SessionBindContext)
case unknown(String)
public static var domain: String {
"openssh.com"
}
public var name: String {
switch self {
case .sessionBind:
"session-bind"
case .unknown(let name):
name
}
}
public var debugDescription: String {
"\(name)@\(OpenSSHExtension.domain)"
}
}
}
extension SSHAgent.ProtocolExtension.OpenSSHExtension {
public struct SessionBindContext: Codable, Sendable {
public let hostKey: Data
public let sessionID: Data
public let signature: Data
public let forwarding: Bool
public init(hostKey: Data, sessionID: Data, signature: Data, forwarding: Bool) {
self.hostKey = hostKey
self.sessionID = sessionID
self.signature = signature
self.forwarding = forwarding
}
public static let empty = SessionBindContext(hostKey: Data(), sessionID: Data(), signature: Data(), forwarding: false)
}
}
+69 -107
Переглянути файл
@@ -2,119 +2,75 @@ import Foundation
import CryptoKit
import OSLog
import SecretKit
import CertificateKit
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 {
private let storeList: SecretStoreList
private let authenticationHandler: AuthenticationHandler
private let certificateStore: CertificateStore
private let witness: SigningWitness?
private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let signatureWriter = OpenSSHSignatureWriter()
private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@MainActor private var sessionID: SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext?
/// Initializes an agent with a store list and a witness.
/// - Parameters:
/// - storeList: The `SecretStoreList` to make available.
/// - witness: A witness to notify of requests.
public init(
storeList: SecretStoreList,
certificateStore: CertificateStore,
authenticationHandler: AuthenticationHandler,
witness: SigningWitness? = nil
) {
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
logger.debug("Agent is running")
self.storeList = storeList
self.certificateStore = certificateStore
self.authenticationHandler = authenticationHandler
self.witness = witness
Task { @MainActor in
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
}
}
}
extension Agent {
public func handle(
request: SSHAgent.Request,
provenance: SigningRequestProvenance,
hosts: [Data: String]?
) async -> Data {
logger.debug("Agent received request of type \(request.debugDescription)")
/// 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):
let target: SigningRequestTarget?
switch context.dataToSign.decoded {
case .sshConnection(let payload):
target = .connection(
.init(
username: payload.username,
hasSignature: payload.hasSignature,
publicKeyAlgorithm: payload.publicKeyAlgorithm,
publicKey: payload.publicKey,
hostKey: payload.hostKey,
host: hosts?[payload.hostKey]
)
)
if let boundSession = await sessionID {
guard payload.hostKey == boundSession.hostKey else {
logger.error("Agent received bind request, but host key does not match signature request host key.")
throw BindingFailure()
}
}
case .sshSig(let payload):
target = .signature(
.init(
namespace: payload.namespace,
hashAlgorithm: payload.hashAlgorithm,
hash: payload.hash
)
)
default:
target = nil
}
_ = target
response.append(SSHAgent.Response.agentSignResponse.data)
response.append(try await sign(data: context.dataToSign.raw, keyBlob: context.keyBlob, provenance: provenance, target: target))
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .protocolExtension(.openSSH(.sessionBind(let bind))):
// This is disabled until forward enforcement is handled.
_ = bind
response = try await MainActor.run {
logger.debug("Agent received bind request but not currently supported.")
throw UnhandledRequestError()
// guard sessionID == nil else {
// logger.error("Agent received bind request, but already bound.")
// throw BindingFailure()
// }
// logger.debug("Agent bound")
// sessionID = bind
// return SSHAgent.Response.agentSuccess.data
}
logger.debug("Agent returned \(SSHAgent.Response.agentSuccess.debugDescription)")
case .unknown(let value):
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
}
@@ -127,6 +83,7 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() async -> Data {
let secrets = await storeList.allSecrets
await certificateHandler.reloadCertificates(for: secrets)
var count = 0
var keyData = Data()
@@ -135,15 +92,16 @@ extension Agent {
keyData.append(keyBlob.lengthAndData)
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
count += 1
for certificate in await certificateStore.certificates(for: secret) {
keyData.append(certificate.data.lengthAndData)
keyData.append(certificate.name.lengthAndData)
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(certificateData.lengthAndData)
keyData.append(name.lengthAndData)
count += 1
}
}
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
}
@@ -152,28 +110,33 @@ 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, target: SigningRequestTarget?) async throws -> Data {
guard let (secret, store) = await secret(matching: keyBlob) else {
let keyBlobHex = keyBlob.formatted(.hex())
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()
}
logger.debug("Agent offering witness chance to object")
do {
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance, target: target)
} catch {
logger.debug("Witness objected")
throw error
}
logger.debug("Witness did not object")
let request = SignatureRequest(secret: secret, provenance: provenance, target: target)
let newContext = AuthenticationContext(secret: secret, requests: [request])
let context = try await authenticationHandler.authenticatedContext(for: request, context: newContext)
let result = try await store.sign(data: data, with: secret, for: provenance, target: target, context: context?.laContext)
let signedData = signatureWriter.data(secret: secret, signature: result)
try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: secret.authenticationRequirement.required)
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: 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)
logger.debug("Agent signed request")
return signedData
}
@@ -206,17 +169,16 @@ extension Agent {
extension Agent {
struct InvalidDataProvidedError: Error {}
struct NoMatchingKeyError: Error {}
struct UnhandledRequestError: Error {}
struct BindingFailure: 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)
}
}
-218
Переглянути файл
@@ -1,218 +0,0 @@
@unsafe @preconcurrency import LocalAuthentication
import SecretKit
import OSLog
/// A context describing a persisted authentication.
public final class AuthenticationContext: AuthenticationContextProtocol {
/// The Secret to persist authentication for.
public let secret: AnySecret
/// The LAContext used to authorize the persistent context.
public let laContext: LAContext?
enum Validity {
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
case time(monotonicExpiration: UInt64)
case requestIDs(Set<UUID>)
case exclusive(UUID)
}
let validity: Validity
/// Initializes a context.
/// - Parameters:
/// - secret: The Secret to persist authentication for.
/// - duration: The duration of the authorization context, in seconds.
init<SecretType: Secret>(secret: SecretType, duration: TimeInterval) {
self.secret = AnySecret(secret)
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.validity = .time(monotonicExpiration: clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds))
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
let durationString = formatter.string(from: duration)!
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
laContext = newContext
}
init<SecretType: Secret>(secret: SecretType, requests: Set<SignatureRequest>) {
self.secret = AnySecret(secret)
if requests.count == 1 {
self.validity = .exclusive(requests.first!.id)
} else {
self.validity = .requestIDs(Set(requests.map(\.id)))
}
if secret.authenticationRequirement.required {
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let appName = requests.first!.provenance.origin.displayName
if requests.count > 1 {
newContext.localizedReason = String(localized: .authContextRequestMultiple(appName: appName, secretName: secret.name))
} else {
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: appName, secretName: secret.name))
}
laContext = newContext
} else {
laContext = nil
}
}
/// A boolean describing whether or not the context is still valid.
public func valid(for request: SignatureRequest) -> Bool {
switch validity {
case .time(let monotonicExpiration):
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
case .requestIDs(let set):
set.contains(request.id)
case .exclusive(let id):
id == request.id
}
}
public func evaluate(requireBiometrics: Bool) async throws -> Bool {
guard let laContext else { return false }
let policy: LAPolicy = requireBiometrics ? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication
return try await laContext.evaluatePolicy(policy, localizedReason: laContext.localizedReason)
}
public func cancel() async {
laContext?.invalidate()
}
}
@MainActor public protocol AuthenticationHandlerProtocol: Observable {
var batchableRequests: [[SignatureRequest]] { get }
func setPendingRequestHandler(_ handler: @escaping () async throws -> Void)
func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)?
func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws
func requestAuthentication(for requests: Set<SignatureRequest>) async throws
}
@Observable @MainActor public class AuthenticationHandler: AuthenticationHandlerProtocol {
private var authenticatedContexts: [AnySecret: AuthenticationContext] = [:]
private var waitingRequests: Set<SignatureRequest> = []
private var activeTask: Task<Bool, any Error>?
private var activeContext: (any AuthenticationContextProtocol)?
private var lastBatchAuthPresentation: Set<SignatureRequest>?
private var presentPendingAuth: (() async throws -> Void)?
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AuthenticationHandler")
public init() {
}
public func setPendingRequestHandler(_ handler: @escaping () async throws -> Void) {
self.presentPendingAuth = handler
}
public func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)? {
if request.secret.authenticationRequirement.required {
// Slow path, will block caller until authenticated (either directly or via a pending requests view).
return try await waitForAuthentication(for: request, context: context)
} else {
// Fast path, no blocking/enqueing required
return context
}
}
func waitForAuthentication(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> any AuthenticationContextProtocol {
logger.log("Entering waitForAuthentication for \(request.id)")
if let existing = existingAuthenticationContext(for: request) {
logger.log("Short circuiting wait, existing valid context already exists.")
return existing
}
waitingRequests.insert(request)
logger.log("Waiting for authentication for \(request.id)")
defer {
logger.log("Removed hold for \(request.id)")
waitingRequests.remove(request)
}
if waitingRequests.count > 1 {
return try await waitUntilRequestActedOn(request)
}
// Hold onto the task and context so we can cancel them when prsenting pending.
activeContext = context
let currentTask = Task<Bool, any Error> {
logger.log("Beginning individual auth prompt")
let result = (try? await context.evaluate(requireBiometrics: request.secret.authenticationRequirement == .biometryCurrent)) ?? false
logger.log("Ended individual auth prompt")
return result
}
activeTask = currentTask
let result = try? await activeTask?.value
if result == false && activeTask?.isCancelled == false {
waitingRequests.remove(request)
throw CancellationError()
} else if currentTask.isCancelled {
return try await waitUntilRequestActedOn(request)
}
return context
}
func waitUntilRequestActedOn(_ request: SignatureRequest) async throws -> any AuthenticationContextProtocol {
logger.log("Auth prompt was cancelled, waiting for explicit auth")
// At this point, we essentially just block the task until either the request has been authenticated "externally" via the pending view.
while waitingRequests.contains(request) {
if waitingRequests != lastBatchAuthPresentation {
// If we're about to present a batch, we cancel the individual auth prompt, and show the batch one.
logger.log("Multiple pending requests exist, cancelling existing auth prompt")
activeTask?.cancel()
lastBatchAuthPresentation = waitingRequests
logger.log("Requesting pending requests presentation")
try await presentPendingAuth?()
await activeContext?.cancel()
logger.log("Requested pending requests presentation")
}
if let preauthenticated = existingAuthenticationContext(for: request) {
logger.log("Explicit auth context found")
return preauthenticated
}
try await Task.sleep(for: .milliseconds(100))
}
throw CancellationError()
}
public var batchableRequests: [[SignatureRequest]] {
waitingRequests.reduce(into: [:]) { partialResult, next in
partialResult[next.batchID, default: []].append(next)
}
.values
.map { $0.sorted() }
}
private func existingAuthenticationContext(for request: SignatureRequest) -> (any AuthenticationContextProtocol)? {
guard let authenticated = authenticatedContexts[request.secret], authenticated.valid(for: request) else { return nil }
return authenticated
}
public func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws {
let context = AuthenticationContext(secret: secret, duration: duration)
let success = try await context.evaluate(requireBiometrics: secret.authenticationRequirement == .biometryCurrent)
guard success else { return }
authenticatedContexts[AnySecret(secret)] = context
}
public func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
activeTask?.cancel()
guard let first = requests.first else { return }
let context = AuthenticationContext(secret: first.secret, requests: requests)
let success = (try? await context.evaluate(requireBiometrics: first.secret.authenticationRequirement == .biometryCurrent)) ?? false
guard success else {
waitingRequests.subtract(requests)
return
}
// Even single-use ones get stuffed into authenticatedContexts, so that it can unblock the response path.
authenticatedContexts[AnySecret(first.secret)] = context
}
}
+24 -4
Переглянути файл
@@ -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)
}
}
-83
Переглянути файл
@@ -1,83 +0,0 @@
import Foundation
import OSLog
import SecretKit
import SSHProtocolKit
import CertificateKit
import Common
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let publicKeysURL: URL
private let certificatesURL: URL
private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(publicKeysURL: URL, certificatesURL: URL) {
self.publicKeysURL = publicKeysURL
self.certificatesURL = certificatesURL
}
/// Writes out the keys specified to disk.
/// - Parameter secrets: The Secrets to generate keys for.
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
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: publicKeysURL) })
.union(Set(secrets.map { legacySSHCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: publicKeysURL.path())) ?? []
let fullPathContents = contentsOfDirectory.map { publicKeysURL.appending(path: $0).path() }
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.createDirectory(at: publicKeysURL, withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
let path = URL.publicKeyPath(for: secret, in: publicKeysURL)
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
}
logger.log("Finished writing public keys")
}
/// Writes out the certificates specified to disk.
/// - Parameter certificates: The Secrets to generate keys for.
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
public func generateCertificates(for certificates: [Certificate], clear: Bool = false) throws {
logger.log("Writing certificates to disk")
if clear {
let validPaths = Set(certificates.map { URL.certificatePath(for: $0.id, in: certificatesURL) })
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: certificatesURL.path())) ?? []
let fullPathContents = contentsOfDirectory.map { certificatesURL.appending(path: $0).path() }
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.createDirectory(at: certificatesURL, withIntermediateDirectories: false, attributes: nil)
for certificate in certificates {
let path = URL.certificatePath(for: certificate.id, in: certificatesURL)
FileManager.default.createFile(atPath: path, contents: certificate.rawData, attributes: nil)
}
logger.log("Finished writing certificates")
}
/// The path for a Secret's SSH Certificate public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the SSH Certificate public key.
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
private func legacySSHCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return publicKeysURL.appending(component: "\(minimalHex).pub").path()
}
}
+46
Переглянути файл
@@ -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"
}
}
}
}
+17 -20
Переглянути файл
@@ -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,31 +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)
}
@@ -80,7 +81,3 @@ extension SigningRequestTracer {
}
}
//// from SecTask.h
@_silgen_name("SecCodeCreateWithPID")
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus
+2 -3
Переглянути файл
@@ -1,6 +1,5 @@
import Foundation
import SecretKit
import SSHProtocolKit
/// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access.
public protocol SigningWitness: Sendable {
@@ -11,13 +10,13 @@ public protocol SigningWitness: Sendable {
/// - store: The `Store` being asked to sign the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
/// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail.
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws
/// Notifies the callee that a signing operation has been performed for a given secret.
/// - Parameters:
/// - secret: The `Secret` that will was used to sign the request.
/// - store: The `Store` that signed the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws
}
+42 -33
Переглянути файл
@@ -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, object: fileHandle)
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,32 +77,29 @@ 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.")
messagesContinuation.finish()
try? fileHandle.close()
try fileHandle.close()
return
}
messagesContinuation.yield(data)
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 {
public func write(_ data: Data) async throws {
try fileHandle.write(contentsOf: data)
fileHandle.waitForDataInBackgroundAndNotify()
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()
let length = withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
path.withCString { cstring in
let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len)
return 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)
let data = unsafe Data(bytes: &addr, count: MemoryLayout<sockaddr_un>.size)
var len: Int = 0
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
path.withCString { cstring in
len = strlen(cstring)
strncpy(pointer, cstring, len)
}
}
addr.sun_len = UInt8(len+2)
var data: Data!
withUnsafePointer(to: &addr) { pointer in
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
}
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
}
+1
Переглянути файл
@@ -0,0 +1 @@
+19
Переглянути файл
@@ -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[];
+4
Переглянути файл
@@ -0,0 +1,4 @@
module SecretAgentKitHeaders [system] {
header "include/SecretAgentKit.h"
export *
}
+1 -1
Переглянути файл
@@ -31,7 +31,7 @@ SecretKit is a collection of protocols describing secrets and stores.
### Authentication Persistence
- ``AuthenticationContextProtocol``
- ``PersistedAuthenticationContext``
### Errors
+18 -7
Переглянути файл
@@ -1,5 +1,4 @@
import Foundation
import LocalAuthentication
/// Type eraser for SecretStore.
open class AnySecretStore: SecretStore, @unchecked Sendable {
@@ -9,7 +8,9 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String
private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, SigningRequestTarget?, LAContext?) async throws -> Data
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: @Sendable () async -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
@@ -18,7 +19,9 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
_name = { secretStore.name }
_id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, target: $3, context: $4) }
_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) }
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { await secretStore.reloadSecrets() }
}
@@ -38,8 +41,16 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
return _secrets()
}
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
try await _sign(data, secret, provenance, target, context)
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) async throws -> Data {
try await _sign(data, secret, provenance)
}
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
await _existingPersistedAuthenticationContext(secret)
}
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
try await _persistAuthentication(secret, duration)
}
public func reloadSecrets() async {
@@ -53,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)) }
@@ -76,7 +87,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
try await _update(secret, name, attributes)
}
public var supportedKeyTypes: KeyAvailability {
public var supportedKeyTypes: [KeyType] {
_supportedKeyTypes()
}
+2 -2
Переглянути файл
@@ -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
}
}
@@ -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
}
}
+110
Переглянути файл
@@ -0,0 +1,110 @@
import Foundation
import OSLog
/// Manages storage and lookup for OpenSSH certificates.
public actor OpenSSHCertificateHandler: Sendable {
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)] = [:]
/// Initializes an OpenSSHCertificateHandler.
public init() {
}
/// Reloads any certificates in the PublicKeys folder.
/// - Parameter secrets: the secrets to look up corresponding certificates for.
public func reloadCertificates(for secrets: [AnySecret]) {
guard publicKeyFileStoreController.hasAnyCertificates else {
logger.log("No certificates, short circuiting")
return
}
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
partialResult[next] = try? loadKeyblobAndName(for: next)
}
}
/// 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.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames[AnySecret(secret)]
}
/// 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.
private func loadKeyblobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret)
guard FileManager.default.fileExists(atPath: certificatePath) else {
return nil
}
logger.debug("Found certificate for \(secret.name)")
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
guard certElements.count >= 2 else {
logger.warning("Certificate found for \(secret.name) but failed to load")
throw OpenSSHCertificateError.parsingFailed
}
guard let certDecoded = Data(base64Encoded: certElements[1] as String) else {
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
throw OpenSSHCertificateError.parsingFailed
}
if certElements.count >= 3 {
let certName = Data(certElements[2].utf8)
return (certDecoded, certName)
}
let certName = Data(secret.name.utf8)
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
}
}
extension OpenSSHCertificateHandler {
enum OpenSSHCertificateError: LocalizedError {
case unsupportedType
case parsingFailed
case doesNotExist
public var errorDescription: String? {
switch self {
case .unsupportedType:
return "The key type was unsupported"
case .parsingFailed:
return "Failed to properly parse the SSH certificate"
case .doesNotExist:
return "Certificate does not exist"
}
}
}
}
@@ -1,6 +1,5 @@
import Foundation
import CryptoKit
import SecretKit
/// Generates OpenSSH representations of the public key sof secrets.
public struct OpenSSHPublicKeyWriter: Sendable {
@@ -19,7 +18,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData
case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData
case .rsa:
@@ -41,14 +40,18 @@ public struct OpenSSHPublicKeyWriter: Sendable {
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
let cleaned = SHA256.hash(data: data(secret: secret)).formatted(.base64(stripPadding: true))
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
return "SHA256:\(cleaned)"
}
/// 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 {
@@ -94,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]
+28
Переглянути файл
@@ -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
}
}
@@ -1,6 +1,5 @@
import Foundation
import CryptoKit
import SecretKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHSignatureWriter: Sendable {
@@ -17,7 +16,7 @@ public struct OpenSSHSignatureWriter: Sendable {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
ecdsaSignature(signature, keyType: secret.keyType)
case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms
mldsaSignature(signature, keyType: secret.keyType)
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
@@ -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)
+72
Переглянути файл
@@ -0,0 +1,72 @@
import Foundation
import OSLog
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: URL
private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: URL) {
directory = homeDirectory.appending(component: "PublicKeys")
}
/// Writes out the keys specified to disk.
/// - Parameter secrets: The Secrets to generate keys for.
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
logger.log("Writing public keys to disk")
if clear {
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)/\($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.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
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 {
do {
return try FileManager.default
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
.isEmpty == false
} catch {
return false
}
}
/// The path for a Secret's SSH Certificate public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the SSH Certificate public key.
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending(component: "\(minimalHex)-cert.pub").path()
}
}
-46
Переглянути файл
@@ -1,46 +0,0 @@
import Foundation
import LocalAuthentication
/// Protocol describing an authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
public protocol AuthenticationContextProtocol: Sendable, Identifiable {
var secret: AnySecret { get }
func valid(for request: SignatureRequest) -> Bool
var laContext: LAContext? { get }
func evaluate(requireBiometrics: Bool) async throws -> Bool
func cancel() async
}
public struct SignatureRequest: Identifiable, Hashable, Sendable, Comparable {
public let id: UUID
public let date: Date
public let secret: AnySecret
public let provenance: SigningRequestProvenance
public let target: SigningRequestTarget?
public init(secret: AnySecret, provenance: SigningRequestProvenance, target: SigningRequestTarget?) {
self.id = UUID()
self.date = Date()
self.secret = secret
self.provenance = provenance
self.target = target
}
public var batchID: Int {
var hasher = Hasher()
guard let target else {
// Requests without target are not permitted to be batched.
id.hash(into: &hasher)
return hasher.finalize()
}
provenance.batchID.hash(into: &hasher)
target.batchID.hash(into: &hasher)
secret.id.hash(into: &hasher)
return hasher.finalize()
}
public static func < (lhs: SignatureRequest, rhs: SignatureRequest) -> Bool {
lhs.date < rhs.date
}
}
+9
Переглянути файл
@@ -0,0 +1,9 @@
import Foundation
/// Protocol describing a persisted authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
public protocol PersistedAuthenticationContext: Sendable {
/// Whether the context remains valid.
var valid: Bool { get }
/// The date at which the authorization expires and the context becomes invalid.
var expiration: Date { get }
}
+15 -30
Переглянути файл
@@ -1,5 +1,4 @@
import Foundation
import LocalAuthentication
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore<SecretType>: Identifiable, Sendable {
@@ -21,7 +20,20 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
/// Checks to see if there is currently a valid persisted authentication for a given secret.
/// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
/// - Returns: A persisted authentication context, if a valid one exists.
func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
/// Persists user authorization for access to a secret.
/// - Parameters:
/// - secret: The ``Secret`` to persist the authorization for.
/// - duration: The duration that the authorization should persist for.
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
/// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() async
@@ -50,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)
+3 -13
Переглянути файл
@@ -2,23 +2,13 @@ import Foundation
import AppKit
/// Describes the chain of applications that requested a signature operation.
public struct SigningRequestProvenance: Hashable, Sendable {
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`
public var chain: [Process]
public var date: Date
public init(root: Process, date: Date = .now) {
public init(root: Process) {
self.chain = [root]
self.date = date
}
public var batchID: Int {
var hasher = Hasher()
chain.map(\.path).hash(into: &hasher)
return hasher.finalize()
}
}
@@ -40,7 +30,7 @@ extension SigningRequestProvenance {
extension SigningRequestProvenance {
/// Describes a process in a `SigningRequestProvenance` chain.
public struct Process: Hashable, Sendable {
public struct Process: Equatable, Sendable {
/// The pid of the process.
public let pid: Int32
-60
Переглянути файл
@@ -1,60 +0,0 @@
import Foundation
import AppKit
/// Describes the target of the signature operation.
public enum SigningRequestTarget: Sendable, Hashable {
case connection(ConnectionPayload)
case signature(SignaturePayload)
public var batchID: Int {
hashValue
}
public struct ConnectionPayload: Sendable, Codable, Hashable {
public let username: String
public let hasSignature: Bool
public let publicKeyAlgorithm: String
public let publicKey: Data
public let hostKey: Data
public let host: String?
public init(
username: String,
hasSignature: Bool,
publicKeyAlgorithm: String,
publicKey: Data,
hostKey: Data,
host: String?
) {
self.username = username
self.hasSignature = hasSignature
self.publicKeyAlgorithm = publicKeyAlgorithm
self.publicKey = publicKey
self.hostKey = hostKey
self.host = host
}
}
public struct SignaturePayload: Sendable, Codable, Hashable {
public let namespace: String
public let hashAlgorithm: String
public let hash: Data
public init(
namespace: String,
hashAlgorithm: String,
hash: Data,
) {
self.namespace = namespace
self.hashAlgorithm = hashAlgorithm
self.hash = hash
}
}
}
+11 -15
Переглянути файл
@@ -3,7 +3,7 @@ import Security
import CryptoTokenKit
import CryptoKit
import SecretKit
import OSLog
import os
extension SecureEnclave {
@@ -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)
try 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).")
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)
}
}
+72
Переглянути файл
@@ -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
}
}
}
+37 -24
Переглянути файл
@@ -2,9 +2,9 @@ import Foundation
import Observation
import Security
import CryptoKit
import LocalAuthentication
@preconcurrency import LocalAuthentication
import SecretKit
import OSLog
import os
extension SecureEnclave {
@@ -17,6 +17,7 @@ extension SecureEnclave {
}
public let id = UUID()
public let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
/// Initializes a Store.
@MainActor public init() {
@@ -36,7 +37,17 @@ extension SecureEnclave {
// MARK: SecretStore
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
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) {
context = existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
@@ -46,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)
}
@@ -77,6 +88,14 @@ extension SecureEnclave {
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
@MainActor public func reloadSecrets() {
let before = secrets
secrets.removeAll()
@@ -102,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
@@ -157,7 +176,6 @@ extension SecureEnclave {
let attributes = try JSONEncoder().encode(attributes)
let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name,
kSecAttrService: Constants.keyTag,
kSecAttrGeneric: attributes,
])
@@ -168,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]
}
}
)
}()
}
}
@@ -201,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 {
@@ -269,7 +282,7 @@ extension SecureEnclave.Store {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = "com.maxgoedjen.secretive.secureenclave.key"
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
static let notificationToken = UUID().uuidString
}
-14
Переглянути файл
@@ -1,14 +0,0 @@
import Foundation
struct RequireDestinationInformationSettingsKey: SettingsStore.SettingsKey {
static let defaultValue: Bool = true
}
extension SettingsStore {
public var requireDestinationInformation: Bool {
get { self[RequireDestinationInformationSettingsKey.self] }
set { self[RequireDestinationInformationSettingsKey.self] = newValue }
}
}
-114
Переглянути файл
@@ -1,114 +0,0 @@
import Foundation
import Observation
import Security
import OSLog
// Setting store backed by macOS keychain for stronger guarantees around ownership/other-process-modification than UserDefaults offers.
@Observable @MainActor public final class SettingsStore: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.settings", category: "SettingsStore")
public init() {
}
private var state: [ObjectIdentifier: UUID] = [:]
subscript<SettingsKeyType: SettingsKey>(_ key: SettingsKeyType.Type) -> SettingsKeyType.Value {
get {
_ = state[ObjectIdentifier(key)]
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
kSecReturnAttributes: true,
kSecMatchLimit: kSecMatchLimitOne,
])
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [CFString: Any] else { return SettingsKeyType.defaultValue }
let decoder = JSONDecoder()
guard let data = typed[kSecValueData] as? Data else { return SettingsKeyType.defaultValue }
return (try? decoder.decode(SettingValue<SettingsKeyType.Value>.self, from: data).value) ?? SettingsKeyType.defaultValue
}
set {
do {
let data = try JSONEncoder().encode(SettingValue(value: newValue))
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: data,
])
let status = SecItemAdd(keychainAttributes, nil)
switch status {
case errSecSuccess:
break
case errSecDuplicateItem:
let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
])
let updatedAttributes = KeychainDictionary([
kSecValueData: data,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
default:
throw KeychainError(statusCode: status)
}
state[ObjectIdentifier(key)] = UUID()
} catch {
logger.error("Error updating key: \(String(describing: SettingsKeyType.self), privacy: .public): \(error.localizedDescription.debugDescription, privacy: .public)")
}
}
}
}
extension SettingsStore {
public protocol SettingsKey<Value>: Equatable, Codable, Sendable {
associatedtype Value: Codable
static var defaultValue: Value { get }
}
}
extension SettingsStore {
struct SettingValue<Value: Codable>: Codable {
let value: Value
}
}
extension SettingsStore {
fileprivate struct KeychainError: Error {
let statusCode: OSStatus?
}
}
fileprivate func KeychainDictionary(_ dictionary: [CFString: Any]) -> CFDictionary {
dictionary as CFDictionary
}
extension SettingsStore {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = "com.maxgoedjen.settingsStore"
}
}
-53
Переглянути файл
@@ -1,53 +0,0 @@
import Foundation
import Security
import CryptoTokenKit
import CryptoKit
import OSLog
import SSHProtocolKit
import CertificateKit
public struct CertificateMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
private let publicKeysDirectory: URL
private let certificatesDirectory: URL
private let certificateStore: CertificateStore
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: URL, certificateStore: CertificateStore) {
publicKeysDirectory = homeDirectory.appending(component: "PublicKeys")
certificatesDirectory = homeDirectory.appending(component: "Certificates")
self.certificateStore = certificateStore
}
@MainActor public func migrate() throws {
try migrate(directory: publicKeysDirectory)
try migrate(directory: certificatesDirectory)
}
@MainActor public func migrate(directory: URL) throws {
let fileCerts = try FileManager.default
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
Task {
for path in fileCerts {
do {
let url = directory.appending(component: path)
let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data)
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to delete successfully migrated cert: \(path)")
}
} catch {
logger.error("Failed to migrate cert: \(path)")
}
}
}
}
}
-29
Переглянути файл
@@ -1,29 +0,0 @@
import Foundation
import OSLog
import SSHProtocolKit
import CertificateKit
import XPCWrappers
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
public final class XPCCertificateParser: OpenSSHCertificateParserProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "XPCCertificateParser")
private let session: XPCTypedSession<OpenSSHCertificate, OpenSSHCertificateError>
public init() async throws {
logger.debug("Creating XPCCertificateParser")
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretiveCertificateParser", warmup: true)
logger.debug("XPCCertificateParser is warmed up.")
}
public func parse(data: Data) async throws -> OpenSSHCertificate {
logger.debug("Parsing input")
defer { logger.debug("Parsed input") }
return try await session.send(data)
}
deinit {
session.complete()
}
}
+20 -13
Переглянути файл
@@ -1,7 +1,7 @@
import Foundation
import Observation
import Security
@unsafe @preconcurrency import CryptoTokenKit
@preconcurrency import CryptoTokenKit
import LocalAuthentication
import SecretKit
@@ -56,21 +56,21 @@ extension SmartCard {
// MARK: Public API
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() }
var rawAttributes: [CFString: Any] = [
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,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
]
if let context {
rawAttributes[kSecUseAuthenticationContext] = context
}
let attributes = KeychainDictionary(rawAttributes)
])
var untyped: CFTypeRef?
let status = unsafe SecItemCopyMatching(attributes, &untyped)
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
@@ -80,12 +80,19 @@ 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) -> PersistedAuthenticationContext? {
nil
}
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
}
/// Reloads all secrets from the store.
@MainActor public func reloadSecrets() {
reloadSecretsInternal()
@@ -145,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)
@@ -156,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
-14
Переглянути файл
@@ -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
}
-74
Переглянути файл
@@ -1,74 +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
#if !DEBUG
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
#endif
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)
}
}
-55
Переглянути файл
@@ -1,55 +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)
#if !DEBUG
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
#endif
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 {}
}
-83
Переглянути файл
@@ -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)
}
}
-11
Переглянути файл
@@ -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
}
+91 -61
Переглянути файл
@@ -1,31 +1,26 @@
import Foundation
import Testing
import CryptoKit
import CertificateKit
@testable import SSHProtocolKit
@testable import SecretKit
@testable import SecretAgentKit
@Suite @MainActor struct AgentTests {
@Suite struct AgentTests {
// 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(), certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let agent = Agent(storeList: SecretStoreList())
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, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let actual = OpenSSHReader(data: response)
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
print(actual, expected)
let agent = Agent(storeList: list)
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesMultiple)
}
@@ -33,43 +28,41 @@ import CertificateKit
@Test func noMatchingIdentities() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let agent = Agent(storeList: list)
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, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let responseReader = OpenSSHReader(data: response)
let length = try responseReader.readNextBytes(as: UInt32.self)
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.raw))
}
// @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,8 +71,8 @@ import CertificateKit
let witness = StubWitness(speakNow: { _,_ in
return true
}, witness: { _, _ in })
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
let response = await agent.handle(request: .signRequest(.empty), provenance: .test, hosts: nil)
let agent = Agent(storeList: list, witness: witness)
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
@@ -91,9 +84,8 @@ import CertificateKit
}, witness: { _, trace in
witnessed = true
})
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
let agent = Agent(storeList: list, witness: witness)
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(witnessed)
}
@@ -107,9 +99,8 @@ import CertificateKit
}, witness: { _, trace in
witnessTrace = trace
})
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
let agent = Agent(storeList: list, witness: witness)
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(witnessTrace == speakNowTrace)
#expect(witnessTrace == .test)
}
@@ -118,20 +109,59 @@ import CertificateKit
@Test func signatureException() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = list.stores.first?.base as! Stub.Store
let store = await list.stores.first?.base as! Stub.Store
store.shouldThrow = true
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let agent = Agent(storeList: list)
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
// MARK: Unsupported
@Test func unhandledAdd() async throws {
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
let response = await agent.handle(request: .addIdentity, provenance: .test, hosts: nil)
let agent = Agent(storeList: SecretStoreList())
let response = try await agent.handle(data: Constants.Requests.addIdentity, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
}
extension SigningRequestProvenance {
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
}
extension AgentTests {
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
let store = Stub.Store()
store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList()
storeList.add(store: store)
return storeList
}
enum Constants {
enum Requests {
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
static let 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: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")!
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
}
enum Secrets {
static let ecdsa256Secret = Stub.Secret(keySize: 256, publicKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08=")!, privateKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy09nw780wy/TSfUmzj15iJkV234AaCLNl+H8qFL6qK8VIg==")!)
static let ecdsa384Secret = Stub.Secret(keySize: 384, publicKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDA==")!, privateKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDHNapAOzrt9E+9QC4/KYoXS7Uw4pmdAz53uIj02tttiq3c0ZyIQ7XoscWWRqRrz8Kw==")!)
}
}
}
-169
Переглянути файл
@@ -1,169 +0,0 @@
import Testing
import Foundation
import LocalAuthentication
import SecretKit
import SecretAgentKit
import SSHProtocolKit
public struct TestSecret: Secret {
public let id = UUID()
public let name = "Test"
public let publicKey = Data()
public var attributes: Attributes
}
@MainActor final class TestContext: @preconcurrency AuthenticationContextProtocol, Sendable {
let id = UUID()
private var testEvaluationResult: Bool
private(set) var canceled: Bool
private(set) var evaluationResult: Bool? = nil
let approval: ApprovalMode
let secret: AnySecret
let laContext: LAContext? = nil
enum ApprovalMode {
case manual
case duration(Duration)
}
init(authenticationRequirement: AuthenticationRequirement, testEvaluationResult: Bool, approval: ApprovalMode = .manual) {
self.canceled = false
self.testEvaluationResult = testEvaluationResult
self.secret = AnySecret(TestSecret(attributes: .init(keyType: .ecdsa256, authentication: authenticationRequirement)))
self.approval = approval
}
nonisolated func valid(for request: SecretKit.SignatureRequest) -> Bool {
true
}
func evaluate(requireBiometrics: Bool) async throws -> Bool {
if case let .duration(duration) = approval {
try await Task.sleep(for: duration)
} else {
while true {
try await Task.sleep(for: .milliseconds(10))
guard !canceled else { return false }
}
}
guard !canceled else { return false }
evaluationResult = testEvaluationResult
return testEvaluationResult
}
func cancel() async {
assert(!canceled)
assert(evaluationResult == nil)
canceled = true
}
}
@MainActor @Suite struct AuthenticationHandlerTests {
let handler = AuthenticationHandler()
@Test func singleImmediatelyRequests() async throws {
var calledPresentPending = false
handler.setPendingRequestHandler {
calledPresentPending = true
}
let context = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.zero))
_ = try await handler.authenticatedContext(for: .init(secret: .init(context.secret), provenance: .test, target: nil), context: context)
#expect(context.evaluationResult == true)
#expect(!calledPresentPending)
}
@Test func sequentialRequestsDoNotBlock() async throws {
var calledPresentPending = false
handler.setPendingRequestHandler {
calledPresentPending = true
}
let context = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.milliseconds(1)))
_ = try await handler.authenticatedContext(for: .init(secret: .init(context.secret), provenance: .test, target: nil), context: context)
#expect(context.evaluationResult == true)
let second = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.milliseconds(1)))
_ = try await handler.authenticatedContext(for: .init(secret: .init(second.secret), provenance: .test, target: nil), context: second)
#expect(context.evaluationResult == true)
#expect(second.evaluationResult == true)
#expect(!calledPresentPending)
}
@Test func secondRetractsAndPresentsBatch() async throws {
var calledPresentPending = false
handler.setPendingRequestHandler {
calledPresentPending = true
}
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
let contextB = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
Task {
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
}
Task {
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
}
#expect(contextA.evaluationResult == nil)
#expect(contextB.evaluationResult == nil)
await Task.yield()
#expect(contextA.canceled)
#expect(calledPresentPending)
}
@Test func authRequiredDoesNotBlockNoAuthRequired() async throws {
var calledPresentPending = false
handler.setPendingRequestHandler {
calledPresentPending = true
}
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .manual)
let contextB = TestContext(authenticationRequirement: .notRequired, testEvaluationResult: true)
var unauthenticatedResult = false
Task {
Task {
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
unauthenticatedResult = true
}
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
}
try await Task.sleep(for: .milliseconds(10))
#expect(contextA.evaluationResult == nil)
#expect(unauthenticatedResult)
#expect(!contextA.canceled)
#expect(!contextB.canceled)
#expect(!calledPresentPending)
}
@Test func batchDoesNotBlockNoAuthRequired() async throws {
var calledPresentPending = false
handler.setPendingRequestHandler {
calledPresentPending = true
}
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
let contextB = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
let contextC = TestContext(authenticationRequirement: .notRequired, testEvaluationResult: true)
Task {
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
}
Task {
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
}
#expect(contextA.evaluationResult == nil)
#expect(contextB.evaluationResult == nil)
await Task.yield()
#expect(contextA.canceled)
#expect(calledPresentPending)
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextC.secret), provenance: .test, target: nil), context: contextA)
#expect(contextA.evaluationResult == nil)
#expect(contextB.evaluationResult == nil)
}
@Test func batching() async throws {
}
}
-41
Переглянути файл
@@ -1,41 +0,0 @@
import SecretKit
import Foundation
extension SigningRequestProvenance {
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
}
extension AgentTests {
func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
let store = Stub.Store()
store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList()
storeList.add(store: store)
return storeList
}
enum Constants {
enum Requests {
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
}
enum Responses {
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
}
enum Secrets {
static let ecdsa256Secret = Stub.Secret(keySize: 256, publicKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08=")!, privateKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy09nw780wy/TSfUmzj15iJkV234AaCLNl+H8qFL6qK8VIg==")!)
static let ecdsa384Secret = Stub.Secret(keySize: 384, publicKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDA==")!, privateKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDHNapAOzrt9E+9QC4/KYoXS7Uw4pmdAz53uIj02tttiq3c0ZyIQ7XoscWWRqRrz8Kw==")!)
}
}
}
+9 -4
Переглянути файл
@@ -1,8 +1,6 @@
import Foundation
import SecretKit
import CryptoKit
import SSHProtocolKit
import LocalAuthentication
struct Stub {}
@@ -50,7 +48,7 @@ extension Stub {
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) throws -> Data {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
@@ -58,6 +56,13 @@ extension Stub {
return try privateKey.signature(for: data).rawRepresentation
}
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
nil
}
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
}
public func reloadSecrets() {
}
@@ -77,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
}
+2 -2
Переглянути файл
@@ -10,14 +10,14 @@ struct StubWitness {
extension StubWitness: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
let objection = speakNow(secret, provenance)
if objection {
throw TheresMyChance()
}
}
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
witness(secret, provenance)
}
@@ -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"))
}
@@ -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)
}
-119
Переглянути файл
@@ -1,119 +0,0 @@
import Cocoa
import OSLog
import SecretKit
import SecureEnclaveSecretKit
import SmartCardSecretKit
import SecretAgentKit
import Brief
import Observation
import Common
import SwiftUI
import CertificateKit
@main
struct SecretAgent: App {
@MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList()
let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store())
return list
}()
@MainActor private let certificateStore: CertificateStore = CertificateStore()
private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier()
private let authenticationHandler = AuthenticationHandler()
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
@Environment(\.openWindow) var openWindow
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "App")
@SceneBuilder var body: some Scene {
MenuBarExtra(isInserted: .constant(false)) {
EmptyView()
} label: {
Image(systemName: "lock")
.task {
await notifier.registerPersistenceHandler {
try await authenticationHandler.persistAuthentication(secret: $0, forDuration: $1)
}
}
.task {
let socketController = SocketController(path: URL.socketPath)
let agent = Agent(
storeList: storeList,
certificateStore: certificateStore,
authenticationHandler: authenticationHandler,
witness: notifier
)
for await session in socketController.sessions {
Task {
do {
let inputParser = try await XPCAgentInputParser()
let hosts = try? await XPCHostsfileReader().read()
for await message in session.messages {
let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance, hosts: hosts)
try session.write(agentResponse)
}
} catch {
try? session.close()
}
}
}
}
.task {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
}
}
.task {
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: certificateStore)
try? certsMigrator.migrate()
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
}
}
.task {
authenticationHandler.setPendingRequestHandler { @MainActor in
openWindow(value: PendingRequestsViewIdentifier())
}
}
.task {
notifier.prompt()
_ = withObservationTracking {
updater.update
} onChange: { [updater, notifier] in
Task {
guard !updater.currentVersion.isTestBuild else { return }
await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release)
}
}
}
}
}
WindowGroup(for: PendingRequestsViewIdentifier.self) { _ in
pendingView
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
@ViewBuilder
var pendingView: some View {
if !authenticationHandler.batchableRequests.isEmpty {
PendingRequestsView(authenticationHandler: authenticationHandler)
}
}
}
struct PendingRequestsViewIdentifier: Codable, Hashable {}
+8 -34
Переглянути файл
@@ -6,21 +6,7 @@ import SmartCardSecretKit
import SecretAgentKit
import Brief
import Observation
import SSHProtocolKit
import CertificateKit
import Common
import SwiftUI
extension EnvironmentValues {
@MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore()
@MainActor var certificateStore: CertificateStore {
EnvironmentValues._certificateStore
}
}
@main
class AppDelegate: NSObject, NSApplicationDelegate {
@@ -31,18 +17,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store())
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: EnvironmentValues._certificateStore)
try? certsMigrator.migrate()
return list
}()
private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
@MainActor private lazy var agent: Agent = {
Agent(storeList: storeList, certificateStore: EnvironmentValues._certificateStore, witness: 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 = URL.socketPath as String
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")
@@ -53,16 +37,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
for await session in socketController.sessions {
Task {
do {
let inputParser = try await XPCAgentInputParser()
let hostsReader = try? await XPCHostsfileReader()
let hosts = try? await hostsReader?.read()
for await message in session.messages {
let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance, hosts: hosts)
try session.write(agentResponse)
let agentResponse = try await agent.handle(data: message, provenance: session.provenance)
try await session.write(agentResponse)
}
} catch {
try? session.close()
try session.close()
}
}
}
@@ -72,19 +52,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
}
}
Task {
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
}
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
notifier.prompt()
_ = withObservationTracking {
updater.update
} onChange: { [updater, notifier] in
Task {
guard !updater.currentVersion.isTestBuild else { return }
guard !updater.testBuild else { return }
await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release)
}
-47
Переглянути файл
@@ -1,47 +0,0 @@
import Foundation
import Security
import CryptoTokenKit
import CryptoKit
import OSLog
import SSHProtocolKit
import CertificateKit
import SharedXPCServices
public struct CertificateMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
private let directory: URL
private let certificateStore: CertificateStore
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: URL, certificateStore: CertificateStore) {
directory = homeDirectory.appending(component: "PublicKeys")
self.certificateStore = certificateStore
}
@MainActor public func migrate() throws {
let fileCerts = try FileManager.default
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
Task {
for path in fileCerts {
do {
let url = directory.appending(component: path)
let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data)
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to delete successfully migrated cert: \(path)")
}
} catch {
logger.error("Failed to migrate cert: \(path)")
}
}
}
}
}
+16 -1
Переглянути файл
@@ -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>
+26 -34
Переглянути файл
@@ -5,8 +5,6 @@ import SecretKit
import SecretAgentKit
import Brief
typealias PersistAction = (@Sendable (AnySecret, TimeInterval) async throws -> Void)
final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate()
@@ -17,12 +15,6 @@ final class Notifier: Sendable {
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate
}
func registerPersistenceHandler(action: @escaping PersistAction) async {
let rawDurations = [
Measurement(value: 1, unit: UnitDuration.minutes),
Measurement(value: 5, unit: UnitDuration.minutes),
@@ -32,9 +24,11 @@ final class Notifier: Sendable {
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
var allPersistenceActions = [doNotPersistAction]
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
var identifiers: [String: TimeInterval] = [:]
for duration in rawDurations {
let seconds = duration.converted(to: .seconds).value
@@ -49,11 +43,16 @@ final class Notifier: Sendable {
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
}
var categories = await UNUserNotificationCenter.current().notificationCategories()
categories.insert(persistAuthenticationCategory)
UNUserNotificationCenter.current().setNotificationCategories(categories)
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate
Task {
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
guard let duration = duration else { return }
try? await store.persistAuthentication(secret: secret, forDuration: duration)
}
}
await notificationDelegate.state.setPersistenceState(options: identifiers, action: action)
}
func prompt() {
@@ -61,23 +60,16 @@ final class Notifier: Sendable {
notificationCenter.requestAuthorization(options: .alert) { _, _ in }
}
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async {
func notify(accessTo 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 = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
switch target {
case .connection(let payload) where payload.host != nil:
notificationContent.subtitle = String(localized: .signedConnectionNotificationDescription(secretName: secret.name, username: payload.username, host: payload.host!))
case .signature(let payload):
notificationContent.subtitle = String(localized: .signedSignatureNotificationDescription(secretName: secret.name, namespace: payload.namespace))
default:
notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name))
}
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive
if offerPersistence {
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) {
@@ -108,16 +100,11 @@ final class Notifier: Sendable {
extension Notifier: SigningWitness {
func speakNowOrForeverHoldYourPeace(
forAccessTo secret: AnySecret,
from store: AnySecretStore,
by provenance: SigningRequestProvenance,
target: SigningRequestTarget?
) async throws {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
}
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
await notify(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: offerPersistence)
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
await notify(accessTo: secret, from: store, by: provenance)
}
}
@@ -146,24 +133,28 @@ extension Notifier {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
fileprivate actor State {
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias IgnoreAction = (@Sendable (Release) async -> Void)
fileprivate var release: Release?
fileprivate var ignoreAction: IgnoreAction?
fileprivate var persistAction: PersistAction?
fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
func setPending(secret: AnySecret, store: AnySecretStore) {
pendingPersistableSecrets[secret.id.description] = secret
pendingPersistableStores[store.id.description] = store
}
func retrievePending(secretID: String, optionID: String) -> (AnySecret, TimeInterval)? {
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
guard let secret = pendingPersistableSecrets[secretID],
let store = pendingPersistableStores[storeID],
let options = persistOptions[optionID] else {
return nil
}
pendingPersistableSecrets.removeValue(forKey: secretID)
return (secret, options)
return (secret, store, options)
}
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
@@ -211,12 +202,13 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
}
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String else {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String,
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
return
}
let optionID = response.actionIdentifier
guard let (secret, persistOptions) = await state.retrievePending(secretID: secretID, optionID: optionID) else { return }
try? await state.persistAction?(secret, persistOptions)
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
await state.persistAction?(secret, store, persistOptions)
}
-202
Переглянути файл
@@ -1,202 +0,0 @@
import SwiftUI
import SecretKit
import SecretAgentKit
import SmartCardSecretKit
import Common
struct PendingRequestsView: View {
private let authenticationHandler: any AuthenticationHandlerProtocol
@Environment(\.dismissWindow) var dismiss
init(authenticationHandler: some AuthenticationHandlerProtocol) {
self.authenticationHandler = authenticationHandler
}
var body: some View {
ScrollView {
Text(.pendingRequestDescription)
ForEach(Array(authenticationHandler.batchableRequests.enumerated()), id: \.offset) { group in
MultilineInfoView {
if let first = group.element.first {
HStack {
HStack {
Image(nsImage: .init(byReferencing: first.provenance.origin.iconURL!))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50)
VStack(alignment: .leading) {
Text(first.provenance.origin.displayName)
.font(.subheadline)
Text(first.secret.name)
.font(.headline)
switch first.target {
case .connection(let payload):
if let host = payload.host {
Text(.authContextConnectingToUsernameAndHost(username: payload.username, host: host))
.font(.caption2)
} else {
Text(.authContextConnectingToUnknownHost)
.font(.caption2)
}
case .signature(let payload):
Text(.authContextSigningForNamespace(namespace: payload.namespace))
.font(.caption2)
default:
EmptyView()
}
}
}
Spacer()
VStack {
Button(.pendingRequestsReviewBatchButton) {
Task {
try? await authenticationHandler.requestAuthentication(for: Set(group.element))
if authenticationHandler.batchableRequests.isEmpty {
dismiss()
}
}
}
.buttonBorderShape(.capsule)
.primaryButton()
}
}
}
} items: {
ForEach(Array(group.element.enumerated()), id: \.offset) { pending in
HStack {
Text(pending.element.provenance.date.formatted())
Spacer()
Button(.pendingRequestsReviewSingleButton) {
Task {
try? await authenticationHandler.requestAuthentication(for: [pending.element])
if authenticationHandler.batchableRequests.isEmpty {
dismiss()
}
}
}
.buttonBorderShape(.capsule)
.normalButton()
}
}
}
}
}
.safeAreaPadding(20)
}
}
private struct TestHandler: AuthenticationHandlerProtocol {
var batchableRequests: [[SignatureRequest]] = []
func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
}
func persistAuthentication<SecretType>(secret: SecretType, forDuration duration: TimeInterval) async throws where SecretType : Secret {
}
func setPendingRequestHandler(_ handler: @escaping () async throws -> Void) {
}
func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)? {
nil
}
}
#Preview {
if #available(macOS 26.0, *) {
ScrollView {
MultilineInfoView {
HStack {
HStack {
Image("ghostty")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50)
VStack(alignment: .leading) {
Text("Ghostty")
.font(.subheadline)
Text("GitHub")
.font(.headline)
Text("Authenticating git@github.com")
.font(.caption2)
}
}
Spacer()
VStack {
Button("Review as Batch") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glassProminent)
}
}
} items: {
ForEach(0..<2) { _ in
HStack {
Text("4:05 PM")
Spacer()
Button("Review") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glass)
}
}
}
MultilineInfoView {
HStack {
HStack {
Image("ghostty")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50)
VStack(alignment: .leading) {
Text("Ghostty")
.font(.subheadline)
Text("Git Signing")
.font(.headline)
Text("Git Signature")
.font(.caption2)
}
}
Spacer()
VStack {
Button("Review as Batch") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glassProminent)
}
}
} items: {
ForEach(0..<2) { _ in
HStack {
Text("4:05 PM")
Spacer()
Button("Review") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glass)
}
}
}
}
.padding()
.formStyle(.grouped)
.frame(minHeight: 700)
}
}
-16
Переглянути файл
@@ -2,22 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>2</string>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
<key>com.apple.security.smartcard</key>
<true/>
<key>keychain-access-groups</key>
-36
Переглянути файл
@@ -1,36 +0,0 @@
import Foundation
import OSLog
import XPCWrappers
import OSLog
public final class XPCHostsfileReader {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "XPCHostsfileReader")
private let session: XPCTypedSession<[Data: String], HostsfileReaderError>
public init() async throws {
logger.debug("Creating XPCHostsfileReader")
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentHostsfileReader", warmup: true)
logger.debug("XPCHostsfileReader is warmed up.")
}
public func read() async throws -> [Data: String] {
logger.debug("Reading hosts file")
defer { logger.debug("Read hosts file") }
return try await session.send(Data())
}
deinit {
session.complete()
}
}
extension XPCHostsfileReader {
public enum HostsfileReaderError: Error, Codable {
case fileDoesNotExist
case parseError
}
}
-31
Переглянути файл
@@ -1,31 +0,0 @@
import Foundation
import OSLog
import SSHProtocolKit
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()
}
}
-11
Переглянути файл
@@ -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>
-22
Переглянути файл
@@ -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.enhanced-security-version-string</key>
<string>2</string>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
</dict>
</plist>

Деякі з файлів не показано, оскільки було змінено дуже багато файлів Показати більше