Merge branch 'main' into patch-2

This commit is contained in:
Max Goedjen 2025-09-09 23:50:49 -07:00 committed by GitHub
commit a83fec21b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
160 changed files with 6753 additions and 4846 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

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

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

View File

@ -1,16 +0,0 @@
name: Add bugs to bugs project
on:
issues:
types:
- opened
jobs:
add-to-project:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v1.0.1
with:
project-url: https://github.com/users/maxgoedjen/projects/1
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}

47
.github/workflows/codeql.yml vendored Normal file
View File

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

View File

@ -3,13 +3,18 @@ name: Nightly
on: on:
schedule: schedule:
- cron: "0 8 * * *" - cron: "0 8 * * *"
workflow_dispatch:
jobs: jobs:
build: build:
# runs-on: macOS-latest
runs-on: macos-15 runs-on: macos-15
permissions:
id-token: write
contents: write
attestations: write
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup Signing - name: Setup Signing
env: env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }} SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@ -20,35 +25,34 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_16.4.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Update Build Number - name: Update Build Number
env: env:
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}
run: | run: |
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig 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_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build - name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs - name: Create ZIP
run: | run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize - name: Notarize
env: env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Document SHAs
run: |
echo "sha-512:"
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
echo "sha-256:"
shasum -a 256 Secretive.zip
shasum -a 256 Archive.zip
- name: Upload App to Artifacts - name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: Secretive.zip name: Secretive.zip
path: Secretive.zip path: Secretive.zip
- name: Attest
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}

View File

@ -6,11 +6,12 @@ on:
- '*' - '*'
jobs: jobs:
test: test:
# runs-on: macOS-latest permissions:
contents: read
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup Signing - name: Setup Signing
env: env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }} SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@ -21,18 +22,20 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_16.4.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Test - name: Test
run: | run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
pushd Sources/Packages # SPM doesn't seem to pick up on the tests currently?
swift test # run: swift test --build-system swiftbuild --package-path Sources/Packages
popd
build: build:
# runs-on: macOS-latest permissions:
id-token: write
contents: write
attestations: write
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup Signing - name: Setup Signing
env: env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }} SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@ -43,7 +46,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_16.4.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Update Build Number - name: Update Build Number
env: env:
TAG_NAME: ${{ github.ref }} TAG_NAME: ${{ github.ref }}
@ -55,64 +58,34 @@ jobs:
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build - name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs - name: Create ZIP
run: | run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize - name: Notarize
env: env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Document SHAs
run: |
echo "sha-512:"
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
echo "sha-256:"
shasum -a 256 Secretive.zip
shasum -a 256 Archive.zip
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: |
Update description
## Features
## Fixes
## Minimum macOS Version
## Build
https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }}
draft: true
prerelease: false
- name: Upload App to Release
id: upload-release-asset-app
uses: actions/upload-release-asset@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./Secretive.zip
asset_name: Secretive.zip
asset_content_type: application/zip
- name: Upload App to Artifacts - name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: Secretive.zip name: Secretive.zip
path: Secretive.zip path: Secretive.zip
- name: Upload Archive to Artifacts - name: Attest
uses: actions/upload-artifact@v4 id: attest
uses: actions/attest-build-provenance@v2
with: with:
name: Xcode_Archive.zip subject-name: "Secretive.zip"
path: Archive.zip subject-digest: ${{ steps.upload.outputs.artifact-digest }}
- 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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref }}
RUN_ID: ${{ github.run_id }}
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}

View File

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

View File

@ -1,116 +1,3 @@
# Setting up Third Party Apps FAQ # App Configuration
## Tower Instructions for setting up apps and shells has moved to [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions)!
Tower provides [instructions](https://www.git-tower.com/help/mac/integration/environment).
## GitHub Desktop
Should just work, no configuration needed
## Fork
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
## VS Code
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
## nushell
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
## Cyberduck
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?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>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Cyberduck.
## Mountain Duck
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?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>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Mountain Duck.
## GitKraken
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?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>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
# The app I use isn't listed here!
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
If you're not able to get it working, please file a [GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) for it. No guarantees we'll be able to get it working, but chances are someone else in the community might be able to.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

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

2
FAQ.md
View File

@ -6,7 +6,7 @@ The secure enclave doesn't allow import or export of private keys. For any new c
### Secretive doesn't work with my git client/app ### Secretive doesn't work with my git client/app
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [App Config FAQ](APP_CONFIG.md). Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions) repo.
### Secretive isn't working for me ### Secretive isn't working for me

View File

@ -2,36 +2,35 @@
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help! If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
## Getting Started ## Crowdin
### Download Xcode [Secretive uses Crowdin for localization](https://crowdin.com/project/secretive/). Open the link and select your language to translate!
Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/). ### Manual Translation
### Clone Secretive Crowdin is the easiest way to translate Secretive, but I'm happy to accept Pull Requests directly as well.
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 ### 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! 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).

69
Package.swift Normal file
View File

@ -0,0 +1,69 @@
// swift-tools-version:6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
// This is basically the same package as `Sources/Packages/Package.swift`, but thinned slightly.
// Ideally this would be the same package, but SPM requires it to be at the root of the project,
// and Xcode does _not_ like that, so they're separate.
let package = Package(
name: "SecretKit",
defaultLocalization: "en",
platforms: [
.macOS(.v14)
],
products: [
.library(
name: "SecretKit",
targets: ["SecretKit"]),
.library(
name: "SecureEnclaveSecretKit",
targets: ["SecureEnclaveSecretKit"]),
.library(
name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]),
],
dependencies: [
],
targets: [
.target(
name: "SecretKit",
dependencies: [],
path: "Sources/Packages/Sources/SecretKit",
resources: [localization],
swiftSettings: swiftSettings
),
.testTarget(
name: "SecretKitTests",
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
path: "Sources/Packages/Tests/SecretKitTests",
swiftSettings: swiftSettings
),
.target(
name: "SecureEnclaveSecretKit",
dependencies: ["SecretKit"],
path: "Sources/Packages/Sources/SecureEnclaveSecretKit",
resources: [localization],
swiftSettings: swiftSettings
),
.target(
name: "SmartCardSecretKit",
dependencies: ["SecretKit"],
path: "Sources/Packages/Sources/SmartCardSecretKit",
resources: [localization],
swiftSettings: swiftSettings
),
]
)
var localization: Resource {
.process("../../Resources/Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
// This freaks out Xcode in a dependency context.
// .treatAllWarnings(as: .error),
]
}

View File

@ -1,4 +1,4 @@
# Secretive ![Test](https://github.com/maxgoedjen/secretive/workflows/Test/badge.svg) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg) # 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 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. 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.
@ -49,7 +49,7 @@ There's a [FAQ here](FAQ.md).
### Auditable Build Process ### Auditable Build Process
Builds are produced by GitHub Actions with an auditable build and release generation process. Each build has a "Document SHAs" step, which will output SHA checksums for the build produced by the GitHub Action, so you can verify that the source code for a given build corresponds to any given release. Builds are produced by GitHub Actions with an auditable build and release generation process. Starting with Secretive 3.0, builds are attested using [GitHub Artifact Attestation](https://docs.github.com/en/actions/concepts/security/artifact-attestations). Attestations are viewable in the build log for a build, and also on the [main attestation page](https://github.com/maxgoedjen/secretive/attestations).
### A Note Around Code Signing and Keychains ### A Note Around Code Signing and Keychains
@ -61,4 +61,4 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
## Security ## Security
If you discover any vulnerabilities in this project, please notify [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with the subject containing "SECRETIVE 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)

View File

@ -1,9 +1,27 @@
# Security Policy # Security Policy
## Security Principles
Secretive is designed with a few general tenets in mind:
### It's Hard to Leak a Key Secretive Can't Read The Key Material
Secretive only operates on hardware-backed keys. In general terms, this means that it should be _very_ hard for Secretive to have any sort of bug that causes a key to be shared, because Secretive can't access private key data even if it wants to.
### Simplicity and Auditability
Secretive won't expand to have every feature it could possibly have. Part of the goal of the app is that it is possible for consumers to reasonably audit the code, and that often means not implementing features that might be cool, but which would significantly inflate the size of the codebase.
### Dependencies
Both in support of the previous principle and to rule out supply chain attacks, Secretive does not rely on any third party dependencies.
There are limited exceptions to this, particularly in the build process, but the app itself does not depend on any third party code.
## Supported Versions ## Supported Versions
The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version. The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version.
## Reporting a Vulnerability ## Reporting a Vulnerability
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY." 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)

View File

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

View File

@ -1,12 +1,13 @@
// swift-tools-version:5.9 // swift-tools-version:6.2
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "SecretivePackages", name: "SecretivePackages",
defaultLocalization: "en",
platforms: [ platforms: [
.macOS(.v12) .macOS(.v14)
], ],
products: [ products: [
.library( .library(
@ -20,13 +21,13 @@ let package = Package(
targets: ["SmartCardSecretKit"]), targets: ["SmartCardSecretKit"]),
.library( .library(
name: "SecretAgentKit", name: "SecretAgentKit",
targets: ["SecretAgentKit"]), targets: ["SecretAgentKit", "XPCWrappers"]),
.library(
name: "SecretAgentKitHeaders",
targets: ["SecretAgentKitHeaders"]),
.library( .library(
name: "Brief", name: "Brief",
targets: ["Brief"]), targets: ["Brief"]),
.library(
name: "XPCWrappers",
targets: ["XPCWrappers"]),
], ],
dependencies: [ dependencies: [
], ],
@ -34,42 +35,61 @@ let package = Package(
.target( .target(
name: "SecretKit", name: "SecretKit",
dependencies: [], dependencies: [],
swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] resources: [localization],
swiftSettings: swiftSettings,
), ),
.testTarget( .testTarget(
name: "SecretKitTests", name: "SecretKitTests",
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"], dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] swiftSettings: swiftSettings,
), ),
.target( .target(
name: "SecureEnclaveSecretKit", name: "SecureEnclaveSecretKit",
dependencies: ["SecretKit"], dependencies: ["SecretKit"],
swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] resources: [localization],
swiftSettings: swiftSettings,
), ),
.target( .target(
name: "SmartCardSecretKit", name: "SmartCardSecretKit",
dependencies: ["SecretKit"], dependencies: ["SecretKit"],
swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] resources: [localization],
swiftSettings: swiftSettings,
), ),
.target( .target(
name: "SecretAgentKit", name: "SecretAgentKit",
dependencies: ["SecretKit", "SecretAgentKitHeaders"], dependencies: ["SecretKit"],
swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] resources: [localization],
), swiftSettings: swiftSettings,
.systemLibrary(
name: "SecretAgentKitHeaders"
), ),
.testTarget( .testTarget(
name: "SecretAgentKitTests", name: "SecretAgentKitTests",
dependencies: ["SecretAgentKit"]) dependencies: ["SecretAgentKit"],
, ),
.target( .target(
name: "Brief", name: "Brief",
dependencies: [] dependencies: ["XPCWrappers"],
resources: [localization],
swiftSettings: swiftSettings,
), ),
.testTarget( .testTarget(
name: "BriefTests", name: "BriefTests",
dependencies: ["Brief"] dependencies: ["Brief"],
),
.target(
name: "XPCWrappers",
swiftSettings: swiftSettings,
), ),
] ]
) )
var localization: Resource {
.process("../../Resources/Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error),
.strictMemorySafety()
]
}

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
/// A release is a representation of a downloadable update. /// A release is a representation of a downloadable update.
public struct Release: Codable { public struct Release: Codable, Sendable {
/// The user-facing name of the release. Typically "Secretive 1.2.3" /// The user-facing name of the release. Typically "Secretive 1.2.3"
public let name: String public let name: String

View File

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

View File

@ -1,16 +1,24 @@
import Foundation import Foundation
import Combine import Observation
import XPCWrappers
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version. /// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
public final class Updater: ObservableObject, UpdaterProtocol { @Observable public final class Updater: UpdaterProtocol, Sendable {
@Published public var update: Release? private let state = State()
public let testBuild: Bool @MainActor @Observable public final class State {
var update: Release? = nil
nonisolated init() {}
}
public var update: Release? {
state.update
}
/// The current version of the app that is running.
public let currentVersion: SemVer
/// The current OS version. /// The current OS version.
private let osVersion: SemVer private let osVersion: SemVer
/// The current version of the app that is running.
private let currentVersion: SemVer
/// Initializes an Updater. /// Initializes an Updater.
/// - Parameters: /// - Parameters:
@ -18,36 +26,40 @@ public final class Updater: ObservableObject, UpdaterProtocol {
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour. /// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
/// - osVersion: The current OS version. /// - osVersion: The current OS version.
/// - currentVersion: The current version of the app that is running. /// - currentVersion: The current version of the app that is running.
public init(checkOnLaunch: Bool, checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) { public init(
checkOnLaunch: Bool,
checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value,
osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion),
currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")
) {
self.osVersion = osVersion self.osVersion = osVersion
self.currentVersion = currentVersion self.currentVersion = currentVersion
testBuild = currentVersion == SemVer("0.0.0") Task {
if checkOnLaunch { if checkOnLaunch {
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet. try await checkForUpdates()
checkForUpdates() }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
try await checkForUpdates()
} }
let timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in
self.checkForUpdates()
} }
timer.tolerance = 60*60
} }
/// Manually trigger an update check. /// Manually trigger an update check.
public func checkForUpdates() { public func checkForUpdates() async throws {
URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in let session = try await XPCTypedSession<[Release], Never>(serviceName: "com.maxgoedjen.Secretive.SecretiveUpdater")
guard let data = data else { return } await evaluate(releases: try await session.send())
guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return } session.complete()
self.evaluate(releases: releases)
}.resume()
} }
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release. /// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
/// - Parameter release: The release to ignore. /// - Parameter release: The release to ignore.
public func ignore(release: Release) { public func ignore(release: Release) async {
guard !release.critical else { return } guard !release.critical else { return }
defaults.set(true, forKey: release.name) defaults.set(true, forKey: release.name)
DispatchQueue.main.async { await MainActor.run {
self.update = nil state.update = nil
} }
} }
@ -57,7 +69,7 @@ extension Updater {
/// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run. /// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run.
/// - Parameter releases: An array of ``Release`` objects. /// - Parameter releases: An array of ``Release`` objects.
func evaluate(releases: [Release]) { func evaluate(releases: [Release]) async {
guard let release = releases guard let release = releases
.sorted() .sorted()
.reversed() .reversed()
@ -67,8 +79,8 @@ extension Updater {
guard !release.prerelease else { return } guard !release.prerelease else { return }
let latestVersion = SemVer(release.name) let latestVersion = SemVer(release.name)
if latestVersion > currentVersion { if latestVersion > currentVersion {
DispatchQueue.main.async { await MainActor.run {
self.update = release state.update = release
} }
} }
} }
@ -87,11 +99,3 @@ extension Updater {
} }
} }
extension Updater {
enum Constants {
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")!
}
}

View File

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

View File

@ -5,12 +5,12 @@ import SecretKit
import AppKit import AppKit
/// 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. /// 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 { public final class Agent: Sendable {
private let storeList: SecretStoreList private let storeList: SecretStoreList
private let witness: SigningWitness? private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter() private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let requestTracer = SigningRequestTracer() private let signatureWriter = OpenSSHSignatureWriter()
private let certificateHandler = OpenSSHCertificateHandler() private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@ -22,59 +22,40 @@ public final class Agent {
logger.debug("Agent is running") logger.debug("Agent is running")
self.storeList = storeList self.storeList = storeList
self.witness = witness self.witness = witness
certificateHandler.reloadCertificates(for: storeList.allSecrets) Task { @MainActor in
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
}
} }
} }
extension Agent { extension Agent {
/// Handles an incoming request. public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
/// - Parameters:
/// - reader: A ``FileHandleReader`` to read the content of the request.
/// - writer: A ``FileHandleWriter`` to write the response to.
/// - Return value:
/// - Boolean if data could be read
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) async -> Bool {
logger.debug("Agent handling new data")
let data = Data(reader.availableData)
guard data.count > 4 else { return false}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return true
}
logger.debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...])
let response = await handle(requestType: requestType, data: subData, reader: reader)
writer.write(response)
return true
}
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) async -> Data {
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting // Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
reloadSecretsIfNeccessary() await reloadSecretsIfNeccessary()
var response = Data() var response = Data()
do { do {
switch requestType { switch request {
case .requestIdentities: case .requestIdentities:
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data) response.append(SSHAgent.Response.agentIdentitiesAnswer.data)
response.append(identities()) response.append(await identities())
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
case .signRequest: case .signRequest(let context):
let provenance = requestTracer.provenance(from: reader) response.append(SSHAgent.Response.agentSignResponse.data)
response.append(SSHAgent.ResponseType.agentSignResponse.data) response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
response.append(try sign(data: data, provenance: provenance)) logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)") case .unknown(let value):
logger.error("Agent received unknown request of type \(value).")
default:
logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.")
throw UnhandledRequestError()
} }
} catch { } catch {
response.removeAll() response = SSHAgent.Response.agentFailure.data
response.append(SSHAgent.ResponseType.agentFailure.data) logger.debug("Agent returned \(SSHAgent.Response.agentFailure.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
} }
let full = OpenSSHKeyWriter().lengthAndData(of: response) return response.lengthAndData
return full
} }
} }
@ -83,27 +64,27 @@ extension Agent {
/// Lists the identities available for signing operations /// Lists the identities available for signing operations
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() -> Data { func identities() async -> Data {
let secrets = storeList.allSecrets let secrets = await storeList.allSecrets
certificateHandler.reloadCertificates(for: secrets) await certificateHandler.reloadCertificates(for: secrets)
var count = secrets.count var count = 0
var keyData = Data() var keyData = Data()
for secret in secrets { for secret in secrets {
let keyBlob = writer.data(secret: secret) let keyBlob = publicKeyWriter.data(secret: secret)
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! keyData.append(keyBlob.lengthAndData)
keyData.append(writer.lengthAndData(of: keyBlob)) keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
keyData.append(writer.lengthAndData(of: curveData)) count += 1
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) { if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(writer.lengthAndData(of: certificateData)) keyData.append(certificateData.lengthAndData)
keyData.append(writer.lengthAndData(of: name)) keyData.append(name.lengthAndData)
count += 1 count += 1
} }
} }
logger.log("Agent enumerated \(count) identities") logger.log("Agent enumerated \(count) identities")
var countBigEndian = UInt32(count).bigEndian var countBigEndian = UInt32(count).bigEndian
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8) let countData = unsafe Data(bytes: &countBigEndian, count: MemoryLayout<UInt32>.size)
return countData + keyData return countData + keyData
} }
@ -112,71 +93,19 @@ extension Agent {
/// - data: The data to sign. /// - data: The data to sign.
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request. /// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
/// - Returns: An OpenSSH formatted Data payload containing the signed data response. /// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data { func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
let reader = OpenSSHReader(data: data) guard let (secret, store) = await secret(matching: keyBlob) else {
let payloadHash = reader.readNextChunk() let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined()
let hash: Data logger.debug("Agent did not have a key matching \(keyBlobHex)")
// Check if hash is actually an openssh certificate and reconstruct the public key if it is throw NoMatchingKeyError()
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
hash = certificatePublicKey
} else {
hash = payloadHash
} }
guard let (store, secret) = secret(matching: hash) else { try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
logger.debug("Agent did not have a key matching \(hash as NSData)")
throw AgentError.noMatchingKey
}
if let witness = witness { let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
try witness.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
}
let dataToSign = reader.readNextChunk() try await witness?.witness(accessTo: secret, from: store, by: provenance)
let signed = try store.sign(data: dataToSign, with: secret, for: provenance)
let derSignature = signed
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
// Convert from DER formatted rep to raw (r||s)
let rawRepresentation: Data
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
case (.ellipticCurve, 384):
rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
default:
throw AgentError.unsupportedKeyType
}
let rawLength = rawRepresentation.count/2
// 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(writer.lengthAndData(of: r))
signatureChunk.append(writer.lengthAndData(of: s))
var signedData = Data()
var sub = Data()
sub.append(writer.lengthAndData(of: curveData))
sub.append(writer.lengthAndData(of: signatureChunk))
signedData.append(writer.lengthAndData(of: sub))
if let witness = witness {
try witness.witness(accessTo: secret, from: store, by: provenance)
}
logger.debug("Agent signed request") logger.debug("Agent signed request")
@ -188,11 +117,12 @@ extension Agent {
extension Agent { extension Agent {
/// Gives any store with no loaded secrets a chance to reload. /// Gives any store with no loaded secrets a chance to reload.
func reloadSecretsIfNeccessary() { func reloadSecretsIfNeccessary() async {
for store in storeList.stores { for store in await storeList.stores {
if store.secrets.isEmpty { if await store.secrets.isEmpty {
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.") let name = await store.name
store.reloadSecrets() logger.debug("Store \(name, privacy: .public) has no loaded secrets. Reloading.")
await store.reloadSecrets()
} }
} }
} }
@ -200,16 +130,10 @@ extension Agent {
/// Finds a ``Secret`` matching a specified hash whos signature was requested. /// Finds a ``Secret`` matching a specified hash whos signature was requested.
/// - Parameter hash: The hash to match against. /// - Parameter hash: The hash to match against.
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found. /// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
func secret(matching hash: Data) -> (AnySecretStore, AnySecret)? { func secret(matching hash: Data) async -> (AnySecret, AnySecretStore)? {
storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in await storeList.allSecretsWithStores.first {
let allMatching = store.secrets.filter { secret in hash == publicKeyWriter.data(secret: $0.0)
hash == writer.data(secret: secret)
} }
if let matching = allMatching.first {
return (store, matching)
}
return nil
}.first
} }
} }
@ -217,21 +141,16 @@ extension Agent {
extension Agent { extension Agent {
/// An error involving agent operations.. struct NoMatchingKeyError: Error {}
enum AgentError: Error { struct UnhandledRequestError: Error {}
case unhandledType
case noMatchingKey
case unsupportedKeyType
case notOpenSSHCertificate
}
} }
extension SSHAgent.ResponseType { extension SSHAgent.Response {
var data: Data { var data: Data {
var raw = self.rawValue var raw = self.rawValue
return Data(bytes: &raw, count: UInt8.bitWidth/8) return unsafe Data(bytes: &raw, count: MemoryLayout<UInt8>.size)
} }
} }

View File

@ -1,32 +1,12 @@
import Foundation import Foundation
/// Protocol abstraction of the reading aspects of FileHandle. extension 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 { public var pidOfConnectedProcess: Int32 {
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1) let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: MemoryLayout<Int32>.size, alignment: 1)
var len = socklen_t(MemoryLayout<Int32>.size) var len = socklen_t(MemoryLayout<Int32>.size)
getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len) unsafe getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
return pidPointer.load(as: Int32.self) return unsafe pidPointer.load(as: Int32.self)
} }
} }

View File

@ -1,12 +1,13 @@
import Foundation import Foundation
import OSLog import OSLog
import SecretKit
/// Manages storage and lookup for OpenSSH certificates. /// Manages storage and lookup for OpenSSH certificates.
public final class OpenSSHCertificateHandler { public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter() private let writer = OpenSSHPublicKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:] private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler. /// Initializes an OpenSSHCertificateHandler.
@ -25,38 +26,6 @@ public final class OpenSSHCertificateHandler {
} }
} }
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
/// - Parameter secret: The secret to check for a certificate.
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames[AnySecret(secret)] != nil
}
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
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 curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
default:
return nil
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with /// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
@ -86,14 +55,13 @@ public final class OpenSSHCertificateHandler {
throw OpenSSHCertificateError.parsingFailed throw OpenSSHCertificateError.parsingFailed
} }
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) { if certElements.count >= 3 {
let certName = Data(certElements[2].utf8)
return (certDecoded, certName) return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) { }
let certName = Data(secret.name.utf8)
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead") logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName) return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import Foundation
import SecretKit import SecretKit
/// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access. /// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access.
public protocol SigningWitness { public protocol SigningWitness: Sendable {
/// A ridiculously named method that notifies the callee that a signing operation is about to be performed using a secret. The callee may `throw` an `Error` to prevent access from occurring. /// A ridiculously named method that notifies the callee that a signing operation is about to be performed using a secret. The callee may `throw` an `Error` to prevent access from occurring.
/// - Parameters: /// - Parameters:
@ -10,13 +10,13 @@ public protocol SigningWitness {
/// - store: The `Store` being asked to sign the request.. /// - store: The `Store` being asked to sign the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
/// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail. /// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail.
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) 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. /// Notifies the callee that a signing operation has been performed for a given secret.
/// - Parameters: /// - Parameters:
/// - secret: The `Secret` that will was used to sign the request. /// - secret: The `Secret` that will was used to sign the request.
/// - store: The `Store` that signed the request.. /// - store: The `Store` that signed the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws
} }

View File

@ -1,23 +1,32 @@
import Foundation import Foundation
import OSLog import OSLog
import SecretKit
/// A controller that manages socket configuration and request dispatching. /// A controller that manages socket configuration and request dispatching.
public final class SocketController { public struct SocketController {
/// The active FileHandle. /// A stream of Sessions. Each session represents one connection to a class communicating with the socket. Multiple Sessions may be active simultaneously.
private var fileHandle: FileHandle? public let sessions: AsyncStream<Session>
/// The active SocketPort.
private var port: SocketPort? /// A continuation to create new sessions.
/// A handler that will be notified when a new read/write handle is available. private let sessionsContinuation: AsyncStream<Session>.Continuation
/// False if no data could be read
public var handler: (@Sendable (FileHandleReader, FileHandleWriter) async -> Bool)? /// The active SocketPort. Must be retained to be kept valid.
/// Logger. private let port: SocketPort
/// The FileHandle for the main socket.
private let fileHandle: FileHandle
/// Logger for the socket controller.
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController")
/// Tracer which determines who originates a socket connection.
private let requestTracer = SigningRequestTracer()
/// Initializes a socket controller with a specified path. /// Initializes a socket controller with a specified path.
/// - Parameter path: The path to use as a socket. /// - Parameter path: The path to use as a socket.
public init(path: String) { public init(path: String) {
(sessions, sessionsContinuation) = AsyncStream<Session>.makeStream()
logger.debug("Socket controller setting up at \(path)") logger.debug("Socket controller setting up at \(path)")
if let _ = try? FileManager.default.removeItem(atPath: path) { if let _ = try? FileManager.default.removeItem(atPath: path) {
logger.debug("Socket controller removed existing socket") logger.debug("Socket controller removed existing socket")
@ -25,76 +34,86 @@ public final class SocketController {
let exists = FileManager.default.fileExists(atPath: path) let exists = FileManager.default.fileExists(atPath: path)
assert(!exists) assert(!exists)
logger.debug("Socket controller path is clear") logger.debug("Socket controller path is clear")
port = socketPort(at: path) port = SocketPort(path: path)
configureSocket(at: path)
logger.debug("Socket listening at \(path)")
}
/// Configures the socket and a corresponding FileHandle.
/// - Parameter path: The path to use as a socket.
func configureSocket(at path: String) {
guard let port = port else { return }
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true) fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionAccept(notification:)), name: .NSFileHandleConnectionAccepted, object: nil) Task { [fileHandle, sessionsContinuation, logger] in
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionDataAvailable(notification:)), name: .NSFileHandleDataAvailable, object: nil) for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) {
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
}
/// Creates a SocketPort for a path.
/// - Parameter path: The path to use as a socket.
/// - Returns: A configured SocketPort.
func socketPort(at path: String) -> SocketPort {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
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)
}
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
}
/// Handles a new connection being accepted, invokes the handler, and prepares to accept new connections.
/// - Parameter notification: A `Notification` that triggered the call.
@objc func handleConnectionAccept(notification: Notification) {
logger.debug("Socket controller accepted connection") logger.debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return } guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
Task { [handler, fileHandle] in let session = Session(fileHandle: new)
_ = await handler?(new, new) sessionsContinuation.yield(session)
await new.waitForDataInBackgroundAndNotifyOnMainActor() await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor()
await fileHandle?.acceptConnectionInBackgroundAndNotifyOnMainActor()
}
}
/// Handles a new connection providing data and invokes the handler callback.
/// - Parameter notification: A `Notification` that triggered the call.
@objc func handleConnectionDataAvailable(notification: Notification) {
logger.debug("Socket controller has new data available")
guard let new = notification.object as? FileHandle else { return }
logger.debug("Socket controller received new file handle")
Task { [handler, logger = UncheckedSendable(logger)] in
if((await handler?(new, new)) == true) {
logger.value.debug("Socket controller handled data, wait for more data")
await new.waitForDataInBackgroundAndNotifyOnMainActor()
} else {
logger.value.debug("Socket controller called with empty data, socked closed")
} }
} }
fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
logger.debug("Socket listening at \(path)")
} }
} }
extension FileHandle { extension SocketController {
/// A session represents a connection that has been established between the two ends of the socket.
public struct Session: Sendable {
/// Data received by the socket.
public let messages: AsyncStream<Data>
/// The provenance of the process that established the session.
public let provenance: SigningRequestProvenance
/// A FileHandle used to communicate with the socket.
private let fileHandle: FileHandle
/// A continuation for issuing new messages.
private let messagesContinuation: AsyncStream<Data>.Continuation
/// A logger for the session.
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Session")
/// Initializes a new Session.
/// - Parameter fileHandle: The FileHandle used to communicate with the socket.
init(fileHandle: FileHandle) {
self.fileHandle = fileHandle
provenance = SigningRequestTracer().provenance(from: fileHandle)
(messages, messagesContinuation) = AsyncStream.makeStream()
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()
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.
public func write(_ data: Data) async throws {
try fileHandle.write(contentsOf: data)
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
}
/// Closes the socket and cleans up resources.
public func close() throws {
logger.debug("Session closed.")
messagesContinuation.finish()
try fileHandle.close()
}
}
}
private extension FileHandle {
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor. /// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() { @MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
@ -109,3 +128,27 @@ extension FileHandle {
} }
} }
private extension SocketPort {
convenience init(path: String) {
var addr = sockaddr_un()
let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
unsafe path.withCString { cstring in
let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len)
return len
}
}
// 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)
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
}
}

View File

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

View File

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

View File

@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores.
### OpenSSH ### OpenSSH
- ``OpenSSHKeyWriter`` - ``OpenSSHPublicKeyWriter``
- ``OpenSSHReader`` - ``OpenSSHReader``
### Signing Process ### Signing Process

View File

@ -1,36 +1,30 @@
import Foundation import Foundation
/// Type eraser for Secret. /// Type eraser for Secret.
public struct AnySecret: Secret { public struct AnySecret: Secret, @unchecked Sendable {
let base: Any public let base: any Secret
private let hashable: AnyHashable
private let _id: () -> AnyHashable private let _id: () -> AnyHashable
private let _name: () -> String private let _name: () -> String
private let _algorithm: () -> Algorithm
private let _keySize: () -> Int
private let _requiresAuthentication: () -> Bool
private let _publicKey: () -> Data private let _publicKey: () -> Data
private let _attributes: () -> Attributes
private let _eq: (AnySecret) -> Bool
public init<T>(_ secret: T) where T: Secret { public init<T>(_ secret: T) where T: Secret {
if let secret = secret as? AnySecret { if let secret = secret as? AnySecret {
base = secret.base base = secret.base
hashable = secret.hashable
_id = secret._id _id = secret._id
_name = secret._name _name = secret._name
_algorithm = secret._algorithm
_keySize = secret._keySize
_requiresAuthentication = secret._requiresAuthentication
_publicKey = secret._publicKey _publicKey = secret._publicKey
_attributes = secret._attributes
_eq = secret._eq
} else { } else {
base = secret as Any base = secret
self.hashable = secret
_id = { secret.id as AnyHashable } _id = { secret.id as AnyHashable }
_name = { secret.name } _name = { secret.name }
_algorithm = { secret.algorithm }
_keySize = { secret.keySize }
_requiresAuthentication = { secret.requiresAuthentication }
_publicKey = { secret.publicKey } _publicKey = { secret.publicKey }
_attributes = { secret.attributes }
_eq = { secret == $0.base as? T }
} }
} }
@ -42,28 +36,20 @@ public struct AnySecret: Secret {
_name() _name()
} }
public var algorithm: Algorithm {
_algorithm()
}
public var keySize: Int {
_keySize()
}
public var requiresAuthentication: Bool {
_requiresAuthentication()
}
public var publicKey: Data { public var publicKey: Data {
_publicKey() _publicKey()
} }
public var attributes: Attributes {
_attributes()
}
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool { public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
lhs.hashable == rhs.hashable lhs._eq(rhs)
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hashable.hash(into: &hasher) id.hash(into: &hasher)
} }
} }

View File

@ -1,21 +1,17 @@
import Foundation import Foundation
import Combine
/// Type eraser for SecretStore. /// Type eraser for SecretStore.
public class AnySecretStore: SecretStore { open class AnySecretStore: SecretStore, @unchecked Sendable {
let base: Any let base: any SecretStore
private let _isAvailable: () -> Bool private let _isAvailable: @MainActor @Sendable () -> Bool
private let _id: () -> UUID private let _id: @Sendable () -> UUID
private let _name: () -> String private let _name: @MainActor @Sendable () -> String
private let _secrets: () -> [AnySecret] private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _verify: (Data, Data, AnySecret) throws -> Bool private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext? private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void private let _reloadSecrets: @Sendable () async -> Void
private let _reloadSecrets: () -> Void
private var sink: AnyCancellable?
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
base = secretStore base = secretStore
@ -23,17 +19,13 @@ public class AnySecretStore: SecretStore {
_name = { secretStore.name } _name = { secretStore.name }
_id = { secretStore.id } _id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } } _secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } _sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
_verify = { try secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) } _existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) } _persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) } _reloadSecrets = { await secretStore.reloadSecrets() }
_reloadSecrets = { secretStore.reloadSecrets() }
sink = secretStore.objectWillChange.sink { _ in
self.objectWillChange.send()
}
} }
public var isAvailable: Bool { @MainActor public var isAvailable: Bool {
return _isAvailable() return _isAvailable()
} }
@ -41,59 +33,62 @@ public class AnySecretStore: SecretStore {
return _id() return _id()
} }
public var name: String { @MainActor public var name: String {
return _name() return _name()
} }
public var secrets: [AnySecret] { @MainActor public var secrets: [AnySecret] {
return _secrets() return _secrets()
} }
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) async throws -> Data {
try _sign(data, secret, provenance) try await _sign(data, secret, provenance)
} }
public func verify(signature: Data, for data: Data, with secret: AnySecret) throws -> Bool { public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
try _verify(signature, data, secret) await _existingPersistedAuthenticationContext(secret)
} }
public func existingPersistedAuthenticationContext(secret: AnySecret) -> PersistedAuthenticationContext? { public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
_existingPersistedAuthenticationContext(secret) try await _persistAuthentication(secret, duration)
} }
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) throws { public func reloadSecrets() async {
try _persistAuthentication(secret, duration) await _reloadSecrets()
}
public func reloadSecrets() {
_reloadSecrets()
} }
} }
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable { public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
private let _create: (String, Bool) throws -> Void private let _create: @Sendable (String, Attributes) async throws -> AnySecret
private let _delete: (AnySecret) throws -> Void private let _delete: @Sendable (AnySecret) async throws -> Void
private let _update: (AnySecret, String) throws -> Void private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
private let _supportedKeyTypes: @Sendable () -> [KeyType]
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try secretStore.create(name: $0, requiresAuthentication: $1) } _create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
_delete = { try secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) } _delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
_update = { try secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) } _update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
_supportedKeyTypes = { secretStore.supportedKeyTypes }
super.init(secretStore) super.init(secretStore)
} }
public func create(name: String, requiresAuthentication: Bool) throws { @discardableResult
try _create(name, requiresAuthentication) public func create(name: String, attributes: Attributes) async throws -> SecretType {
try await _create(name, attributes)
} }
public func delete(secret: AnySecret) throws { public func delete(secret: AnySecret) async throws {
try _delete(secret) try await _delete(secret)
} }
public func update(secret: AnySecret, name: String) throws { public func update(secret: AnySecret, name: String, attributes: Attributes) async throws {
try _update(secret, name) try await _update(secret, name, attributes)
}
public var supportedKeyTypes: [KeyType] {
_supportedKeyTypes()
} }
} }

View File

@ -36,12 +36,12 @@ public struct KeychainError: Error {
/// A signing-related error. /// A signing-related error.
public struct SigningError: Error { public struct SigningError: Error {
/// The underlying error reported by the API, if one was returned. /// The underlying error reported by the API, if one was returned.
public let error: SecurityError? public let error: CFError?
/// Initializes a SigningError with an optional SecurityError. /// Initializes a SigningError with an optional SecurityError.
/// - Parameter statusCode: The SecurityError, if one is applicable. /// - Parameter statusCode: The SecurityError, if one is applicable.
public init(error: SecurityError?) { public init(error: SecurityError?) {
self.error = error self.error = unsafe error?.takeRetainedValue()
} }
} }
@ -51,19 +51,17 @@ public extension SecretStore {
/// Returns the appropriate keychian signature algorithm to use for a given secret. /// Returns the appropriate keychian signature algorithm to use for a given secret.
/// - Parameters: /// - Parameters:
/// - secret: The secret which will be used for signing. /// - secret: The secret which will be used for signing.
/// - allowRSA: Whether or not RSA key types should be permited.
/// - Returns: The appropriate algorithm. /// - Returns: The appropriate algorithm.
func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm { func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
switch (secret.algorithm, secret.keySize) { switch secret.keyType {
case (.ellipticCurve, 256): case .ecdsa256:
return .ecdsaSignatureMessageX962SHA256 .ecdsaSignatureMessageX962SHA256
case (.ellipticCurve, 384): case .ecdsa384:
return .ecdsaSignatureMessageX962SHA384 .ecdsaSignatureMessageX962SHA384
case (.rsa, 1024), (.rsa, 2048): case .rsa2048:
guard allowRSA else { fatalError() } .rsaSignatureMessagePKCS1v15SHA512
return .rsaSignatureMessagePKCS1v15SHA512
default: default:
fatalError() nil
} }
} }

View File

@ -0,0 +1,23 @@
import Foundation
extension Data {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
let rawLength = UInt32(count)
var endian = rawLength.bigEndian
return unsafe Data(bytes: &endian, count: MemoryLayout<UInt32>.size) + self
}
}
extension String {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
Data(utf8).lengthAndData
}
}

View File

@ -1,89 +0,0 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHKeyWriter {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType) -> Data {
lengthAndData(of: curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
lengthAndData(of: curveIdentifier(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
lengthAndData(of: secret.publicKey)
}
/// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType, comment: String? = nil) -> String {
[curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment]
.compactMap { $0 }
.joined(separator: " ")
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
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))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
}
}
extension OpenSSHKeyWriter {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Parameter data: The data payload.
/// - Returns: OpenSSH data.
public func lengthAndData(of data: Data) -> Data {
let rawLength = UInt32(data.count)
var endian = rawLength.bigEndian
return Data(bytes: &endian, count: UInt32.bitWidth/8) + data
}
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func curveType(for algorithm: Algorithm, length: Int) -> String {
switch algorithm {
case .ellipticCurve:
return "ecdsa-sha2-nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function, per
// https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key
return "rsa-sha2-512"
}
}
/// The OpenSSH identifier for an algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
private func curveIdentifier(for algorithm: Algorithm, length: Int) -> String {
switch algorithm {
case .ellipticCurve:
return "nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function
return "rsa-sha2-512"
}
}
}

View File

@ -0,0 +1,114 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of the public key sof secrets.
public struct OpenSSHPublicKeyWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
openSSHIdentifier(for: secret.keyType).lengthAndData +
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData
case .mldsa:
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
openSSHIdentifier(for: secret.keyType).lengthAndData +
rsaPublicKeyBlob(secret: secret)
}
}
/// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), comment(secret: secret)]
.compactMap { $0 }
.joined(separator: " ")
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
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))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
}
public func comment<SecretType: Secret>(secret: SecretType) -> String {
if let comment = secret.publicKeyAttribution {
return comment
} else {
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
return "\(dashedKeyName)@\(dashedHostName)"
}
}
}
extension OpenSSHPublicKeyWriter {
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func openSSHIdentifier(for keyType: KeyType) -> String {
switch keyType {
case .ecdsa256:
"ecdsa-sha2-nistp256"
case .ecdsa384:
"ecdsa-sha2-nistp384"
case .mldsa65:
"ssh-mldsa-65"
case .mldsa87:
"ssh-mldsa-87"
case .rsa2048:
"ssh-rsa"
default:
"unknown"
}
}
}
extension OpenSSHPublicKeyWriter {
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]
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
// we only support one key type, and the keychain always gives it in a specific format.
guard secret.keyType == .rsa2048 else { fatalError() }
let length = secret.keyType.size/8
let data = secret.publicKey
let n = Data(data[8..<(9+length)])
let e = Data(data[(2+9+length)...])
return e.lengthAndData + n.lengthAndData
}
}

View File

@ -1,30 +0,0 @@
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.withUnsafeBytes { pointer in
return pointer.load(as: UInt32.self)
}
let length = Int(littleEndianLength.bigEndian)
let dataRange = 0..<length
let ret = Data(remaining[dataRange])
remaining.removeSubrange(dataRange)
return ret
}
}

View File

@ -0,0 +1,75 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHSignatureWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType, signature: Data) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
// 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-00#name-public-key-algorithms
mldsaSignature(signature, keyType: secret.keyType)
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
rsaSignature(signature)
}
}
}
extension OpenSSHSignatureWriter {
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
let rawLength = rawRepresentation.count/2
// 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)
signatureChunk.append(s.lengthAndData)
var mutSignedData = Data()
var sub = Data()
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
sub.append(signatureChunk.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
func mldsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
var mutSignedData = Data()
var sub = Data()
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
sub.append(rawRepresentation.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
func rsaSignature(_ rawRepresentation: Data) -> Data {
var mutSignedData = Data()
var sub = Data()
sub.append("rsa-sha2-512".lengthAndData)
sub.append(rawRepresentation.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
}

View File

@ -2,15 +2,15 @@ import Foundation
import OSLog import OSLog
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts. /// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
public final class PublicKeyFileStoreController { public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: String private let directory: URL
private let keyWriter = OpenSSHKeyWriter() private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController. /// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) { public init(homeDirectory: URL) {
directory = homeDirectory.appending("/PublicKeys") directory = homeDirectory.appending(component: "PublicKeys")
} }
/// Writes out the keys specified to disk. /// Writes out the keys specified to disk.
@ -20,19 +20,20 @@ public final class PublicKeyFileStoreController {
logger.log("Writing public keys to disk") logger.log("Writing public keys to disk")
if clear { if clear {
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) })) let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" } let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
let untracked = Set(fullPathContents) let untracked = Set(fullPathContents)
.subtracting(validPaths) .subtracting(validPaths)
for path in untracked { for path in untracked {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path)) // string instead of fileURLWithPath since we're already using fileURL format.
try? FileManager.default.removeItem(at: URL(string: path)!)
} }
} }
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
for secret in secrets { for secret in secrets {
let path = publicKeyPath(for: secret) let path = publicKeyPath(for: secret)
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue } let data = Data(keyWriter.openSSHString(secret: secret).utf8)
FileManager.default.createFile(atPath: path, contents: data, attributes: nil) FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
} }
logger.log("Finished writing public keys") logger.log("Finished writing public keys")
@ -44,14 +45,14 @@ public final class PublicKeyFileStoreController {
/// - 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. /// - 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 { public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex).pub") 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. /// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
public var hasAnyCertificates: Bool { public var hasAnyCertificates: Bool {
do { do {
return try FileManager.default return try FileManager.default
.contentsOfDirectory(atPath: directory) .contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") } .filter { $0.hasSuffix("-cert.pub") }
.isEmpty == false .isEmpty == false
} catch { } catch {
@ -65,7 +66,7 @@ public final class PublicKeyFileStoreController {
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be. /// - 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 { public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex)-cert.pub") return directory.appending(component: "\(minimalHex)-cert.pub").path()
} }
} }

View File

@ -1,49 +1,47 @@
import Foundation import Foundation
import Combine import Observation
/// A "Store Store," which holds a list of type-erased stores. /// A "Store Store," which holds a list of type-erased stores.
public final class SecretStoreList: ObservableObject { @Observable @MainActor public final class SecretStoreList: Sendable {
/// The Stores managed by the SecretStoreList. /// The Stores managed by the SecretStoreList.
@Published public var stores: [AnySecretStore] = [] public var stores: [AnySecretStore] = []
/// A modifiable store, if one is available. /// A modifiable store, if one is available.
@Published public var modifiableStore: AnySecretStoreModifiable? public var modifiableStore: AnySecretStoreModifiable? = nil
private var cancellables: Set<AnyCancellable> = []
/// Initializes a SecretStoreList. /// Initializes a SecretStoreList.
public init() { public nonisolated init() {
} }
/// Adds a non-type-erased SecretStore to the list. /// Adds a non-type-erased SecretStore to the list.
public func add<SecretStoreType: SecretStore>(store: SecretStoreType) { public func add<SecretStoreType: SecretStore>(store: SecretStoreType) {
addInternal(store: AnySecretStore(store)) stores.append(AnySecretStore(store))
} }
/// Adds a non-type-erased modifiable SecretStore. /// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) { public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store) let modifiable = AnySecretStoreModifiable(store)
if modifiableStore == nil {
modifiableStore = modifiable modifiableStore = modifiable
addInternal(store: modifiable) }
stores.append(modifiable)
} }
/// A boolean describing whether there are any Stores available. /// A boolean describing whether there are any Stores available.
public var anyAvailable: Bool { public var anyAvailable: Bool {
stores.reduce(false, { $0 || $1.isAvailable }) stores.contains(where: \.isAvailable)
} }
public var allSecrets: [AnySecret] { public var allSecrets: [AnySecret] {
stores.flatMap(\.secrets) stores.flatMap(\.secrets)
} }
} public var allSecretsWithStores: [(AnySecret, AnySecretStore)] {
stores.flatMap { store in
extension SecretStoreList { store.secrets.map { secret in
(secret, store)
private func addInternal(store: AnySecretStore) { }
stores.append(store) }
store.objectWillChange.sink {
self.objectWillChange.send()
}.store(in: &cancellables)
} }
} }

View File

@ -0,0 +1,55 @@
import Foundation
public struct Attributes: Sendable, Codable, Hashable {
/// The type of key involved.
public let keyType: KeyType
/// The authentication requirements for the key. This is simply a description of the option recorded at creation modifying it doers not modify the key's authentication requirements.
public let authentication: AuthenticationRequirement
/// The string appended to the end of the SSH Public Key.
/// If nil, a default value will be used.
public var publicKeyAttribution: String?
public init(
keyType: KeyType,
authentication: AuthenticationRequirement,
publicKeyAttribution: String? = nil
) {
self.keyType = keyType
self.authentication = authentication
self.publicKeyAttribution = publicKeyAttribution
}
public struct UnsupportedOptionError: Error {
package init() {}
}
}
/// The option specified
public enum AuthenticationRequirement: String, Hashable, Sendable, Codable, Identifiable {
/// Authentication is not required for usage.
case notRequired
/// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry..
case presenceRequired
/// ONLY the current set of biometric data, as matching at time of creation, is accepted.
/// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password.
case biometryCurrent
/// The authentication requirement was not recorded at creation, and is unknown.
case unknown
/// Whether or not the key is known to require authentication.
public var required: Bool {
self == .presenceRequired || self == .biometryCurrent
}
public var id: AuthenticationRequirement {
self
}
}

View File

@ -1,7 +1,7 @@
import Foundation 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. /// 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 { public protocol PersistedAuthenticationContext: Sendable {
/// Whether the context remains valid. /// Whether the context remains valid.
var valid: Bool { get } var valid: Bool { get }
/// The date at which the authorization expires and the context becomes invalid. /// The date at which the authorization expires and the context becomes invalid.

View File

@ -1,47 +1,85 @@
import Foundation import Foundation
/// The base protocol for describing a Secret /// The base protocol for describing a Secret
public protocol Secret: Identifiable, Hashable { public protocol Secret: Identifiable, Hashable, Sendable {
/// A user-facing string identifying the Secret. /// A user-facing string identifying the Secret.
var name: String { get } var name: String { get }
/// The algorithm this secret uses.
var algorithm: Algorithm { get }
/// The key size for the secret.
var keySize: Int { get }
/// Whether the secret requires authentication before use.
var requiresAuthentication: Bool { get }
/// The public key data for the secret. /// The public key data for the secret.
var publicKey: Data { get } var publicKey: Data { get }
/// The attributes of the key.
var attributes: Attributes { get }
} }
/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported. public extension Secret {
public enum Algorithm: Hashable {
case ellipticCurve /// The algorithm and key size this secret uses.
var keyType: KeyType {
attributes.keyType
}
/// Whether the secret requires authentication before use.
var authenticationRequirement: AuthenticationRequirement {
attributes.authentication
}
/// An attribution string to apply to the generated public key.
var publicKeyAttribution: String? {
attributes.publicKeyAttribution
}
}
/// The type of algorithm the Secret uses.
public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
public static let ecdsa256 = KeyType(algorithm: .ecdsa, size: 256)
public static let ecdsa384 = KeyType(algorithm: .ecdsa, size: 384)
public static let mldsa65 = KeyType(algorithm: .mldsa, size: 65)
public static let mldsa87 = KeyType(algorithm: .mldsa, size: 87)
public static let rsa2048 = KeyType(algorithm: .rsa, size: 2048)
public enum Algorithm: Hashable, Sendable, Codable {
case ecdsa
case mldsa
case rsa case rsa
}
public var algorithm: Algorithm
public var size: Int
public init(algorithm: Algorithm, size: Int) {
self.algorithm = algorithm
self.size = size
}
/// Initializes the Algorithm with a secAttr representation of an algorithm. /// Initializes the Algorithm with a secAttr representation of an algorithm.
/// - Parameter secAttr: the secAttr, represented as an NSNumber. /// - Parameter secAttr: the secAttr, represented as an NSNumber.
public init(secAttr: NSNumber) { public init?(secAttr: NSNumber, size: Int) {
let secAttrString = secAttr.stringValue as CFString let secAttrString = secAttr.stringValue as CFString
switch secAttrString { switch secAttrString {
case kSecAttrKeyTypeEC: case kSecAttrKeyTypeEC:
self = .ellipticCurve algorithm = .ecdsa
case kSecAttrKeyTypeRSA: case kSecAttrKeyTypeRSA:
self = .rsa algorithm = .rsa
default: default:
fatalError() return nil
}
self.size = size
}
public var secAttrKeyType: CFString? {
switch algorithm {
case .ecdsa:
kSecAttrKeyTypeEC
case .rsa:
kSecAttrKeyTypeRSA
case .mldsa:
nil
} }
} }
public var secAttrKeyType: CFString { public var description: String {
switch self { "\(algorithm)-\(size)"
case .ellipticCurve:
return kSecAttrKeyTypeEC
case .rsa:
return kSecAttrKeyTypeRSA
}
} }
} }

View File

@ -1,19 +1,18 @@
import Foundation import Foundation
import Combine
/// Manages access to Secrets, and performs signature operations on data using those Secrets. /// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore: ObservableObject, Identifiable { public protocol SecretStore<SecretType>: Identifiable, Sendable {
associatedtype SecretType: Secret associatedtype SecretType: Secret
/// A boolean indicating whether or not the store is available. /// A boolean indicating whether or not the store is available.
var isAvailable: Bool { get } @MainActor var isAvailable: Bool { get }
/// A unique identifier for the store. /// A unique identifier for the store.
var id: UUID { get } var id: UUID { get }
/// A user-facing name for the store. /// A user-facing name for the store.
var name: String { get } @MainActor var name: String { get }
/// The secrets the store manages. /// The secrets the store manages.
var secrets: [SecretType] { get } @MainActor var secrets: [SecretType] { get }
/// Signs a data payload with a specified Secret. /// Signs a data payload with a specified Secret.
/// - Parameters: /// - Parameters:
@ -21,53 +20,49 @@ public protocol SecretStore: ObservableObject, Identifiable {
/// - secret: The ``Secret`` to sign with. /// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from. /// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: The signed data. /// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
/// Verifies that a signature is valid over a specified payload.
/// - Parameters:
/// - signature: The signature over the data.
/// - data: The data to verify the signature of.
/// - secret: The secret whose signature to verify.
/// - Returns: Whether the signature was verified.
func verify(signature: Data, for data: Data, with secret: SecretType) throws -> Bool
/// Checks to see if there is currently a valid persisted authentication for a given secret. /// Checks to see if there is currently a valid persisted authentication for a given secret.
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for. /// - secret: The ``Secret`` to check if there is a persisted authentication for.
/// - Returns: A persisted authentication context, if a valid one exists. /// - Returns: A persisted authentication context, if a valid one exists.
func existingPersistedAuthenticationContext(secret: SecretType) -> PersistedAuthenticationContext? func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
/// Persists user authorization for access to a secret. /// Persists user authorization for access to a secret.
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to persist the authorization for. /// - secret: The ``Secret`` to persist the authorization for.
/// - duration: The duration that the authorization should persist 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. /// - 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) throws func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
/// Requests that the store reload secrets from any backing store, if neccessary. /// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() func reloadSecrets() async
} }
/// A SecretStore that the Secretive admin app can modify. /// A SecretStore that the Secretive admin app can modify.
public protocol SecretStoreModifiable: SecretStore { public protocol SecretStoreModifiable<SecretType>: SecretStore {
/// Creates a new ``Secret`` in the store. /// Creates a new ``Secret`` in the store.
/// - Parameters: /// - Parameters:
/// - name: The user-facing name for the ``Secret``. /// - name: The user-facing name for the ``Secret``.
/// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret. /// - attributes: A struct describing the options for creating the key.'
func create(name: String, requiresAuthentication: Bool) throws @discardableResult
func create(name: String, attributes: Attributes) async throws -> SecretType
/// Deletes a Secret in the store. /// Deletes a Secret in the store.
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to delete. /// - secret: The ``Secret`` to delete.
func delete(secret: SecretType) throws func delete(secret: SecretType) async throws
/// Updates the name of a Secret in the store. /// Updates the name of a Secret in the store.
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to update. /// - secret: The ``Secret`` to update.
/// - name: The new name for the Secret. /// - name: The new name for the Secret.
func update(secret: SecretType, name: String) throws /// - attributes: The new attributes for the secret.
func update(secret: SecretType, name: String, attributes: Attributes) async throws
var supportedKeyTypes: [KeyType] { get }
} }

View File

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

View File

@ -0,0 +1,103 @@
import Foundation
import Security
import CryptoTokenKit
import CryptoKit
import SecretKit
import os
extension SecureEnclave {
public struct CryptoKitMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CryptoKitMigrator")
public init() {
}
/// Keys prior to 3.0 were created and stored directly using the keychain as kSecClassKey items. CryptoKit operates a little differently, in that it creates a key on your behalf which you can persist using an opaque data blob to a generic keychain item. Keychain created keys _also_ use this blob under the hood, but it's stored in the "toid" attribute. This migrates the old keys from kSecClassKey to generic items, copying the "toid" to be the main stored data. If the key is migrated successfully, the old key's identifier is renamed to indicate it's been migrated.
/// - Note: Migration is non-destructive users can still see and use their keys in older versions of Secretive.
@MainActor public func migrate(to store: Store) throws {
let privateAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: Constants.oldKeyType,
kSecAttrApplicationTag: SecureEnclave.Store.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
unsafe SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
var migratedAny = false
for key in privateTyped {
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = key[kSecAttrApplicationLabel] as! Data
guard !id.contains(Constants.migrationMagicNumber) else {
logger.log("Skipping \(name), already migrated.")
continue
}
let ref = key[kSecValueRef] as! SecKey
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
let tokenObjectID = unsafe 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.")
try markMigrated(secret: secret, oldID: id)
continue
}
logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).")
try markMigrated(secret: secret, oldID: id)
migratedAny = true
} catch {
logger.error("Failed to migrate \(name): \(error).")
}
}
if migratedAny {
store.reloadSecrets()
}
}
public func markMigrated(secret: Secret, oldID: Data) throws {
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id
])
let newID = oldID + Constants.migrationMagicNumber
let updatedAttributes = KeychainDictionary([
kSecAttrApplicationLabel: newID as CFData
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
}
}
}
extension SecureEnclave.CryptoKitMigrator {
enum Constants {
public static let oldKeyType = kSecAttrKeyTypeECSECPrimeRandom as String
public static let migrationMagicNumber = Data("_cryptokit_1".utf8)
// https://github.com/apple-opensource/Security/blob/5e9101b3bd1fb096bae4f40e79d50426ba1db8e9/OSX/sec/Security/SecItemConstants.c#L111
public static nonisolated(unsafe) let tokenObjectID = "toid" as CFString
}
}

View File

@ -0,0 +1,70 @@
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
unsafe self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
/// A boolean describing whether or not the context is still valid.
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]
let durationString = formatter.string(from: duration)!
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
persistedAuthenticationContexts[secret] = context
}
}
}

View File

@ -1,5 +1,4 @@
import Foundation import Foundation
import Combine
import SecretKit import SecretKit
extension SecureEnclave { extension SecureEnclave {
@ -7,12 +6,26 @@ extension SecureEnclave {
/// An implementation of Secret backed by the Secure Enclave. /// An implementation of Secret backed by the Secure Enclave.
public struct Secret: SecretKit.Secret { public struct Secret: SecretKit.Secret {
public let id: Data public let id: String
public let name: String public let name: String
public let algorithm = Algorithm.ellipticCurve
public let keySize = 256
public let requiresAuthentication: Bool
public let publicKey: Data public let publicKey: Data
public let attributes: Attributes
init(
id: String,
name: String,
publicKey: Data,
attributes: Attributes
) {
self.id = id
self.name = name
self.publicKey = publicKey
self.attributes = attributes
}
public static func ==(lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
} }

View File

@ -1,351 +1,292 @@
import Foundation import Foundation
import Combine import Observation
import Security import Security
import CryptoTokenKit import CryptoKit
import LocalAuthentication import LocalAuthentication
import SecretKit import SecretKit
import os
extension SecureEnclave { extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave. /// An implementation of Store backed by the Secure Enclave using CryptoKit API.
public final class Store: SecretStoreModifiable { @Observable public final class Store: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
public var isAvailable: Bool { public var isAvailable: Bool {
// For some reason, as of build time, CryptoKit.SecureEnclave.isAvailable always returns false CryptoKit.SecureEnclave.isAvailable
// error msg "Received error sending GET UNIQUE DEVICE command"
// Verify it with TKTokenWatcher manually.
TKTokenWatcher().tokenIDs.contains("com.apple.setoken")
} }
public let id = UUID() public let id = UUID()
public let name = String(localized: "secure_enclave") public let name = String(localized: .secureEnclave)
@Published public private(set) var secrets: [Secret] = [] private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
/// Initializes a Store. /// Initializes a Store.
public init() { @MainActor public init() {
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { [reload = reloadSecretsInternal(notifyAgent:)] _ in
reload(false)
}
loadSecrets() loadSecrets()
Task {
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
guard Constants.notificationToken != (note.object as? String) else {
// Don't reload if we're the ones triggering this by reloading.
continue
}
reloadSecrets()
}
}
} }
// MARK: Public API // MARK: - Public API
public func create(name: String, requiresAuthentication: Bool) throws { // MARK: SecretStore
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
if requiresAuthentication { var context: LAContext
flags = [.privateKeyUsage, .userPresence] if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = unsafe existing.context
} else { } else {
flags = .privateKeyUsage 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,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: secret.id,
kSecReturnAttributes: true,
kSecReturnData: true,
])
var untyped: CFTypeRef?
let status = unsafe SecItemCopyMatching(queryAttributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped as? [CFString: Any] else {
throw KeychainError(statusCode: errSecSuccess)
}
guard let attributesData = untypedSafe[kSecAttrGeneric] as? Data,
let keyData = untypedSafe[kSecValueData] as? Data else {
throw MissingAttributesError()
}
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
switch attributes.keyType {
case .ecdsa256:
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
return try key.signature(for: data).rawRepresentation
case .mldsa65:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
return try key.signature(for: data)
case .mldsa87:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
return try key.signature(for: data)
default:
throw UnsupportedAlgorithmError()
}
}
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()
loadSecrets()
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
}
}
// MARK: SecretStoreModifiable
public func create(name: String, attributes: Attributes) async throws -> Secret {
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
case .notRequired:
[.privateKeyUsage]
case .presenceRequired:
[.userPresence, .privateKeyUsage]
case .biometryCurrent:
[.biometryCurrentSet, .privateKeyUsage]
case .unknown:
fatalError()
} }
let access = let access =
SecAccessControlCreateWithFlags(kCFAllocatorDefault, unsafe SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags, flags,
&accessError) as Any &accessError)
if let error = accessError { if let error = unsafe accessError {
throw error.takeRetainedValue() as Error throw unsafe error.takeRetainedValue() as Error
}
let dataRep: Data
let publicKey: Data
switch attributes.keyType {
case .ecdsa256:
let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
dataRep = created.dataRepresentation
publicKey = created.publicKey.x963Representation
case .mldsa65:
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!)
dataRep = created.dataRepresentation
publicKey = created.publicKey.rawRepresentation
case .mldsa87:
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!)
dataRep = created.dataRepresentation
publicKey = created.publicKey.rawRepresentation
default:
throw Attributes.UnsupportedOptionError()
}
let id = try saveKey(dataRep, name: name, attributes: attributes)
await reloadSecrets()
return Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
} }
let attributes = KeychainDictionary([ public func delete(secret: Secret) async throws {
kSecAttrLabel: name,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecPrivateKeyAttrs: [
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
])
var createKeyError: SecurityError?
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
if let error = createKeyError {
throw error.takeRetainedValue() as Error
}
guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else {
throw KeychainError(statusCode: nil)
}
try savePublicKey(publicKey, name: name)
reloadSecretsInternal()
}
public func delete(secret: Secret) throws {
let deleteAttributes = KeychainDictionary([ let deleteAttributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: Constants.keyClass,
kSecAttrApplicationLabel: secret.id as CFData kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: secret.id,
]) ])
let status = SecItemDelete(deleteAttributes) let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
} }
reloadSecretsInternal() await reloadSecrets()
} }
public func update(secret: Secret, name: String) throws { public func update(secret: Secret, name: String, attributes: Attributes) async throws {
let updateQuery = KeychainDictionary([ let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: Constants.keyClass,
kSecAttrApplicationLabel: secret.id as CFData kSecAttrAccount: secret.id,
]) ])
let attributes = try JSONEncoder().encode(attributes)
let updatedAttributes = KeychainDictionary([ let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name, kSecAttrLabel: name,
kSecAttrGeneric: attributes,
]) ])
let status = SecItemUpdate(updateQuery, updatedAttributes) let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
} }
reloadSecretsInternal() await reloadSecrets()
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public var supportedKeyTypes: [KeyType] {
let context: LAContext if #available(macOS 26, *) {
if let existing = persistedAuthenticationContexts[secret], existing.valid { [
context = existing.context .ecdsa256,
.mldsa65,
.mldsa87,
]
} else { } else {
let newContext = LAContext() [.ecdsa256]
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
context = newContext
}
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_verify_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var verifyError: SecurityError?
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
} }
} }
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
if let durationString = formatter.string(from: duration) {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
} else {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
}
newContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) { [weak self] success, _ in
guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
self?.persistedAuthenticationContexts[secret] = context
}
}
public func reloadSecrets() {
reloadSecretsInternal(notifyAgent: false)
}
} }
} }
extension SecureEnclave.Store { extension SecureEnclave.Store {
/// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecretsInternal(notifyAgent: Bool = true) {
let before = secrets
secrets.removeAll()
loadSecrets()
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
}
}
}
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { @MainActor private func loadSecrets() {
let publicAttributes = KeychainDictionary([ let queryAttributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: Constants.keyClass,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrService: Constants.keyTag,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, kSecUseDataProtectionKeychain: true,
kSecAttrKeyClass: kSecAttrKeyClassPublic, kSecReturnData: true,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll, kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true kSecReturnAttributes: true
]) ])
var publicUntyped: CFTypeRef? var untyped: CFTypeRef?
SecItemCopyMatching(publicAttributes, &publicUntyped) unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return } guard let typed = untyped as? [[CFString: Any]] else { return }
let privateAttributes = KeychainDictionary([ let wrapped: [SecureEnclave.Secret] = typed.compactMap {
kSecClass: kSecClassKey, do {
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in
let id = next[kSecAttrApplicationLabel] as! Data
partialResult[id] = next
}
let authNotRequiredAccessControl: SecAccessControl =
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage],
nil)!
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
let id = $0[kSecAttrApplicationLabel] as! Data guard let attributesData = $0[kSecAttrGeneric] as? Data,
let publicKeyRef = $0[kSecValueRef] as! SecKey let id = $0[kSecAttrAccount] as? String else {
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any] throw MissingAttributesError()
let publicKey = publicKeyAttributes[kSecValueData] as! Data }
let privateKey = privateMapped[id] let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
let requiresAuth: Bool let keyData = $0[kSecValueData] as! Data
if let authRequirements = privateKey?[kSecAttrAccessControl] { let publicKey: Data
// Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality. switch attributes.keyType {
requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl case .ecdsa256:
} else { let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
requiresAuth = false publicKey = key.publicKey.x963Representation
case .mldsa65:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
publicKey = key.publicKey.rawRepresentation
case .mldsa87:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
publicKey = key.publicKey.rawRepresentation
default:
throw UnsupportedAlgorithmError()
}
return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
} catch {
return nil
} }
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
} }
secrets.append(contentsOf: wrapped) secrets.append(contentsOf: wrapped)
} }
/// Saves a public key. /// Saves a public key.
/// - Parameters: /// - Parameters:
/// - publicKey: The public key to save. /// - key: The data representation key to save.
/// - name: A user-facing name for the key. /// - name: A user-facing name for the key.
private func savePublicKey(_ publicKey: SecKey, name: String) throws { /// - attributes: Attributes of the key.
let attributes = KeychainDictionary([ /// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate.
kSecClass: kSecClassKey, @discardableResult
kSecAttrKeyType: SecureEnclave.Constants.keyType, func saveKey(_ key: Data, name: String, attributes: Attributes) throws -> String {
kSecAttrKeyClass: kSecAttrKeyClassPublic, let attributes = try JSONEncoder().encode(attributes)
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, let id = UUID().uuidString
kSecValueRef: publicKey, let keychainAttributes = KeychainDictionary([
kSecAttrIsPermanent: true, kSecClass: Constants.keyClass,
kSecReturnData: true, kSecAttrService: Constants.keyTag,
kSecAttrLabel: name kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAttrAccount: id,
kSecValueData: key,
kSecAttrLabel: name,
kSecAttrGeneric: attributes
]) ])
let status = SecItemAdd(attributes, nil) let status = SecItemAdd(keychainAttributes, nil)
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
} }
return id
} }
} }
extension SecureEnclave { extension SecureEnclave.Store {
enum Constants { enum Constants {
static let keyTag = "com.maxgoedjen.secretive.secureenclave.key".data(using: .utf8)! as CFData static let keyClass = kSecClassGenericPassword as String
static let keyType = kSecAttrKeyTypeECSECPrimeRandom static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
static let unauthenticatedThreshold: TimeInterval = 0.05 static let notificationToken = UUID().uuidString
}
}
extension SecureEnclave {
/// A context describing a persisted authentication.
private struct PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
/// The LAContext used to authorize the persistent context.
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)
}
} }
struct UnsupportedAlgorithmError: Error {}
struct MissingAttributesError: Error {}
} }

View File

@ -1,5 +1,4 @@
import Foundation import Foundation
import Combine
import SecretKit import SecretKit
extension SmartCard { extension SmartCard {
@ -9,10 +8,8 @@ extension SmartCard {
public let id: Data public let id: Data
public let name: String public let name: String
public let algorithm: Algorithm
public let keySize: Int
public let requiresAuthentication: Bool = false
public let publicKey: Data public let publicKey: Data
public var attributes: Attributes
} }

View File

@ -1,57 +1,66 @@
import Foundation import Foundation
import Combine import Observation
import Security import Security
import CryptoTokenKit @unsafe @preconcurrency import CryptoTokenKit
import LocalAuthentication import LocalAuthentication
import SecretKit import SecretKit
extension SmartCard { extension SmartCard {
/// An implementation of Store backed by a Smart Card. @MainActor @Observable fileprivate final class State {
public final class Store: SecretStore { var isAvailable = false
var name = String(localized: .smartCard)
var secrets: [Secret] = []
let watcher = TKTokenWatcher()
var tokenID: String? = nil
nonisolated init() {}
}
/// An implementation of Store backed by a Smart Card.
@Observable public final class Store: SecretStore {
private let state = State()
public var isAvailable: Bool {
state.isAvailable
}
@MainActor public var smartcardTokenID: String? {
state.tokenID
}
@Published public var isAvailable: Bool = false
public let id = UUID() public let id = UUID()
public private(set) var name = String(localized: "smart_card") @MainActor public var name: String {
@Published public private(set) var secrets: [Secret] = [] state.name
private let watcher = TKTokenWatcher() }
private var tokenID: String? public var secrets: [Secret] {
state.secrets
}
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
tokenID = watcher.nonSecureEnclaveTokens.first Task {
watcher.setInsertionHandler { [reload = reloadSecretsInternal] string in await MainActor.run {
guard self.tokenID == nil else { return } if let tokenID = smartcardTokenID {
guard !string.contains("setoken") else { return } state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
self.tokenID = string
DispatchQueue.main.async {
reload()
}
self.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
}
if let tokenID = tokenID {
self.isAvailable = true
self.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
} }
loadSecrets() loadSecrets()
} }
// Doing this inside a regular mainactor handler casues thread assertions in CryptoTokenKit to blow up when the handler executes.
await state.watcher.setInsertionHandler { id in
Task {
await self.smartcardInserted(for: id)
}
}
}
}
// MARK: Public API // MARK: Public API
public func create(name: String) throws { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
fatalError("Keys must be created on the smart card.") guard let tokenID = await state.tokenID else { fatalError() }
}
public func delete(secret: Secret) throws {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard let tokenID = tokenID else { fatalError() }
let context = LAContext() let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@ -61,7 +70,7 @@ extension SmartCard {
kSecReturnRef: true kSecReturnRef: true
]) ])
var untyped: CFTypeRef? var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped) let status = unsafe SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
} }
@ -70,35 +79,13 @@ extension SmartCard {
} }
let key = untypedSafe as! SecKey let key = untypedSafe as! SecKey
var signError: SecurityError? var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else { guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
throw SigningError(error: signError) guard let signature = unsafe SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
throw unsafe SigningError(error: signError)
} }
return signature as Data return signature as Data
} }
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
}
}
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
nil nil
} }
@ -107,7 +94,7 @@ extension SmartCard {
} }
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
public func reloadSecrets() { @MainActor public func reloadSecrets() {
reloadSecretsInternal() reloadSecretsInternal()
} }
@ -117,32 +104,44 @@ extension SmartCard {
extension SmartCard.Store { extension SmartCard.Store {
private func reloadSecretsInternal() { @MainActor private func reloadSecretsInternal() {
self.isAvailable = self.tokenID != nil let before = state.secrets
let before = self.secrets state.isAvailable = state.tokenID != nil
self.secrets.removeAll() state.secrets.removeAll()
self.loadSecrets() loadSecrets()
if self.secrets != before { if self.secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self) NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
} }
} }
/// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was inserted.
@MainActor private func smartcardInserted(for tokenID: String? = nil) {
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
guard state.tokenID == nil else { return }
guard !string.contains("setoken") else { return }
state.tokenID = string
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
state.tokenID = string
reloadSecretsInternal()
}
/// Resets the token ID and reloads secrets. /// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was removed. /// - Parameter tokenID: The ID of the token that was removed.
private func smartcardRemoved(for tokenID: String? = nil) { @MainActor private func smartcardRemoved(for tokenID: String? = nil) {
self.tokenID = nil state.tokenID = nil
reloadSecrets() reloadSecrets()
} }
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { @MainActor private func loadSecrets() {
guard let tokenID = tokenID else { return } guard let tokenID = state.tokenID else { return }
let fallbackName = String(localized: "smart_card") let fallbackName = String(localized: .smartCard)
if let driverName = watcher.tokenInfo(forTokenID: tokenID)?.driverName { if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
name = driverName state.name = driverName
} else { } else {
name = fallbackName state.name = fallbackName
} }
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
@ -153,102 +152,23 @@ extension SmartCard.Store {
kSecReturnAttributes: true kSecReturnAttributes: true
]) ])
var untyped: CFTypeRef? var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped) unsafe SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return } guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped = typed.map { let wrapped: [SecretType] = typed.compactMap {
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let tokenID = $0[kSecAttrApplicationLabel] as! Data let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber) let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber
let keySize = $0[kSecAttrKeySizeInBits] as! Int let keySize = $0[kSecAttrKeySizeInBits] as! Int
let publicKeyRef = $0[kSecValueRef] as! SecKey let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)! let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any] let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey) let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
} let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
secrets.append(contentsOf: wrapped) guard signatureAlgorithm(for: secret) != nil else { return nil }
} return secret
}
// MARK: Smart Card specific encryption/decryption/verification
extension SmartCard.Store {
/// Encrypts a payload with a specified key.
/// - Parameters:
/// - data: The payload to encrypt.
/// - secret: The secret to encrypt with.
/// - Returns: The encrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecUseAuthenticationContext: context
])
var encryptError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &encryptError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
guard let signature = SecKeyCreateEncryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
throw SigningError(error: encryptError)
}
return signature as Data
}
/// Decrypts a payload with a specified key.
/// - Parameters:
/// - data: The payload to decrypt.
/// - secret: The secret to decrypt with.
/// - Returns: The decrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func decrypt(data: Data, with secret: SecretType) throws -> Data {
guard let tokenID = tokenID else { fatalError() }
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var encryptError: SecurityError?
guard let signature = SecKeyCreateDecryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
throw SigningError(error: encryptError)
}
return signature as Data
}
private func encryptionAlgorithm(for secret: SecretType) -> SecKeyAlgorithm {
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM
case (.ellipticCurve, 384):
return .eciesEncryptionCofactorVariableIVX963SHA384AESGCM
case (.rsa, 1024), (.rsa, 2048):
return .rsaEncryptionOAEPSHA512AESGCM
default:
fatalError()
} }
state.secrets.append(contentsOf: wrapped)
} }
} }
@ -261,3 +181,9 @@ extension TKTokenWatcher {
} }
} }
extension SmartCard {
public struct UnsupportKeyType: Error {}
}

View File

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

View File

@ -0,0 +1,70 @@
import Foundation
public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
private let exportedObject: ErasedXPCProtocol
public init<XPCProtocolType: XPCProtocol>(exportedObject: XPCProtocolType) {
self.exportedObject = ErasedXPCProtocol(exportedObject)
}
public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
let exportedObject = exportedObject
newConnection.exportedObject = exportedObject
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
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 {
reply(nil, error)
}
}
}
}
}
func process(_ data: Data, with reply: @Sendable @escaping (Data?, (any Error)?) -> Void) {
_process(data, reply)
}
}
extension NSError {
private enum Constants {
static let domain = "com.maxgoedjen.secretive.xpcwrappers"
static let code = -1
static let dataKey = "underlying"
}
@nonobjc convenience init<ErrorType: Codable & Error>(_ error: ErrorType) {
let encoded = try? JSONEncoder().encode(error)
self.init(domain: Constants.domain, code: Constants.code, userInfo: [Constants.dataKey: encoded as Any])
}
@nonobjc public func underlying<ErrorType: Codable & Error>(as errorType: ErrorType.Type) -> ErrorType? {
guard domain == Constants.domain && code == Constants.code, let data = userInfo[Constants.dataKey] as? Data else { return nil }
return try? JSONDecoder().decode(ErrorType.self, from: data)
}
}

View File

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

View File

@ -1,54 +1,65 @@
import XCTest import Testing
import Foundation
@testable import Brief @testable import Brief
class ReleaseParsingTests: XCTestCase { @Suite struct ReleaseParsingTests {
func testNonCritical() { @Test
func nonCritical() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release")
XCTAssert(release.critical == false) #expect(release.critical == false)
} }
func testCritical() { @Test
func critical() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
XCTAssert(release.critical == true) #expect(release.critical == true)
} }
func testOSMissing() { @Test
func osMissing() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
XCTAssert(release.minimumOSVersion == SemVer("11.0.0")) #expect(release.minimumOSVersion == SemVer("11.0.0"))
} }
func testOSPresentWithContentBelow() { @Test
func osPresentWithContentBelow() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update ##Minimum macOS Version\n1.2.3\nBuild info") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update ##Minimum macOS Version\n1.2.3\nBuild info")
XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) #expect(release.minimumOSVersion == SemVer("1.2.3"))
} }
func testOSPresentAtEnd() { @Test
func osPresentAtEnd() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3")
XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) #expect(release.minimumOSVersion == SemVer("1.2.3"))
} }
func testOSWithMacOSPrefix() { @Test
func osWithMacOSPrefix() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: macOS 1.2.3") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: macOS 1.2.3")
XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) #expect(release.minimumOSVersion == SemVer("1.2.3"))
} }
func testOSGreaterThanMinimum() { @Test
func osGreaterThanMinimum() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3")
XCTAssert(release.minimumOSVersion < SemVer("11.0.0")) #expect(release.minimumOSVersion < SemVer("11.0.0"))
} }
func testOSEqualToMinimum() { @Test
func osEqualToMinimum() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 11.2.3") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 11.2.3")
XCTAssert(release.minimumOSVersion <= SemVer("11.2.3")) #expect(release.minimumOSVersion <= SemVer("11.2.3"))
} }
func testOSLessThanMinimum() { @Test
func osLessThanMinimum() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3") let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3")
XCTAssert(release.minimumOSVersion > SemVer("1.0.0")) #expect(release.minimumOSVersion > SemVer("1.0.0"))
} }
func testGreatestSelectedIfOldPatchIsPublishedLater() { @Test
@MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws {
// If 2.x.x series has been published, and a patch for 1.x.x is issued // If 2.x.x series has been published, and a patch for 1.x.x is issued
// 2.x.x should still be selected if user can run it. // 2.x.x should still be selected if user can run it.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0")) let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
@ -60,16 +71,13 @@ class ReleaseParsingTests: XCTestCase {
Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"),
] ]
let expectation = XCTestExpectation() await updater.evaluate(releases: releases)
updater.evaluate(releases: releases) try await Task.sleep(nanoseconds: 1)
DispatchQueue.main.async { #expect(updater.update == two)
XCTAssert(updater.update == two)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
} }
func testLatestVersionIsRunnable() { @Test
@MainActor func latestVersionIsRunnable() async throws {
// If the 2.x.x series has been published but the user can't run it // If the 2.x.x series has been published but the user can't run it
// the last version the user can run should be selected. // the last version the user can run should be selected.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0")) let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))
@ -80,16 +88,13 @@ class ReleaseParsingTests: XCTestCase {
Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3"), Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3"),
Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"),
] ]
let expectation = XCTestExpectation() await updater.evaluate(releases: releases)
updater.evaluate(releases: releases) try await Task.sleep(nanoseconds: 1)
DispatchQueue.main.async { #expect(updater.update == oneOhTwo)
XCTAssert(updater.update == oneOhTwo)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
} }
func testSorting() { @Test
func sorting() {
let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available!") let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available!")
let releases = [ let releases = [
Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release"), Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release"),
@ -98,7 +103,7 @@ class ReleaseParsingTests: XCTestCase {
Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch!"), Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch!"),
] ]
let sorted = releases.sorted().reversed().first let sorted = releases.sorted().reversed().first
XCTAssert(sorted == two) #expect(sorted == two)
} }
} }

View File

@ -1,51 +1,52 @@
import XCTest import Testing
import Foundation
@testable import Brief @testable import Brief
class SemVerTests: XCTestCase { @Suite struct SemVerTests {
func testEqual() { @Test func equal() {
let current = SemVer("1.0.2") let current = SemVer("1.0.2")
let old = SemVer("1.0.2") let old = SemVer("1.0.2")
XCTAssert(!(current > old)) #expect(!(current > old))
} }
func testPatchGreaterButMinorLess() { @Test func patchGreaterButMinorLess() {
let current = SemVer("1.1.0") let current = SemVer("1.1.0")
let old = SemVer("1.0.2") let old = SemVer("1.0.2")
XCTAssert(current > old) #expect(current > old)
} }
func testMajorSameMinorGreater() { @Test func majorSameMinorGreater() {
let current = SemVer("1.0.2") let current = SemVer("1.0.2")
let new = SemVer("1.0.3") let new = SemVer("1.0.3")
XCTAssert(current < new) #expect(current < new)
} }
func testMajorGreaterMinorLesser() { @Test func majorGreaterMinorLesser() {
let current = SemVer("1.0.2") let current = SemVer("1.0.2")
let new = SemVer("2.0.0") let new = SemVer("2.0.0")
XCTAssert(current < new) #expect(current < new)
} }
func testRegularParsing() { @Test func regularParsing() {
let current = SemVer("1.0.2") let current = SemVer("1.0.2")
XCTAssert(current.versionNumbers == [1, 0, 2]) #expect(current.versionNumbers == [1, 0, 2])
} }
func testNoPatch() { @Test func noPatch() {
let current = SemVer("1.1") let current = SemVer("1.1")
XCTAssert(current.versionNumbers == [1, 1, 0]) #expect(current.versionNumbers == [1, 1, 0])
} }
func testGarbage() { @Test func garbage() {
let current = SemVer("Test") let current = SemVer("Test")
XCTAssert(current.versionNumbers == [0, 0, 0]) #expect(current.versionNumbers == [0, 0, 0])
} }
func testBeta() { @Test func beta() {
let current = SemVer("1.0.2") let current = SemVer("1.0.2")
let new = SemVer("1.1.0_beta1") let new = SemVer("1.1.0_beta1")
XCTAssert(current < new) #expect(current < new)
} }
} }

View File

@ -1,56 +1,59 @@
import Foundation import Foundation
import XCTest import Testing
import CryptoKit import CryptoKit
@testable import SecretKit @testable import SecretKit
@testable import SecretAgentKit @testable import SecretAgentKit
class AgentTests: XCTestCase { @Suite struct AgentTests {
let stubWriter = StubFileHandleWriter()
// MARK: Identity Listing // MARK: Identity Listing
func testEmptyStores() async { @Test func emptyStores() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let agent = Agent(storeList: SecretStoreList()) let agent = Agent(storeList: SecretStoreList())
await agent.handle(reader: stubReader, writer: stubWriter) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestIdentitiesEmpty) let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesEmpty)
} }
func testIdentitiesList() async { @Test func identitiesList() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestIdentitiesMultiple) let response = await agent.handle(request: request, provenance: .test)
let actual = OpenSSHReader(data: response)
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
print(actual, expected)
#expect(response == Constants.Responses.requestIdentitiesMultiple)
} }
// MARK: Signatures // MARK: Signatures
func testNoMatchingIdentities() async { @Test func noMatchingIdentities() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
// XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
} }
func testSignature() async { @Test func ecdsaSignature() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...]) guard case SSHAgent.Request.signRequest(let context) = request else { return }
_ = requestReader.readNextChunk() let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let dataToSign = requestReader.readNextChunk()
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) let response = await agent.handle(request: request, provenance: .test)
let outer = OpenSSHReader(data: stubWriter.data[5...]) let responseReader = OpenSSHReader(data: response)
let payload = outer.readNextChunk() let length = try responseReader.readNextBytes(as: UInt32.self).bigEndian
let inner = OpenSSHReader(data: payload) let type = try responseReader.readNextBytes(as: UInt8.self).bigEndian
_ = inner.readNextChunk() #expect(length == response.count - MemoryLayout<UInt32>.size)
let signedData = inner.readNextChunk() #expect(type == SSHAgent.Response.agentSignResponse.rawValue)
let rsData = OpenSSHReader(data: signedData) let outer = OpenSSHReader(data: responseReader.remaining)
var r = rsData.readNextChunk() let inner = try outer.readNextChunkAsSubReader()
var s = rsData.readNextChunk() _ = 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 // This is fine IRL, but it freaks out CryptoKit
if r[0] == 0 { if r[0] == 0 {
r.removeFirst() r.removeFirst()
@ -60,52 +63,42 @@ class AgentTests: XCTestCase {
} }
var rs = r var rs = r
rs.append(s) rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs) let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign) // Correct signature
let store = list.stores.first! #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
let derVerifies = try! store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret)) .isValidSignature(signature, for: context.dataToSign))
let invalidRandomSignature = try? store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomData = try? store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidWrongKey = try? store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa384Secret))
XCTAssertTrue(referenceValid)
XCTAssertTrue(derVerifies)
XCTAssert(invalidRandomSignature == false)
XCTAssert(invalidRandomData == false)
XCTAssert(invalidWrongKey == false)
} }
// MARK: Witness protocol // MARK: Witness protocol
func testWitnessObjectionStopsRequest() async { @Test func witnessObjectionStopsRequest() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
let witness = StubWitness(speakNow: { _,_ in let witness = StubWitness(speakNow: { _,_ in
return true return true
}, witness: { _, _ in }) }, witness: { _, _ in })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
} }
func testWitnessSignature() async { @Test func witnessSignature() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) nonisolated(unsafe) var witnessed = false
var witnessed = false
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
return false return false
}, witness: { _, trace in }, witness: { _, trace in
witnessed = true witnessed = true
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
XCTAssertTrue(witnessed) _ = await agent.handle(request: request, provenance: .test)
#expect(witnessed)
} }
func testRequestTracing() async { @Test func requestTracing() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
var speakNowTrace: SigningRequestProvenance! = nil nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
var witnessTrace: SigningRequestProvenance! = nil
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
speakNowTrace = trace speakNowTrace = trace
return false return false
@ -113,39 +106,43 @@ class AgentTests: XCTestCase {
witnessTrace = trace witnessTrace = trace
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
XCTAssertEqual(witnessTrace, speakNowTrace) _ = await agent.handle(request: request, provenance: .test)
XCTAssertEqual(witnessTrace.origin.displayName, "Finder") #expect(witnessTrace == speakNowTrace)
XCTAssertEqual(witnessTrace.origin.validSignature, true) #expect(witnessTrace == .test)
XCTAssertEqual(witnessTrace.origin.parentPID, 1)
} }
// MARK: Exception Handling // MARK: Exception Handling
func testSignatureException() async { @Test func signatureException() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let store = await list.stores.first?.base as! Stub.Store
let store = list.stores.first?.base as! Stub.Store
store.shouldThrow = true store.shouldThrow = true
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
} }
// MARK: Unsupported // MARK: Unsupported
func testUnhandledAdd() async { @Test func unhandledAdd() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity)
let agent = Agent(storeList: SecretStoreList()) let agent = Agent(storeList: SecretStoreList())
await agent.handle(reader: stubReader, writer: stubWriter) let response = await agent.handle(request: .addIdentity, provenance: .test)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
} }
} }
extension SigningRequestProvenance {
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
}
extension AgentTests { extension AgentTests {
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList { @MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
let store = Stub.Store() let store = Stub.Store()
store.secrets.append(contentsOf: secrets) store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList() let storeList = SecretStoreList()
@ -157,14 +154,13 @@ extension AgentTests {
enum Requests { enum Requests {
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")! 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 requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")! static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
} }
enum Responses { enum Responses {
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")! static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")! static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
static let requestFailure = Data(base64Encoded: "AAAAAQU=")! static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
} }

View File

@ -0,0 +1,27 @@
import Foundation
import Testing
@testable import SecretAgentKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHReaderTests {
@Test func signatureRequest() throws {
let reader = OpenSSHReader(data: Constants.signatureRequest)
let hash = try reader.readNextChunk()
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
let dataToSign = try reader.readNextChunk()
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
let empty = try reader.readNextChunk()
#expect(empty.isEmpty)
}
}
extension OpenSSHReaderTests {
enum Constants {
static let signatureRequest = Data(base64Encoded: "AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAADvAAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAAAA")!
}
}

View File

@ -1,14 +0,0 @@
import SecretAgentKit
import AppKit
struct StubFileHandleReader: FileHandleReader {
let availableData: Data
var fileDescriptor: Int32 {
NSWorkspace.shared.runningApplications.filter({ $0.localizedName == "Finder" }).first!.processIdentifier
}
var pidOfConnectedProcess: Int32 {
fileDescriptor
}
}

View File

@ -1,12 +0,0 @@
import Foundation
import SecretAgentKit
class StubFileHandleWriter: FileHandleWriter {
var data = Data()
func write(_ data: Data) {
self.data.append(data)
}
}

View File

@ -6,7 +6,7 @@ struct Stub {}
extension Stub { extension Stub {
public final class Store: SecretStore { public final class Store: SecretStore, @unchecked Sendable {
public let isAvailable = true public let isAvailable = true
public let id = UUID() public let id = UUID()
@ -45,43 +45,15 @@ extension Stub {
let privateData = (privateAttributes[kSecValueData] as! Data) let privateData = (privateAttributes[kSecValueData] as! Data)
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData) let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
print(secret) print(secret)
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))") print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard !shouldThrow else { guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil) throw NSError(domain: "test", code: 0, userInfo: nil)
} }
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([ let privateKey = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey)
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, return try privateKey.signature(for: data).rawRepresentation
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPrivate
])
, nil)!
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
}
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: Unmanaged<CFError>?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError)
if let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
}
return verified
} }
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
@ -102,24 +74,22 @@ extension Stub {
struct Secret: SecretKit.Secret, CustomDebugStringConvertible { struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
let id = UUID().uuidString.data(using: .utf8)! let id = Data(UUID().uuidString.utf8)
let name = UUID().uuidString let name = UUID().uuidString
let algorithm = Algorithm.ellipticCurve let attributes: Attributes
let keySize: Int
let publicKey: Data let publicKey: Data
let requiresAuthentication = false let requiresAuthentication = false
let privateKey: Data let privateKey: Data
init(keySize: Int, publicKey: Data, privateKey: Data) { init(keySize: Int, publicKey: Data, privateKey: Data) {
self.keySize = keySize self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired, publicKeyAttribution: "ecdsa-\(keySize)@example.com")
self.publicKey = publicKey self.publicKey = publicKey
self.privateKey = privateKey self.privateKey = privateKey
} }
var debugDescription: String { var debugDescription: String {
""" """
Key Size \(keySize) Key Size \(attributes.keyType.size)
Private: \(privateKey.base64EncodedString()) Private: \(privateKey.base64EncodedString())
Public: \(publicKey.base64EncodedString()) Public: \(publicKey.base64EncodedString())
""" """

View File

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

View File

@ -1,19 +1,20 @@
import Foundation import Foundation
import XCTest import Testing
@testable import SecretKit @testable import SecretKit
@testable import SecureEnclaveSecretKit @testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit @testable import SmartCardSecretKit
class AnySecretTests: XCTestCase {
func testEraser() { @Suite struct AnySecretTests {
let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!)
@Test func eraser() {
let data = Data(UUID().uuidString.utf8)
let secret = SmartCard.Secret(id: data, name: "Name", publicKey: data, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired))
let erased = AnySecret(secret) let erased = AnySecret(secret)
XCTAssert(erased.id == secret.id as AnyHashable) #expect(erased.id == secret.id as AnyHashable)
XCTAssert(erased.name == secret.name) #expect(erased.name == secret.name)
XCTAssert(erased.algorithm == secret.algorithm) #expect(erased.keyType == secret.keyType)
XCTAssert(erased.keySize == secret.keySize) #expect(erased.publicKey == secret.publicKey)
XCTAssert(erased.publicKey == secret.publicKey)
} }
} }

View File

@ -0,0 +1,55 @@
import Foundation
import Testing
@testable import SecretKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHPublicKeyWriterTests {
let writer = OpenSSHPublicKeyWriter()
@Test func ecdsa256MD5Fingerprint() {
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
}
@Test func ecdsa256SHA256Fingerprint() {
#expect(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa256Secret) == "SHA256:/VQFeGyM8qKA8rB6WGMuZZxZLJln2UgXLk3F0uTF650")
}
@Test func ecdsa256PublicKey() {
#expect(writer.openSSHString(secret: Constants.ecdsa256Secret) ==
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo= test@example.com")
}
@Test func ecdsa256Hash() {
#expect(writer.data(secret: Constants.ecdsa256Secret) == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo="))
}
@Test func ecdsa384MD5Fingerprint() {
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa384Secret) == "66:e0:66:d7:41:ed:19:8e:e2:20:df:ce:ac:7e:2b:6e")
}
@Test func ecdsa384SHA256Fingerprint() {
#expect(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa384Secret) == "SHA256:GJUEymQNL9ymaMRRJCMGY4rWIJHu/Lm8Yhao/PAiz1I")
}
@Test func ecdsa384PublicKey() {
#expect(writer.openSSHString(secret: Constants.ecdsa384Secret) ==
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ== test@example.com")
}
@Test func ecdsa384Hash() {
#expect(writer.data(secret: Constants.ecdsa384Secret) == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ=="))
}
}
extension OpenSSHPublicKeyWriterTests {
enum Constants {
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
}
}

View File

@ -1,27 +0,0 @@
import Foundation
import XCTest
@testable import SecretKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
class OpenSSHReaderTests: XCTestCase {
func testSignatureRequest() {
let reader = OpenSSHReader(data: Constants.signatureRequest)
let hash = reader.readNextChunk()
XCTAssert(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
let dataToSign = reader.readNextChunk()
XCTAssert(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
let empty = reader.readNextChunk()
XCTAssert(empty.isEmpty)
}
}
extension OpenSSHReaderTests {
enum Constants {
static let signatureRequest = Data(base64Encoded: "AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAADvAAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAAAA")!
}
}

View File

@ -1,55 +0,0 @@
import Foundation
import XCTest
@testable import SecretKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
class OpenSSHWriterTests: XCTestCase {
let writer = OpenSSHKeyWriter()
func testECDSA256MD5Fingerprint() {
XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret), "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
}
func testECDSA256SHA256Fingerprint() {
XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa256Secret), "SHA256:/VQFeGyM8qKA8rB6WGMuZZxZLJln2UgXLk3F0uTF650")
}
func testECDSA256PublicKey() {
XCTAssertEqual(writer.openSSHString(secret: Constants.ecdsa256Secret),
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")
}
func testECDSA256Hash() {
XCTAssertEqual(writer.data(secret: Constants.ecdsa256Secret), Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo="))
}
func testECDSA384MD5Fingerprint() {
XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa384Secret), "66:e0:66:d7:41:ed:19:8e:e2:20:df:ce:ac:7e:2b:6e")
}
func testECDSA384SHA256Fingerprint() {
XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa384Secret), "SHA256:GJUEymQNL9ymaMRRJCMGY4rWIJHu/Lm8Yhao/PAiz1I")
}
func testECDSA384PublicKey() {
XCTAssertEqual(writer.openSSHString(secret: Constants.ecdsa384Secret),
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")
}
func testECDSA384Hash() {
XCTAssertEqual(writer.data(secret: Constants.ecdsa384Secret), Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ=="))
}
}
extension OpenSSHWriterTests {
enum Constants {
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", algorithm: .ellipticCurve, keySize: 256, publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!)
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!)
}
}

View File

@ -1,24 +1,27 @@
import Cocoa import Cocoa
import OSLog import OSLog
import Combine
import SecretKit import SecretKit
import SecureEnclaveSecretKit import SecureEnclaveSecretKit
import SmartCardSecretKit import SmartCardSecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
import Observation
@NSApplicationMain @main
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
private let storeList: SecretStoreList = { @MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
list.add(store: SecureEnclave.Store()) let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()
private let updater = Updater(checkOnLaunch: false) private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier() private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private lazy var agent: Agent = { private lazy var agent: Agent = {
Agent(storeList: storeList, witness: notifier) Agent(storeList: storeList, witness: notifier)
}() }()
@ -26,22 +29,42 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String
return SocketController(path: path) return SocketController(path: path)
}() }()
private var updateSink: AnyCancellable?
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
func applicationDidFinishLaunching(_ aNotification: Notification) { func applicationDidFinishLaunching(_ aNotification: Notification) {
logger.debug("SecretAgent finished launching") logger.debug("SecretAgent finished launching")
DispatchQueue.main.async { Task {
self.socketController.handler = self.agent.handle(reader:writer:) let inputParser = try await XPCAgentInputParser()
for await session in socketController.sessions {
Task {
do {
for await message in session.messages {
let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance)
try await session.write(agentResponse)
} }
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in } catch {
try session.close()
}
}
}
}
Task {
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
} }
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
notifier.prompt() notifier.prompt()
updateSink = updater.$update.sink { update in _ = withObservationTracking {
guard let update = update else { return } updater.update
self.notifier.notify(update: update, ignore: self.updater.ignore(release:)) } onChange: { [updater, notifier] in
Task {
guard !updater.currentVersion.isTestBuild else { return }
await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release)
}
}
} }
} }

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

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

View File

@ -9,22 +9,7 @@
<key>Website</key> <key>Website</key>
<string>https://github.com/maxgoedjen/secretive</string> <string>https://github.com/maxgoedjen/secretive</string>
<key>Connections</key> <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> <key>Services</key>
<array/> <array/>
</dict> </dict>

View File

@ -5,13 +5,13 @@ import SecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
class Notifier { final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate() private let notificationDelegate = NotificationDelegate()
init() { init() {
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: "update_notification_update_button"), options: []) let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: .updateNotificationUpdateButton), options: [])
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: "update_notification_ignore_button"), options: []) let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: .updateNotificationIgnoreButton), options: [])
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: []) let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: []) let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
@ -22,32 +22,35 @@ class Notifier {
Measurement(value: 24, unit: UnitDuration.hours) Measurement(value: 24, unit: UnitDuration.hours)
] ]
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: "persist_authentication_decline_button"), options: []) let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
var allPersistenceActions = [doNotPersistAction] var allPersistenceActions = [doNotPersistAction]
let formatter = DateComponentsFormatter() let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day] formatter.allowedUnits = [.hour, .minute, .day]
var identifiers: [String: TimeInterval] = [:]
for duration in rawDurations { for duration in rawDurations {
let seconds = duration.converted(to: .seconds).value let seconds = duration.converted(to: .seconds).value
guard let string = formatter.string(from: seconds)?.capitalized else { continue } guard let string = formatter.string(from: seconds)?.capitalized else { continue }
let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)") let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)")
let action = UNNotificationAction(identifier: identifier, title: string, options: []) let action = UNNotificationAction(identifier: identifier, title: string, options: [])
notificationDelegate.persistOptions[identifier] = seconds identifiers[identifier] = seconds
allPersistenceActions.append(action) allPersistenceActions.append(action)
} }
let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: []) let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: [])
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) { if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
persistAuthenticationCategory.setValue(String(localized: "persist_authentication_accept_button"), forKey: "_actionsMenuTitle") persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
} }
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate UNUserNotificationCenter.current().delegate = notificationDelegate
notificationDelegate.persistAuthentication = { secret, store, duration in Task {
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
guard let duration = duration else { return } guard let duration = duration else { return }
try? store.persistAuthentication(secret: secret, forDuration: duration) try? await store.persistAuthentication(secret: secret, forDuration: duration)
}
} }
} }
@ -57,53 +60,51 @@ class Notifier {
notificationCenter.requestAuthorization(options: .alert) { _, _ in } notificationCenter.requestAuthorization(options: .alert) { _, _ in }
} }
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) { func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
notificationDelegate.pendingPersistableSecrets[secret.id.description] = secret await notificationDelegate.state.setPending(secret: secret, store: store)
notificationDelegate.pendingPersistableStores[store.id.description] = store
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)") notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
notificationContent.subtitle = String(localized: "signed_notification_description_\(secret.name)") notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name))
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive notificationContent.interruptionLevel = .timeSensitive
if secret.requiresAuthentication && store.existingPersistedAuthenticationContext(secret: secret) == nil { if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
} }
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
notificationContent.attachments = [attachment] notificationContent.attachments = [attachment]
} }
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
notificationCenter.add(request, withCompletionHandler: nil) try? await notificationCenter.add(request)
} }
func notify(update: Release, ignore: ((Release) -> Void)?) { func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
notificationDelegate.release = update await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
notificationDelegate.ignore = ignore
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
if update.critical { if update.critical {
notificationContent.interruptionLevel = .critical notificationContent.interruptionLevel = .critical
notificationContent.title = String(localized: "update_notification_update_critical_title_\(update.name)") notificationContent.title = String(localized: .updateNotificationUpdateCriticalTitle(updateName: update.name))
} else { } else {
notificationContent.title = String(localized: "update_notification_update_normal_title_\(update.name)") notificationContent.title = String(localized: .updateNotificationUpdateNormalTitle(updateName: update.name))
} }
notificationContent.subtitle = String(localized: "update_notification_update_description") notificationContent.subtitle = String(localized: .updateNotificationUpdateDescription)
notificationContent.body = update.body notificationContent.body = update.body
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
notificationCenter.add(request, withCompletionHandler: nil) try? await notificationCenter.add(request)
} }
} }
extension Notifier: SigningWitness { extension Notifier: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) 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) throws { func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
notify(accessTo: secret, from: store, by: provenance) await notify(accessTo: secret, from: store, by: provenance)
} }
} }
@ -129,55 +130,91 @@ extension Notifier {
} }
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { 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 release: Release?
fileprivate var ignore: ((Release) -> Void)? fileprivate var ignoreAction: IgnoreAction?
fileprivate var persistAuthentication: ((AnySecret, AnySecretStore, TimeInterval?) -> Void)? fileprivate var persistAction: PersistAction?
fileprivate var persistOptions: [String: TimeInterval] = [:] fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:] fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
func setPending(secret: AnySecret, store: AnySecretStore) {
pendingPersistableSecrets[secret.id.description] = secret
pendingPersistableStores[store.id.description] = store
}
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
guard let secret = pendingPersistableSecrets[secretID],
let store = pendingPersistableStores[storeID],
let options = persistOptions[optionID] else {
return nil
}
pendingPersistableSecrets.removeValue(forKey: secretID)
return (secret, store, options)
}
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
self.persistOptions = options
self.persistAction = action
}
func prepareForNotification(release: Release, ignoreAction: IgnoreAction?) {
self.release = release
self.ignoreAction = ignoreAction
}
}
fileprivate let state = State()
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
} }
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
let category = response.notification.request.content.categoryIdentifier let category = response.notification.request.content.categoryIdentifier
switch category { switch category {
case Notifier.Constants.updateCategoryIdentitifier: case Notifier.Constants.updateCategoryIdentitifier:
handleUpdateResponse(response: response) await handleUpdateResponse(response: response)
case Notifier.Constants.persistAuthenticationCategoryIdentitifier: case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
handlePersistAuthenticationResponse(response: response) await handlePersistAuthenticationResponse(response: response)
default: default:
break break
} }
completionHandler()
} }
func handleUpdateResponse(response: UNNotificationResponse) { func handleUpdateResponse(response: UNNotificationResponse) async {
guard let update = release else { return } let id = response.actionIdentifier
switch response.actionIdentifier { guard let update = await state.release else { return }
switch id {
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
NSWorkspace.shared.open(update.html_url) NSWorkspace.shared.open(update.html_url)
case Notifier.Constants.ignoreActionIdentitifier: case Notifier.Constants.ignoreActionIdentitifier:
ignore?(update) await state.ignoreAction?(update)
default: default:
fatalError() fatalError()
} }
} }
func handlePersistAuthenticationResponse(response: UNNotificationResponse) { func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, let secret = pendingPersistableSecrets[secretID], 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, let store = pendingPersistableStores[storeID] let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
else { return } return
pendingPersistableSecrets[secretID] = nil }
persistAuthentication?(secret, store, persistOptions[response.actionIdentifier]) let optionID = response.actionIdentifier
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
await state.persistAction?(secret, store, persistOptions)
} }
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.list, .banner]) func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
[.list, .banner]
} }
} }

View File

@ -2,10 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.smartcard</key> <key>com.apple.security.smartcard</key>
<true/> <true/>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>

View File

@ -0,0 +1,23 @@
import Foundation
import SecretAgentKit
import Brief
import XPCWrappers
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
private let session: XPCTypedSession<SSHAgent.Request, SSHAgentInputParser.AgentParsingError>
public init() async throws {
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentInputParser", warmup: true)
}
public func parse(data: Data) async throws -> SSHAgent.Request {
try await session.send(data)
}
deinit {
session.complete()
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>XPCService</key>
<dict>
<key>ServiceType</key>
<string>Application</string>
</dict>
</dict>
</plist>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>iOSPackagesShouldBuildARM64e</key>
<true/>
</dict>
</plist>

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1320" LastUpgradeVersion = "2600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -89,7 +89,7 @@
</BuildableProductRunnable> </BuildableProductRunnable>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Debug"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = "" savedToolIdentifier = ""
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"

View File

@ -1,32 +1,52 @@
import Cocoa
import SwiftUI import SwiftUI
import SecretKit import SecretKit
import SecureEnclaveSecretKit import SecureEnclaveSecretKit
import SmartCardSecretKit import SmartCardSecretKit
import Brief import Brief
@main extension EnvironmentValues {
struct Secretive: App {
private let storeList: SecretStoreList = { // This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
list.add(store: SecureEnclave.Store()) let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()
private let agentStatusChecker = AgentStatusChecker()
private let justUpdatedChecker = JustUpdatedChecker()
private static let _agentStatusChecker = AgentStatusChecker()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
private static let _updater: any UpdaterProtocol = {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
return Updater(checkOnLaunch: hasRunSetup)
}()
@Entry var updater: any UpdaterProtocol = _updater
private static let _justUpdatedChecker = JustUpdatedChecker()
@Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker
@MainActor var secretStoreList: SecretStoreList {
EnvironmentValues._secretStoreList
}
}
@main
struct Secretive: App {
@Environment(\.agentStatusChecker) var agentStatusChecker
@Environment(\.justUpdatedChecker) var justUpdatedChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false @AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false @State private var showingSetup = false
@State private var showingIntegrations = false
@State private var showingCreation = false @State private var showingCreation = false
@SceneBuilder var body: some Scene { @SceneBuilder var body: some Scene {
WindowGroup { WindowGroup {
ContentView<Updater, AgentStatusChecker>(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup) ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environmentObject(storeList) .environment(EnvironmentValues._secretStoreList)
.environmentObject(Updater(checkOnLaunch: hasRunSetup))
.environmentObject(agentStatusChecker)
.onAppear { .onAppear {
if !hasRunSetup { if !hasRunSetup {
showingSetup = true showingSetup = true
@ -35,31 +55,34 @@ struct Secretive: App {
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
guard hasRunSetup else { return } guard hasRunSetup else { return }
agentStatusChecker.check() agentStatusChecker.check()
if agentStatusChecker.running && justUpdatedChecker.justUpdated { if agentStatusChecker.running && justUpdatedChecker.justUpdatedBuild {
// Relaunch the agent, since it'll be running from earlier update still // Relaunch the agent, since it'll be running from earlier update still
reinstallAgent() reinstallAgent()
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild { } else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
forceLaunchAgent() forceLaunchAgent()
} }
} }
.sheet(isPresented: $showingIntegrations) {
IntegrationsView()
}
} }
.commands { .commands {
CommandGroup(before: CommandGroupPlacement.appSettings) {
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
showingIntegrations = true
}
}
CommandGroup(after: CommandGroupPlacement.newItem) { CommandGroup(after: CommandGroupPlacement.newItem) {
Button("app_menu_new_secret_button") { Button(.appMenuNewSecretButton) {
showingCreation = true showingCreation = true
} }
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift])) .keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
} }
CommandGroup(replacing: .help) { CommandGroup(replacing: .help) {
Button("app_menu_help_button") { Button(.appMenuHelpButton) {
NSWorkspace.shared.open(Constants.helpURL) NSWorkspace.shared.open(Constants.helpURL)
} }
} }
CommandGroup(after: .help) {
Button("app_menu_setup_button") {
showingSetup = true
}
}
SidebarCommands() SidebarCommands()
} }
} }
@ -69,22 +92,21 @@ struct Secretive: App {
extension Secretive { extension Secretive {
private func reinstallAgent() { private func reinstallAgent() {
justUpdatedChecker.check() Task {
LaunchAgentController().install { _ = await LaunchAgentController().install()
// Wait a second for launchd to kick in (next runloop isn't enough). try? await Task.sleep(for: .seconds(1))
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
agentStatusChecker.check() agentStatusChecker.check()
if !agentStatusChecker.running { if !agentStatusChecker.running {
forceLaunchAgent() forceLaunchAgent()
} }
} }
} }
}
private func forceLaunchAgent() { private func forceLaunchAgent() {
// We've run setup, we didn't just update, launchd is just not doing it's thing. // We've run setup, we didn't just update, launchd is just not doing it's thing.
// Force a launch directly. // Force a launch directly.
LaunchAgentController().forceLaunch { _ in Task {
_ = await LaunchAgentController().forceLaunch()
agentStatusChecker.check() agentStatusChecker.check()
} }
} }

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

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