Compare commits
2 Commits
sshextensi
...
extensions
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f1f83113 | ||
|
|
3e128d2a81 |
BIN
.github/readme/app-dark.png
vendored
|
Before Width: | Height: | Size: 668 KiB After Width: | Height: | Size: 520 KiB |
BIN
.github/readme/app-light.png
vendored
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 519 KiB |
BIN
.github/readme/localize_add.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
.github/readme/localize_sidebar.png
vendored
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
.github/readme/localize_translate.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
.github/readme/notification.png
vendored
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 1.4 MiB |
BIN
.github/readme/touchid.png
vendored
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 259 KiB |
47
.github/workflows/codeql.yml
vendored
@@ -1,47 +0,0 @@
|
|||||||
name: "CodeQL Advanced"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
schedule:
|
|
||||||
- cron: '26 15 * * 3'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze (${{ matrix.language }})
|
|
||||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }}
|
|
||||||
permissions:
|
|
||||||
security-events: write
|
|
||||||
packages: read
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- language: actions
|
|
||||||
build-mode: none
|
|
||||||
# Disable this until CodeQL supports Xcode 26 builds.
|
|
||||||
# - language: swift
|
|
||||||
# build-mode: manual
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v3
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
build-mode: ${{ matrix.build-mode }}
|
|
||||||
- if: matrix.build-mode == 'manual'
|
|
||||||
name: "Select Xcode"
|
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
|
||||||
- if: matrix.build-mode == 'manual'
|
|
||||||
name: "Build"
|
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v3
|
|
||||||
with:
|
|
||||||
category: "/language:${{matrix.language}}"
|
|
||||||
42
.github/workflows/nightly.yml
vendored
@@ -3,15 +3,10 @@ name: Nightly
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 8 * * *"
|
- cron: "0 8 * * *"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-26
|
# runs-on: macOS-latest
|
||||||
permissions:
|
runs-on: macos-15
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
attestations: write
|
|
||||||
actions: read
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -25,33 +20,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_26.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: |
|
||||||
DATE=$(date "+%Y-%m-%d")
|
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig
|
||||||
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/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
||||||
- name: Build
|
- 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: Move to Artifact Folder
|
- name: Create ZIPs
|
||||||
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
|
||||||
- name: Upload App to Artifacts
|
|
||||||
id: upload
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Secretive
|
|
||||||
path: Artifact
|
|
||||||
- name: Download Zipped Artifact
|
|
||||||
id: download
|
|
||||||
env:
|
|
||||||
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
|
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
||||||
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > 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 }}
|
||||||
@@ -61,5 +43,9 @@ jobs:
|
|||||||
id: attest
|
id: attest
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-name: "Secretive.zip"
|
subject-path: 'Secretive.zip'
|
||||||
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
- name: Upload App to Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: Secretive.zip
|
||||||
|
path: Secretive.zip
|
||||||
|
|||||||
64
.github/workflows/oneoff.yml
vendored
@@ -1,64 +0,0 @@
|
|||||||
name: One-Off Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: macos-26
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
attestations: write
|
|
||||||
actions: read
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- name: Setup Signing
|
|
||||||
env:
|
|
||||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
|
||||||
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
|
|
||||||
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
|
|
||||||
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
|
|
||||||
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
|
||||||
run: ./.github/scripts/signing.sh
|
|
||||||
- name: Set Environment
|
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
|
||||||
- name: Update Build Number
|
|
||||||
env:
|
|
||||||
RUN_ID: ${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
DATE=$(date "+%Y-%m-%d")
|
|
||||||
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_oneoff-$DATE/g" Sources/Config/Config.xcconfig
|
|
||||||
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
|
||||||
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
|
||||||
- name: Build
|
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
|
||||||
- name: Move to Artifact Folder
|
|
||||||
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
|
||||||
- name: Upload App to Artifacts
|
|
||||||
id: upload
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Secretive
|
|
||||||
path: Artifact
|
|
||||||
- name: Download Zipped Artifact
|
|
||||||
id: download
|
|
||||||
env:
|
|
||||||
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
|
|
||||||
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
|
|
||||||
- name: Notarize
|
|
||||||
env:
|
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
|
||||||
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
|
||||||
- name: Attest
|
|
||||||
id: attest
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-name: "Secretive.zip"
|
|
||||||
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
|
||||||
60
.github/workflows/release.yml
vendored
@@ -6,9 +6,8 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
permissions:
|
# runs-on: macOS-latest
|
||||||
contents: read
|
runs-on: macos-15
|
||||||
runs-on: macos-26
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -22,18 +21,16 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
run: swift test --build-system swiftbuild --package-path Sources/Packages
|
||||||
# SPM doesn't seem to pick up on the tests currently?
|
|
||||||
# run: swift test --build-system swiftbuild --package-path Sources/Packages
|
|
||||||
build:
|
build:
|
||||||
|
# runs-on: macOS-latest
|
||||||
|
runs-on: macos-15
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
attestations: write
|
attestations: write
|
||||||
actions: read
|
|
||||||
runs-on: macos-26
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -47,7 +44,7 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.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 }}
|
||||||
@@ -56,25 +53,13 @@ jobs:
|
|||||||
export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//')
|
export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//')
|
||||||
sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_URL/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
||||||
- name: Build
|
- 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: Move to Artifact Folder
|
- name: Create ZIPs
|
||||||
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
|
||||||
- name: Upload App to Artifacts
|
|
||||||
id: upload
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Secretive.zip
|
|
||||||
path: Artifact
|
|
||||||
- name: Download Zipped Artifact
|
|
||||||
id: download
|
|
||||||
env:
|
|
||||||
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
|
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
||||||
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
|
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Xcode_Archive.zip
|
||||||
- name: Notarize
|
- name: Notarize
|
||||||
env:
|
env:
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
@@ -84,15 +69,26 @@ jobs:
|
|||||||
id: attest
|
id: attest
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-path: "Secretive.zip"
|
subject-path: 'Secretive.zip, Xcode_Archive.zip'
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
run: |
|
||||||
|
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
|
||||||
|
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
|
||||||
|
gh release create $TAG_NAME -d -F .github/templates/release.md
|
||||||
|
gh release upload Secretive.zip
|
||||||
|
gh release upload Xcode_Archive.zip
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG_NAME: ${{ github.ref }}
|
TAG_NAME: ${{ github.ref }}
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
|
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
|
||||||
run: |
|
- name: Upload App to Artifacts
|
||||||
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
|
uses: actions/upload-artifact@v4
|
||||||
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
|
with:
|
||||||
gh release create $TAG_NAME -d -F .github/templates/release.md
|
name: Secretive.zip
|
||||||
gh release upload $TAG_NAME Secretive.zip
|
path: Secretive.zip
|
||||||
|
- name: Upload Archive to Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: Xcode_Archive.zip
|
||||||
|
path: Xcode_Archive.zip
|
||||||
|
|||||||
11
.github/workflows/test.yml
vendored
@@ -3,17 +3,14 @@ name: Test
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
permissions:
|
# runs-on: macOS-latest
|
||||||
contents: read
|
runs-on: macos-15
|
||||||
runs-on: macos-26
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
||||||
- name: Test Main Packages
|
- name: Test Main Packages
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
run: swift test --build-system swiftbuild --package-path Sources/Packages
|
||||||
# SPM doesn't seem to pick up on the tests currently?
|
|
||||||
# run: swift test --build-system swiftbuild --package-path Sources/Packages
|
|
||||||
- name: Test SecretKit Packages
|
- name: Test SecretKit Packages
|
||||||
run: swift test --build-system swiftbuild
|
run: swift test --build-system swiftbuild
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -93,7 +93,3 @@ iOSInjectionProject/
|
|||||||
Archive.xcarchive
|
Archive.xcarchive
|
||||||
.DS_Store
|
.DS_Store
|
||||||
contents.xcworkspacedata
|
contents.xcworkspacedata
|
||||||
|
|
||||||
# Per-User Configs
|
|
||||||
|
|
||||||
Sources/Config/OpenSource.xcconfig
|
|
||||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
@@ -2,35 +2,36 @@
|
|||||||
|
|
||||||
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
|
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
|
||||||
|
|
||||||
## Crowdin
|
## Getting Started
|
||||||
|
|
||||||
[Secretive uses Crowdin for localization](https://crowdin.com/project/secretive/). Open the link and select your language to translate!
|
### Download Xcode
|
||||||
|
|
||||||
### Manual Translation
|
Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/).
|
||||||
|
|
||||||
Crowdin is the easiest way to translate Secretive, but I'm happy to accept Pull Requests directly as well.
|
### Clone Secretive
|
||||||
|
|
||||||
|
Clone Secretive using [these instructions from GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository).
|
||||||
|
|
||||||
|
### Open Secretive
|
||||||
|
|
||||||
|
Open [Sources/Secretive.xcodeproj](Sources/Secretive.xcodeproj) in Xcode.
|
||||||
|
|
||||||
|
### Translate
|
||||||
|
|
||||||
|
Navigate to [Secretive/Localizable](Sources/Secretive/Localizable.xcstrings).
|
||||||
|
|
||||||
|
<img src="/.github/readme/localize_sidebar.png" alt="Screenshot of Xcode navigating to the Localizable file" width="300">
|
||||||
|
|
||||||
|
If your language already has an in-progress localization, select it from the list. If it isn't there, hit the "+" button and choose your language from the list.
|
||||||
|
|
||||||
|
<img src="/.github/readme/localize_add.png" alt="Screenshot of Xcode adding a new language" width="600">
|
||||||
|
|
||||||
|
Start translating! You'll see a list of english phrases, and a space to add a translation of your language.
|
||||||
|
|
||||||
|
### Create a Pull Request
|
||||||
|
|
||||||
|
Push your changes and open a pull request.
|
||||||
|
|
||||||
### Questions
|
### 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).
|
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ let package = Package(
|
|||||||
.library(
|
.library(
|
||||||
name: "SmartCardSecretKit",
|
name: "SmartCardSecretKit",
|
||||||
targets: ["SmartCardSecretKit"]),
|
targets: ["SmartCardSecretKit"]),
|
||||||
.library(
|
|
||||||
name: "SSHProtocolKit",
|
|
||||||
targets: ["SSHProtocolKit"]),
|
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
@@ -56,24 +53,11 @@ let package = Package(
|
|||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "SSHProtocolKit",
|
|
||||||
dependencies: ["SecretKit"],
|
|
||||||
path: "Sources/Packages/Sources/SSHProtocolKit",
|
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings,
|
|
||||||
),
|
|
||||||
.testTarget(
|
|
||||||
name: "SSHProtocolKitTests",
|
|
||||||
dependencies: ["SSHProtocolKit"],
|
|
||||||
path: "Sources/Packages/Tests/SSHProtocolKitTests",
|
|
||||||
swiftSettings: swiftSettings,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
var localization: Resource {
|
var localization: Resource {
|
||||||
.process("../../Resources/Localizable.xcstrings")
|
.process("../../Localizable.xcstrings")
|
||||||
}
|
}
|
||||||
|
|
||||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||||
|
|||||||
20
README.md
@@ -1,11 +1,11 @@
|
|||||||
# Secretive [](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) 
|
# Secretive [](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) 
|
||||||
|
|
||||||
|
|
||||||
Secretive is an app for protecting and managing SSH keys with the Secure Enclave.
|
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="/.github/readme/app-light.png">
|
<img src="/.github/readme/app-light.png" alt="Screenshot of Secretive" width="600">
|
||||||
<img src="/.github/readme/app-dark.png" alt="Screenshot of Secretive" width="600">
|
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Secretive is an app for protecting and managing SSH keys with the Secure Enclave
|
|||||||
|
|
||||||
### Safer Storage
|
### Safer Storage
|
||||||
|
|
||||||
The most common setup for SSH keys is just keeping them on disk, guarded by proper permissions. This is fine in most cases, but it's not super hard for malicious users or malware to copy your private key. If you protect your keys with the Secure Enclave, it's impossible to export them, by design.
|
The most common setup for SSH keys is just keeping them on disk, guarded by proper permissions. This is fine in most cases, but it's not super hard for malicious users or malware to copy your private key. If you store your keys in the Secure Enclave, it's impossible to export them, by design.
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Builds are produced by GitHub Actions with an auditable build and release genera
|
|||||||
|
|
||||||
### A Note Around Code Signing and Keychains
|
### A Note Around Code Signing and Keychains
|
||||||
|
|
||||||
While Secretive uses the Secure Enclave to protect keys, it still relies on Keychain APIs to store and access them. Keychain restricts reads of keys to the app (and specifically, the bundle ID) that created them. If you build Secretive from source, make sure you are consistent in which bundle ID you use so that the Keychain is able to locate your keys.
|
While Secretive uses the Secure Enclave for key storage, it still relies on Keychain APIs to access them. Keychain restricts reads of keys to the app (and specifically, the bundle ID) that created them. If you build Secretive from source, make sure you are consistent in which bundle ID you use so that the Keychain is able to locate your keys.
|
||||||
|
|
||||||
### Backups and Transfers to New Machines
|
### Backups and Transfers to New Machines
|
||||||
|
|
||||||
@@ -61,12 +61,4 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
|
|||||||
|
|
||||||
## Security
|
## 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)
|
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."
|
||||||
|
|
||||||
## Acknowledgements
|
|
||||||
|
|
||||||
### sekey
|
|
||||||
Secretive was inspired by the [sekey project](https://github.com/sekey/sekey).
|
|
||||||
|
|
||||||
### Localization
|
|
||||||
Secretive is localized to many languages by a generous team of volunteers. To learn more, see [LOCALIZING.md](LOCALIZING.md). Secretive's localization workflow is generously provided by [Crowdin](https://crowdin.com).
|
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ The latest version on the [Releases page](https://github.com/maxgoedjen/secretiv
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
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)
|
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY."
|
||||||
|
|||||||
@@ -1,8 +1,2 @@
|
|||||||
CI_VERSION = GITHUB_CI_VERSION
|
CI_VERSION = GITHUB_CI_VERSION
|
||||||
CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER
|
CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER
|
||||||
CI_BUILD_LINK = GITHUB_BUILD_URL
|
|
||||||
|
|
||||||
#include? "OpenSource.xcconfig"
|
|
||||||
|
|
||||||
SECRETIVE_BASE_BUNDLE_ID = $(SECRETIVE_BASE_BUNDLE_ID_OSS:default=com.maxgoedjen.Secretive)
|
|
||||||
SECRETIVE_DEVELOPMENT_TEAM = $(SECRETIVE_DEVELOPMENT_TEAM_OSS:default=Z72PRUAWF6)
|
|
||||||
|
|||||||
@@ -13,24 +13,12 @@
|
|||||||
},
|
},
|
||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
|
"parallelizable" : true,
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Packages",
|
"containerPath" : "container:Secretive.xcodeproj",
|
||||||
"identifier" : "BriefTests",
|
"identifier" : "50617D9323FCE48E0099B055",
|
||||||
"name" : "BriefTests"
|
"name" : "SecretiveTests"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target" : {
|
|
||||||
"containerPath" : "container:Packages",
|
|
||||||
"identifier" : "SecretKitTests",
|
|
||||||
"name" : "SecretKitTests"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target" : {
|
|
||||||
"containerPath" : "container:Packages",
|
|
||||||
"identifier" : "SecretAgentKitTests",
|
|
||||||
"name" : "SecretAgentKitTests"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
6141
Sources/Packages/Localizable.xcstrings
Normal file
@@ -23,17 +23,11 @@ let package = Package(
|
|||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
targets: ["SecretAgentKit"]),
|
targets: ["SecretAgentKit"]),
|
||||||
.library(
|
.library(
|
||||||
name: "Common",
|
name: "SecretAgentKitHeaders",
|
||||||
targets: ["Common"]),
|
targets: ["SecretAgentKitHeaders"]),
|
||||||
.library(
|
.library(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
targets: ["Brief"]),
|
targets: ["Brief"]),
|
||||||
.library(
|
|
||||||
name: "XPCWrappers",
|
|
||||||
targets: ["XPCWrappers"]),
|
|
||||||
.library(
|
|
||||||
name: "SSHProtocolKit",
|
|
||||||
targets: ["SSHProtocolKit"]),
|
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
@@ -46,7 +40,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SecretKitTests",
|
name: "SecretKitTests",
|
||||||
dependencies: ["SecretKit", "SecretAgentKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
@@ -63,34 +57,20 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
dependencies: ["SecretKit", "SSHProtocolKit", "Common"],
|
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
|
||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
|
.systemLibrary(
|
||||||
|
name: "SecretAgentKitHeaders",
|
||||||
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SecretAgentKitTests",
|
name: "SecretAgentKitTests",
|
||||||
dependencies: ["SecretAgentKit"],
|
dependencies: ["SecretAgentKit"],
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "SSHProtocolKit",
|
|
||||||
dependencies: ["SecretKit"],
|
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings,
|
|
||||||
),
|
|
||||||
.testTarget(
|
|
||||||
name: "SSHProtocolKitTests",
|
|
||||||
dependencies: ["SSHProtocolKit"],
|
|
||||||
swiftSettings: swiftSettings,
|
|
||||||
),
|
|
||||||
.target(
|
|
||||||
name: "Common",
|
|
||||||
dependencies: ["SSHProtocolKit", "SecretKit"],
|
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings,
|
|
||||||
),
|
|
||||||
.target(
|
.target(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
dependencies: ["XPCWrappers", "SSHProtocolKit"],
|
dependencies: [],
|
||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
@@ -98,21 +78,16 @@ let package = Package(
|
|||||||
name: "BriefTests",
|
name: "BriefTests",
|
||||||
dependencies: ["Brief"],
|
dependencies: ["Brief"],
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "XPCWrappers",
|
|
||||||
swiftSettings: swiftSettings,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
var localization: Resource {
|
var localization: Resource {
|
||||||
.process("../../Resources/Localizable.xcstrings")
|
.process("../../Localizable.xcstrings")
|
||||||
}
|
}
|
||||||
|
|
||||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||||
[
|
[
|
||||||
.swiftLanguageMode(.v6),
|
.swiftLanguageMode(.v6),
|
||||||
.treatAllWarnings(as: .error),
|
.treatAllWarnings(as: .error),
|
||||||
.strictMemorySafety()
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// A release is a representation of a downloadable update.
|
/// A release is a representation of a downloadable update.
|
||||||
public struct Release: Codable, Sendable, Hashable {
|
public struct Release: Codable, Sendable {
|
||||||
|
|
||||||
/// The user-facing name of the release. Typically "Secretive 1.2.3"
|
/// The user-facing name of the release. Typically "Secretive 1.2.3"
|
||||||
public let name: String
|
public let name: String
|
||||||
@@ -16,8 +15,6 @@ public struct Release: Codable, Sendable, Hashable {
|
|||||||
/// A user-facing description of the contents of the update.
|
/// A user-facing description of the contents of the update.
|
||||||
public let body: String
|
public let body: String
|
||||||
|
|
||||||
public let attributedBody: AttributedString
|
|
||||||
|
|
||||||
/// Initializes a Release.
|
/// Initializes a Release.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: The user-facing name of the release.
|
/// - name: The user-facing name of the release.
|
||||||
@@ -29,56 +26,6 @@ public struct Release: Codable, Sendable, Hashable {
|
|||||||
self.prerelease = prerelease
|
self.prerelease = prerelease
|
||||||
self.html_url = html_url
|
self.html_url = html_url
|
||||||
self.body = body
|
self.body = body
|
||||||
self.attributedBody = AttributedString(_markdown: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(_ release: GitHubRelease) {
|
|
||||||
self.name = release.name
|
|
||||||
self.prerelease = release.prerelease
|
|
||||||
self.html_url = release.html_url
|
|
||||||
self.body = release.body
|
|
||||||
self.attributedBody = AttributedString(_markdown: release.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct GitHubRelease: Codable, Sendable {
|
|
||||||
let name: String
|
|
||||||
let prerelease: Bool
|
|
||||||
let html_url: URL
|
|
||||||
let body: String
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate extension AttributedString {
|
|
||||||
|
|
||||||
init(_markdown markdown: String) {
|
|
||||||
let split = markdown.split(whereSeparator: \.isNewline)
|
|
||||||
let lines = split
|
|
||||||
.compactMap {
|
|
||||||
try? AttributedString(markdown: String($0), options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full))
|
|
||||||
}
|
|
||||||
.map { (string: AttributedString) in
|
|
||||||
guard case let .header(level) = string.runs.first?.presentationIntent?.components.first?.kind else { return string }
|
|
||||||
return AttributedString("\n") + string
|
|
||||||
.transformingAttributes(\.font) { font in
|
|
||||||
font.value = switch level {
|
|
||||||
case 2: .headline.bold()
|
|
||||||
case 3: .headline
|
|
||||||
default: .subheadline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.transformingAttributes(\.underlineStyle) { underline in
|
|
||||||
underline.value = switch level {
|
|
||||||
case 2: .single
|
|
||||||
default: .none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+ AttributedString("\n")
|
|
||||||
}
|
|
||||||
self = lines.reduce(into: AttributedString()) { partialResult, next in
|
|
||||||
partialResult.append(next)
|
|
||||||
partialResult.append(AttributedString("\n"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,12 @@ 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
|
||||||
// Nightlies have the format 0.0.0_nightly-2025-09-03
|
let strippedBeta = version.split(separator: "_").first!
|
||||||
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)
|
||||||
@@ -30,7 +22,6 @@ public struct SemVer: Sendable {
|
|||||||
/// - 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
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.
|
||||||
@Observable public final class Updater: UpdaterProtocol, Sendable {
|
@Observable public final class Updater: UpdaterProtocol, Sendable {
|
||||||
@@ -14,11 +13,12 @@ import XPCWrappers
|
|||||||
state.update
|
state.update
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current version of the app that is running.
|
public let testBuild: Bool
|
||||||
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:
|
||||||
@@ -34,25 +34,28 @@ import XPCWrappers
|
|||||||
) {
|
) {
|
||||||
self.osVersion = osVersion
|
self.osVersion = osVersion
|
||||||
self.currentVersion = currentVersion
|
self.currentVersion = currentVersion
|
||||||
Task {
|
testBuild = currentVersion == SemVer("0.0.0")
|
||||||
if checkOnLaunch {
|
if checkOnLaunch {
|
||||||
try await checkForUpdates()
|
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
|
||||||
|
Task {
|
||||||
|
await checkForUpdates()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
|
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
|
||||||
try await checkForUpdates()
|
await checkForUpdates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manually trigger an update check.
|
/// Manually trigger an update check.
|
||||||
public func checkForUpdates() async throws {
|
public func checkForUpdates() async {
|
||||||
let session = try await XPCTypedSession<[Release], Never>(serviceName: "com.maxgoedjen.Secretive.SecretiveUpdater")
|
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL) else { return }
|
||||||
await evaluate(releases: try await session.send())
|
guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
|
||||||
session.complete()
|
await evaluate(releases: releases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 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) async {
|
public func ignore(release: Release) async {
|
||||||
@@ -99,3 +102,11 @@ extension Updater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Updater {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")!
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ public protocol UpdaterProtocol: Observable, Sendable {
|
|||||||
|
|
||||||
/// The latest update
|
/// The latest update
|
||||||
@MainActor 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 currentVersion: SemVer { get }
|
var testBuild: Bool { get }
|
||||||
|
|
||||||
func ignore(release: Release) async
|
func ignore(release: Release) async
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Bundle {
|
|
||||||
public static var agentBundleID: String {
|
|
||||||
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "Host", with: "SecretAgent")
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var hostBundleID: String {
|
|
||||||
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "SecretAgent", with: "Host")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SSHProtocolKit
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
extension URL {
|
|
||||||
|
|
||||||
public static var agentHomeURL: URL {
|
|
||||||
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var socketPath: String {
|
|
||||||
#if DEBUG
|
|
||||||
URL.agentHomeURL.appendingPathComponent("socket-debug.ssh").path()
|
|
||||||
#else
|
|
||||||
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var publicKeyDirectory: URL {
|
|
||||||
agentHomeURL.appending(component: "PublicKeys")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path for a Secret's public key.
|
|
||||||
/// - Parameter secret: The Secret to return the path for.
|
|
||||||
/// - Returns: The path to the Secret's public key.
|
|
||||||
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
|
||||||
public static func publicKeyPath<SecretType: Secret>(for secret: SecretType, in directory: URL) -> String {
|
|
||||||
let keyWriter = OpenSSHPublicKeyWriter()
|
|
||||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
|
||||||
return directory.appending(component: "\(minimalHex).pub").path()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
|
|
||||||
public var normalizedPathAndFolder: (String, String) {
|
|
||||||
// All foundation-based normalization methods replace this with the container directly.
|
|
||||||
let processedPath = replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
|
|
||||||
let url = URL(filePath: processedPath)
|
|
||||||
let folder = url.deletingLastPathComponent().path()
|
|
||||||
return (processedPath, folder)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
1
Sources/Packages/Sources/Localization/Stub.swift
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
public struct HexDataStyle<SequenceType: Sequence>: Hashable, Codable {
|
|
||||||
|
|
||||||
let separator: String
|
|
||||||
|
|
||||||
public init(separator: String) {
|
|
||||||
self.separator = separator
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension HexDataStyle: FormatStyle where SequenceType.Element == UInt8 {
|
|
||||||
|
|
||||||
public func format(_ value: SequenceType) -> String {
|
|
||||||
value
|
|
||||||
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
|
||||||
.joined(separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FormatStyle where Self == HexDataStyle<Data> {
|
|
||||||
|
|
||||||
public static func hex(separator: String = "") -> HexDataStyle<Data> {
|
|
||||||
HexDataStyle(separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
|
|
||||||
|
|
||||||
public static func hex(separator: String = ":") -> HexDataStyle<Insecure.MD5Digest> {
|
|
||||||
HexDataStyle(separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Reads OpenSSH protocol data.
|
|
||||||
public final class OpenSSHReader {
|
|
||||||
|
|
||||||
var remaining: Data
|
|
||||||
var done = false
|
|
||||||
|
|
||||||
/// Initialize the reader with an OpenSSH data payload.
|
|
||||||
/// - Parameter data: The data to read.
|
|
||||||
public init(data: Data) {
|
|
||||||
remaining = Data(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the next chunk of data from the playload.
|
|
||||||
/// - Returns: The next chunk of data.
|
|
||||||
public func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
|
|
||||||
let length = try readNextBytes(as: UInt32.self, convertEndianness: convertEndianness)
|
|
||||||
guard remaining.count >= length else { throw .beyondBounds }
|
|
||||||
let dataRange = 0..<Int(length)
|
|
||||||
let ret = Data(remaining[dataRange])
|
|
||||||
remaining.removeSubrange(dataRange)
|
|
||||||
if remaining.isEmpty {
|
|
||||||
done = true
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
public func readNextBytes<T: FixedWidthInteger>(as: T.Type, convertEndianness: Bool = true) throws(OpenSSHReaderError) -> T {
|
|
||||||
let size = MemoryLayout<T>.size
|
|
||||||
guard remaining.count >= size else { throw .beyondBounds }
|
|
||||||
let lengthRange = 0..<size
|
|
||||||
let lengthChunk = remaining[lengthRange]
|
|
||||||
remaining.removeSubrange(lengthRange)
|
|
||||||
if remaining.isEmpty {
|
|
||||||
done = true
|
|
||||||
}
|
|
||||||
let value = unsafe lengthChunk.bytes.unsafeLoad(as: T.self)
|
|
||||||
return convertEndianness ? T(value.bigEndian) : T(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func readNextByteAsBool() throws(OpenSSHReaderError) -> Bool {
|
|
||||||
let size = MemoryLayout<Bool>.size
|
|
||||||
guard remaining.count >= size else { throw .beyondBounds }
|
|
||||||
let lengthRange = 0..<size
|
|
||||||
let lengthChunk = remaining[lengthRange]
|
|
||||||
remaining.removeSubrange(lengthRange)
|
|
||||||
if remaining.isEmpty {
|
|
||||||
done = true
|
|
||||||
}
|
|
||||||
return unsafe lengthChunk.bytes.unsafeLoad(as: Bool.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
|
|
||||||
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
|
|
||||||
OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum OpenSSHReaderError: Error, Codable {
|
|
||||||
case incorrectFormat
|
|
||||||
case beyondBounds
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
|
||||||
public enum SSHAgent {}
|
|
||||||
|
|
||||||
extension SSHAgent {
|
|
||||||
|
|
||||||
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
|
||||||
public enum Request: CustomDebugStringConvertible, Codable, Sendable {
|
|
||||||
|
|
||||||
case requestIdentities
|
|
||||||
case signRequest(SignatureRequestContext)
|
|
||||||
case addIdentity
|
|
||||||
case removeIdentity
|
|
||||||
case removeAllIdentities
|
|
||||||
case addIDConstrained
|
|
||||||
case addSmartcardKey
|
|
||||||
case removeSmartcardKey
|
|
||||||
case lock
|
|
||||||
case unlock
|
|
||||||
case addSmartcardKeyConstrained
|
|
||||||
case protocolExtension(ProtocolExtension)
|
|
||||||
case unknown(UInt8)
|
|
||||||
|
|
||||||
public var protocolID: UInt8 {
|
|
||||||
switch self {
|
|
||||||
case .requestIdentities: 11
|
|
||||||
case .signRequest: 13
|
|
||||||
case .addIdentity: 17
|
|
||||||
case .removeIdentity: 18
|
|
||||||
case .removeAllIdentities: 19
|
|
||||||
case .addIDConstrained: 25
|
|
||||||
case .addSmartcardKey: 20
|
|
||||||
case .removeSmartcardKey: 21
|
|
||||||
case .lock: 22
|
|
||||||
case .unlock: 23
|
|
||||||
case .addSmartcardKeyConstrained: 26
|
|
||||||
case .protocolExtension: 27
|
|
||||||
case .unknown(let value): value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var debugDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES"
|
|
||||||
case .signRequest: "SSH_AGENTC_SIGN_REQUEST"
|
|
||||||
case .addIdentity: "SSH_AGENTC_ADD_IDENTITY"
|
|
||||||
case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY"
|
|
||||||
case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES"
|
|
||||||
case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED"
|
|
||||||
case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY"
|
|
||||||
case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY"
|
|
||||||
case .lock: "SSH_AGENTC_LOCK"
|
|
||||||
case .unlock: "SSH_AGENTC_UNLOCK"
|
|
||||||
case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED"
|
|
||||||
case .protocolExtension: "SSH_AGENTC_EXTENSION"
|
|
||||||
case .unknown: "UNKNOWN_MESSAGE"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SignatureRequestContext: Sendable, Codable {
|
|
||||||
public let keyBlob: Data
|
|
||||||
public let dataToSign: SignaturePayload
|
|
||||||
|
|
||||||
public init(keyBlob: Data, dataToSign: SignaturePayload) {
|
|
||||||
self.keyBlob = keyBlob
|
|
||||||
self.dataToSign = dataToSign
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var empty: SignatureRequestContext {
|
|
||||||
SignatureRequestContext(keyBlob: Data(), dataToSign: SignaturePayload(raw: Data(), decoded: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SignaturePayload: Sendable, Codable {
|
|
||||||
|
|
||||||
public let raw: Data
|
|
||||||
public let decoded: DecodedPayload?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
raw: Data,
|
|
||||||
decoded: DecodedPayload?
|
|
||||||
) {
|
|
||||||
self.raw = raw
|
|
||||||
self.decoded = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum DecodedPayload: Sendable, Codable {
|
|
||||||
case sshConnection(SSHConnectionPayload)
|
|
||||||
case sshSig(SSHSigPayload)
|
|
||||||
|
|
||||||
public struct SSHConnectionPayload: Sendable, Codable {
|
|
||||||
|
|
||||||
public let username: String
|
|
||||||
public let hasSignature: Bool
|
|
||||||
public let publicKeyAlgorithm: String
|
|
||||||
public let publicKey: Data
|
|
||||||
public let hostKey: Data
|
|
||||||
|
|
||||||
public init(
|
|
||||||
username: String,
|
|
||||||
hasSignature: Bool,
|
|
||||||
publicKeyAlgorithm: String,
|
|
||||||
publicKey: Data,
|
|
||||||
hostKey: Data
|
|
||||||
) {
|
|
||||||
self.username = username
|
|
||||||
self.hasSignature = hasSignature
|
|
||||||
self.publicKeyAlgorithm = publicKeyAlgorithm
|
|
||||||
self.publicKey = publicKey
|
|
||||||
self.hostKey = hostKey
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SSHSigPayload: Sendable, Codable {
|
|
||||||
|
|
||||||
public let namespace: String
|
|
||||||
public let hashAlgorithm: String
|
|
||||||
public let hash: Data
|
|
||||||
|
|
||||||
public init(
|
|
||||||
namespace: String,
|
|
||||||
hashAlgorithm: String,
|
|
||||||
hash: Data,
|
|
||||||
) {
|
|
||||||
self.namespace = namespace
|
|
||||||
self.hashAlgorithm = hashAlgorithm
|
|
||||||
self.hash = hash
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
|
||||||
public enum Response: UInt8, CustomDebugStringConvertible {
|
|
||||||
|
|
||||||
case agentFailure = 5
|
|
||||||
case agentSuccess = 6
|
|
||||||
case agentIdentitiesAnswer = 12
|
|
||||||
case agentSignResponse = 14
|
|
||||||
case agentExtensionFailure = 28
|
|
||||||
case agentExtensionResponse = 29
|
|
||||||
|
|
||||||
public var debugDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .agentFailure: "SSH_AGENT_FAILURE"
|
|
||||||
case .agentSuccess: "SSH_AGENT_SUCCESS"
|
|
||||||
case .agentIdentitiesAnswer: "SSH2_AGENT_IDENTITIES_ANSWER"
|
|
||||||
case .agentSignResponse: "SSH2_AGENT_SIGN_RESPONSE"
|
|
||||||
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
|
|
||||||
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// Extensions, as defined in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.agent
|
|
||||||
|
|
||||||
extension SSHAgent {
|
|
||||||
|
|
||||||
public enum ProtocolExtension: CustomDebugStringConvertible, Codable, Sendable {
|
|
||||||
case openSSH(OpenSSHExtension)
|
|
||||||
case unknown(String)
|
|
||||||
|
|
||||||
public var debugDescription: String {
|
|
||||||
switch self {
|
|
||||||
case let .openSSH(protocolExtension):
|
|
||||||
protocolExtension.debugDescription
|
|
||||||
case .unknown(let string):
|
|
||||||
"Unknown (\(string))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var empty: ProtocolExtension {
|
|
||||||
.unknown("empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ProtocolExtensionParsingError: Error {}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SSHAgent.ProtocolExtension {
|
|
||||||
|
|
||||||
public enum OpenSSHExtension: CustomDebugStringConvertible, Codable, Sendable {
|
|
||||||
case sessionBind(SessionBindContext)
|
|
||||||
case unknown(String)
|
|
||||||
|
|
||||||
public static var domain: String {
|
|
||||||
"openssh.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
public var name: String {
|
|
||||||
switch self {
|
|
||||||
case .sessionBind:
|
|
||||||
"session-bind"
|
|
||||||
case .unknown(let name):
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var debugDescription: String {
|
|
||||||
"\(name)@\(OpenSSHExtension.domain)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SSHAgent.ProtocolExtension.OpenSSHExtension {
|
|
||||||
|
|
||||||
public struct SessionBindContext: Codable, Sendable {
|
|
||||||
|
|
||||||
public let hostKey: Data
|
|
||||||
public let sessionID: Data
|
|
||||||
public let signature: Data
|
|
||||||
public let forwarding: Bool
|
|
||||||
|
|
||||||
public init(hostKey: Data, sessionID: Data, signature: Data, forwarding: Bool) {
|
|
||||||
self.hostKey = hostKey
|
|
||||||
self.sessionID = sessionID
|
|
||||||
self.signature = signature
|
|
||||||
self.forwarding = forwarding
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let empty = SessionBindContext(hostKey: Data(), sessionID: Data(), signature: Data(), forwarding: false)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import CryptoKit
|
|||||||
import OSLog
|
import OSLog
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import AppKit
|
import AppKit
|
||||||
import SSHProtocolKit
|
|
||||||
|
|
||||||
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
|
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
|
||||||
public final class Agent: Sendable {
|
public final class Agent: Sendable {
|
||||||
@@ -15,8 +14,6 @@ public final class Agent: Sendable {
|
|||||||
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")
|
||||||
|
|
||||||
@MainActor private var sessionID: SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext?
|
|
||||||
|
|
||||||
/// Initializes an agent with a store list and a witness.
|
/// Initializes an agent with a store list and a witness.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - storeList: The `SecretStoreList` to make available.
|
/// - storeList: The `SecretStoreList` to make available.
|
||||||
@@ -34,62 +31,89 @@ public final class Agent: Sendable {
|
|||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
|
/// Handles an incoming request.
|
||||||
logger.debug("Agent received request of type \(request.debugDescription)")
|
/// - Parameters:
|
||||||
|
/// - data: The data to handle.
|
||||||
|
/// - provenance: The origin of the request.
|
||||||
|
/// - Returns: A response data payload.
|
||||||
|
public func handle(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
|
logger.debug("Agent handling new data")
|
||||||
|
guard data.count > 4 else {
|
||||||
|
throw InvalidDataProvidedError()
|
||||||
|
}
|
||||||
|
let requestTypeInt = data[4]
|
||||||
|
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
||||||
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription) for unknown request type \(requestTypeInt)")
|
||||||
|
return SSHAgent.ResponseType.agentFailure.data.lengthAndData
|
||||||
|
}
|
||||||
|
logger.debug("Agent handling request of type \(requestType.debugDescription)")
|
||||||
|
let subData = Data(data[5...])
|
||||||
|
let response = await handle(requestType: requestType, data: subData, provenance: provenance)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(requestType: SSHAgent.RequestType, data: Data, provenance: SigningRequestProvenance) async -> Data {
|
||||||
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
||||||
await reloadSecretsIfNeccessary()
|
await reloadSecretsIfNeccessary()
|
||||||
var response = Data()
|
var response = Data()
|
||||||
do {
|
do {
|
||||||
switch request {
|
switch requestType {
|
||||||
case .requestIdentities:
|
case .requestIdentities:
|
||||||
response.append(SSHAgent.Response.agentIdentitiesAnswer.data)
|
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
||||||
response.append(await identities())
|
response.append(await identities())
|
||||||
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
||||||
case .signRequest(let context):
|
case .signRequest:
|
||||||
if let boundSession = await sessionID {
|
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
||||||
switch context.dataToSign.decoded {
|
response.append(try await sign(data: data, provenance: provenance))
|
||||||
case .sshConnection(let payload):
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
|
||||||
guard payload.hostKey == boundSession.hostKey else {
|
case .protocolExtension:
|
||||||
logger.error("Agent received bind request, but host key does not match signature reqeust host key.")
|
response.append(SSHAgent.ResponseType.agentExtensionResponse.data)
|
||||||
throw BindingFailure()
|
try await handleExtension(data)
|
||||||
}
|
|
||||||
case .sshSig:
|
|
||||||
// SSHSIG does not have a host binding payload.
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
|
let reader = OpenSSHReader(data: data)
|
||||||
|
while true {
|
||||||
|
do {
|
||||||
|
let payloadHash = try reader.readNextChunk()
|
||||||
|
print(String(String(decoding: payloadHash, as: UTF8.self)))
|
||||||
|
print(payloadHash)
|
||||||
|
} catch {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.append(SSHAgent.Response.agentSignResponse.data)
|
logger.debug("Agent received valid request of type \(requestType.debugDescription), but not currently supported.")
|
||||||
response.append(try await sign(data: context.dataToSign.raw, keyBlob: context.keyBlob, provenance: provenance))
|
response.append(SSHAgent.ResponseType.agentFailure.data)
|
||||||
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
|
|
||||||
case .protocolExtension(.openSSH(.sessionBind(let bind))):
|
|
||||||
response = try await MainActor.run {
|
|
||||||
guard sessionID == nil else {
|
|
||||||
logger.error("Agent received bind request, but already bound.")
|
|
||||||
throw BindingFailure()
|
|
||||||
}
|
|
||||||
logger.debug("Agent bound")
|
|
||||||
sessionID = bind
|
|
||||||
return SSHAgent.Response.agentSuccess.data
|
|
||||||
}
|
|
||||||
logger.debug("Agent returned \(SSHAgent.Response.agentSuccess.debugDescription)")
|
|
||||||
case .unknown(let value):
|
|
||||||
logger.error("Agent received unknown request of type \(value).")
|
|
||||||
throw UnhandledRequestError()
|
|
||||||
default:
|
|
||||||
logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.")
|
|
||||||
throw UnhandledRequestError()
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
response = SSHAgent.Response.agentFailure.data
|
response = SSHAgent.ResponseType.agentFailure.data
|
||||||
logger.debug("Agent returned \(SSHAgent.Response.agentFailure.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
||||||
}
|
}
|
||||||
return response.lengthAndData
|
return response.lengthAndData
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PROTOCOL EXTENSIONS
|
||||||
|
extension Agent {
|
||||||
|
|
||||||
|
func handleExtension(_ data: Data) async throws {
|
||||||
|
let reader = OpenSSHReader(data: data)
|
||||||
|
guard try reader.readNextChunkAsString() == "session-bind@openssh.com" else { throw UnsupportedExtensionError() }
|
||||||
|
let hostKey = try reader.readNextChunk()
|
||||||
|
let keyReader = OpenSSHReader(data: hostKey)
|
||||||
|
_ = try keyReader.readNextChunkAsString() // Key Type
|
||||||
|
let keyData = try keyReader.readNextChunk()
|
||||||
|
let sessionID = try reader.readNextChunk()
|
||||||
|
let signatureData = try reader.readNextChunk()
|
||||||
|
let forwarding = try reader.readNextBytes(as: Bool.self)
|
||||||
|
let signatureReader = OpenSSHSignatureReader()
|
||||||
|
guard try signatureReader.verify(signatureData, for: sessionID, with: keyData) else { throw SignatureVerificationFailedError() }
|
||||||
|
print("Fowarding: \(forwarding)")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnsupportedExtensionError: Error {}
|
||||||
|
struct SignatureVerificationFailedError: Error {}
|
||||||
|
}
|
||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
/// Lists the identities available for signing operations
|
/// Lists the identities available for signing operations
|
||||||
@@ -114,7 +138,7 @@ extension Agent {
|
|||||||
}
|
}
|
||||||
logger.log("Agent enumerated \(count) identities")
|
logger.log("Agent enumerated \(count) identities")
|
||||||
var countBigEndian = UInt32(count).bigEndian
|
var countBigEndian = UInt32(count).bigEndian
|
||||||
let countData = unsafe Data(bytes: &countBigEndian, count: MemoryLayout<UInt32>.size)
|
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
|
||||||
return countData + keyData
|
return countData + keyData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,16 +147,27 @@ 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, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
func sign(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
guard let (secret, store) = await secret(matching: keyBlob) else {
|
let reader = OpenSSHReader(data: data)
|
||||||
let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined()
|
let payloadHash = try reader.readNextChunk()
|
||||||
logger.debug("Agent did not have a key matching \(keyBlobHex)")
|
let hash: Data
|
||||||
|
|
||||||
|
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
||||||
|
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
||||||
|
hash = certificatePublicKey
|
||||||
|
} else {
|
||||||
|
hash = payloadHash
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let (secret, store) = await secret(matching: hash) else {
|
||||||
|
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
||||||
throw NoMatchingKeyError()
|
throw NoMatchingKeyError()
|
||||||
}
|
}
|
||||||
|
|
||||||
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
||||||
|
|
||||||
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
|
let dataToSign = try reader.readNextChunk()
|
||||||
|
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
||||||
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||||
|
|
||||||
try await witness?.witness(accessTo: secret, from: store, by: provenance)
|
try await witness?.witness(accessTo: secret, from: store, by: provenance)
|
||||||
@@ -171,17 +206,16 @@ extension Agent {
|
|||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
|
struct InvalidDataProvidedError: Error {}
|
||||||
struct NoMatchingKeyError: Error {}
|
struct NoMatchingKeyError: Error {}
|
||||||
struct UnhandledRequestError: Error {}
|
|
||||||
struct BindingFailure: Error {}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SSHAgent.Response {
|
extension SSHAgent.ResponseType {
|
||||||
|
|
||||||
var data: Data {
|
var data: Data {
|
||||||
var raw = self.rawValue
|
var raw = self.rawValue
|
||||||
return unsafe Data(bytes: &raw, count: MemoryLayout<UInt8>.size)
|
return Data(bytes: &raw, count: UInt8.bitWidth/8)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension FileHandle {
|
/// Protocol abstraction of the reading aspects of FileHandle.
|
||||||
|
public protocol FileHandleReader: Sendable {
|
||||||
|
|
||||||
|
/// Gets data that is available for reading.
|
||||||
|
var availableData: Data { get }
|
||||||
|
/// A file descriptor of the handle.
|
||||||
|
var fileDescriptor: Int32 { get }
|
||||||
|
/// The process ID of the process coonnected to the other end of the FileHandle.
|
||||||
|
var pidOfConnectedProcess: Int32 { get }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol abstraction of the writing aspects of FileHandle.
|
||||||
|
public protocol FileHandleWriter: Sendable {
|
||||||
|
|
||||||
|
/// Writes data to the handle.
|
||||||
|
func write(_ data: Data)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FileHandle: FileHandleReader, FileHandleWriter {
|
||||||
|
|
||||||
public var pidOfConnectedProcess: Int32 {
|
public var pidOfConnectedProcess: Int32 {
|
||||||
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: MemoryLayout<Int32>.size, alignment: 1)
|
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1)
|
||||||
var len = socklen_t(MemoryLayout<Int32>.size)
|
var len = socklen_t(MemoryLayout<Int32>.size)
|
||||||
unsafe getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
|
getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
|
||||||
return unsafe pidPointer.load(as: Int32.self)
|
return pidPointer.load(as: Int32.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import SecretKit
|
|
||||||
import SSHProtocolKit
|
|
||||||
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
public protocol SSHAgentInputParserProtocol {
|
|
||||||
|
|
||||||
func parse(data: Data) async throws -> SSHAgent.Request
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
|
|
||||||
logger.debug("Parsing new data")
|
|
||||||
guard data.count > 4 else {
|
|
||||||
throw .invalidData
|
|
||||||
}
|
|
||||||
let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4
|
|
||||||
let rawRequestInt = data[4]
|
|
||||||
let remainingDataRange = 5..<min(Int(specifiedLength), data.count)
|
|
||||||
lazy var body: Data = { Data(data[remainingDataRange]) }()
|
|
||||||
switch rawRequestInt {
|
|
||||||
case SSHAgent.Request.requestIdentities.protocolID:
|
|
||||||
return .requestIdentities
|
|
||||||
case SSHAgent.Request.signRequest(.empty).protocolID:
|
|
||||||
do {
|
|
||||||
return .signRequest(try signatureRequestContext(from: body))
|
|
||||||
} catch {
|
|
||||||
throw .openSSHReader(error)
|
|
||||||
}
|
|
||||||
case SSHAgent.Request.addIdentity.protocolID:
|
|
||||||
return .addIdentity
|
|
||||||
case SSHAgent.Request.removeIdentity.protocolID:
|
|
||||||
return .removeIdentity
|
|
||||||
case SSHAgent.Request.removeAllIdentities.protocolID:
|
|
||||||
return .removeAllIdentities
|
|
||||||
case SSHAgent.Request.addIDConstrained.protocolID:
|
|
||||||
return .addIDConstrained
|
|
||||||
case SSHAgent.Request.addSmartcardKey.protocolID:
|
|
||||||
return .addSmartcardKey
|
|
||||||
case SSHAgent.Request.removeSmartcardKey.protocolID:
|
|
||||||
return .removeSmartcardKey
|
|
||||||
case SSHAgent.Request.lock.protocolID:
|
|
||||||
return .lock
|
|
||||||
case SSHAgent.Request.unlock.protocolID:
|
|
||||||
return .unlock
|
|
||||||
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
|
|
||||||
return .addSmartcardKeyConstrained
|
|
||||||
case SSHAgent.Request.protocolExtension(.empty).protocolID:
|
|
||||||
return .protocolExtension(try protocolExtension(from: body))
|
|
||||||
default:
|
|
||||||
return .unknown(rawRequestInt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SSHAgentInputParser {
|
|
||||||
|
|
||||||
private enum Constants {
|
|
||||||
static let userAuthMagic: UInt8 = 50 // SSH2_MSG_USERAUTH_REQUEST
|
|
||||||
static let sshSigMagic = Data("SSHSIG".utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
|
|
||||||
let reader = OpenSSHReader(data: data)
|
|
||||||
let rawKeyBlob = try reader.readNextChunk()
|
|
||||||
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
|
|
||||||
let rawPayload = try reader.readNextChunk()
|
|
||||||
let payload: SSHAgent.Request.SignatureRequestContext.SignaturePayload
|
|
||||||
do {
|
|
||||||
if rawPayload.count > 6 && rawPayload[0..<6] == Constants.sshSigMagic {
|
|
||||||
payload = .init(raw: rawPayload, decoded: .sshSig(try sshSigPayload(from: rawPayload[6...])))
|
|
||||||
} else {
|
|
||||||
payload = .init(raw: rawPayload, decoded: .sshConnection(try sshConnectionPayload(from: rawPayload)))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
payload = .init(raw: rawPayload, decoded: nil)
|
|
||||||
}
|
|
||||||
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshSigPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHSigPayload {
|
|
||||||
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L79
|
|
||||||
let payloadReader = OpenSSHReader(data: data)
|
|
||||||
let namespace = try payloadReader.readNextChunkAsString()
|
|
||||||
_ = try payloadReader.readNextChunk() // reserved
|
|
||||||
let hashAlgorithm = try payloadReader.readNextChunkAsString()
|
|
||||||
let hash = try payloadReader.readNextChunk()
|
|
||||||
return .init(
|
|
||||||
namespace: namespace,
|
|
||||||
hashAlgorithm: hashAlgorithm,
|
|
||||||
hash: hash
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshConnectionPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHConnectionPayload {
|
|
||||||
let payloadReader = OpenSSHReader(data: data)
|
|
||||||
_ = try payloadReader.readNextChunk()
|
|
||||||
let magic = try payloadReader.readNextBytes(as: UInt8.self, convertEndianness: false)
|
|
||||||
guard magic == Constants.userAuthMagic else { throw .incorrectFormat }
|
|
||||||
let username = try payloadReader.readNextChunkAsString()
|
|
||||||
_ = try payloadReader.readNextChunkAsString() // "ssh-connection"
|
|
||||||
_ = try payloadReader.readNextChunkAsString() // "publickey-hostbound-v00@openssh.com"
|
|
||||||
let hasSignature = try payloadReader.readNextByteAsBool()
|
|
||||||
let algorithm = try payloadReader.readNextChunkAsString()
|
|
||||||
let publicKeyReader = try payloadReader.readNextChunkAsSubReader()
|
|
||||||
_ = try publicKeyReader.readNextChunk()
|
|
||||||
_ = try publicKeyReader.readNextChunk()
|
|
||||||
let publicKey = try publicKeyReader.readNextChunk()
|
|
||||||
let hostKeyReader = try payloadReader.readNextChunkAsSubReader()
|
|
||||||
_ = try hostKeyReader.readNextChunk()
|
|
||||||
let hostKey = try hostKeyReader.readNextChunk()
|
|
||||||
return .init(
|
|
||||||
username: username,
|
|
||||||
hasSignature: hasSignature,
|
|
||||||
publicKeyAlgorithm: algorithm,
|
|
||||||
publicKey: publicKey,
|
|
||||||
hostKey: hostKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func protocolExtension(from data: Data) throws(AgentParsingError) -> SSHAgent.ProtocolExtension {
|
|
||||||
do {
|
|
||||||
let reader = OpenSSHReader(data: data)
|
|
||||||
let nameRaw = try reader.readNextChunkAsString()
|
|
||||||
let nameSplit = nameRaw.split(separator: "@")
|
|
||||||
guard nameSplit.count == 2 else {
|
|
||||||
throw AgentParsingError.invalidData
|
|
||||||
}
|
|
||||||
let (name, domain) = (nameSplit[0], nameSplit[1])
|
|
||||||
switch domain {
|
|
||||||
case SSHAgent.ProtocolExtension.OpenSSHExtension.domain:
|
|
||||||
switch name {
|
|
||||||
case SSHAgent.ProtocolExtension.OpenSSHExtension.sessionBind(.empty).name:
|
|
||||||
let hostkeyBlob = try reader.readNextChunkAsSubReader()
|
|
||||||
let hostKeyType = try hostkeyBlob.readNextChunkAsString()
|
|
||||||
let hostKeyData = try hostkeyBlob.readNextChunk()
|
|
||||||
let sessionID = try reader.readNextChunk()
|
|
||||||
let signatureBlob = try reader.readNextChunkAsSubReader()
|
|
||||||
_ = try signatureBlob.readNextChunk() // key type again
|
|
||||||
let signature = try signatureBlob.readNextChunk()
|
|
||||||
let forwarding = try reader.readNextByteAsBool()
|
|
||||||
switch hostKeyType {
|
|
||||||
// FIXME: FACTOR OUT?
|
|
||||||
case "ssh-ed25519":
|
|
||||||
let hostKey = try CryptoKit.Curve25519.Signing.PublicKey(rawRepresentation: hostKeyData)
|
|
||||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
|
||||||
throw AgentParsingError.incorrectSignature
|
|
||||||
}
|
|
||||||
case "ecdsa-sha2-nistp256":
|
|
||||||
let hostKey = try CryptoKit.P256.Signing.PublicKey(rawRepresentation: hostKeyData)
|
|
||||||
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
|
||||||
throw AgentParsingError.incorrectSignature
|
|
||||||
}
|
|
||||||
case "ecdsa-sha2-nistp384":
|
|
||||||
let hostKey = try CryptoKit.P384.Signing.PublicKey(rawRepresentation: hostKeyData)
|
|
||||||
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
|
||||||
throw AgentParsingError.incorrectSignature
|
|
||||||
}
|
|
||||||
case "ssh-mldsa-65":
|
|
||||||
if #available(macOS 26.0, *) {
|
|
||||||
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
|
||||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
|
||||||
throw AgentParsingError.incorrectSignature
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw AgentParsingError.unhandledRequest
|
|
||||||
}
|
|
||||||
case "ssh-mldsa-87":
|
|
||||||
if #available(macOS 26.0, *) {
|
|
||||||
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
|
||||||
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
|
||||||
throw AgentParsingError.incorrectSignature
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw AgentParsingError.unhandledRequest
|
|
||||||
}
|
|
||||||
case "ssh-rsa":
|
|
||||||
// FIXME: HANDLE
|
|
||||||
throw AgentParsingError.unhandledRequest
|
|
||||||
default:
|
|
||||||
throw AgentParsingError.unhandledRequest
|
|
||||||
}
|
|
||||||
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
|
|
||||||
hostKey: hostKeyData,
|
|
||||||
sessionID: sessionID,
|
|
||||||
signature: signature,
|
|
||||||
forwarding: forwarding
|
|
||||||
)
|
|
||||||
return .openSSH(.sessionBind(context))
|
|
||||||
default:
|
|
||||||
return .openSSH(.unknown(String(name)))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return .unknown(nameRaw)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch let error as OpenSSHReaderError {
|
|
||||||
throw .openSSHReader(error)
|
|
||||||
} catch let error as AgentParsingError {
|
|
||||||
throw error
|
|
||||||
} catch {
|
|
||||||
throw .unknownRequest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func certificatePublicKeyBlob(from hash: Data) -> Data? {
|
|
||||||
let reader = OpenSSHReader(data: hash)
|
|
||||||
do {
|
|
||||||
let certType = 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 incorrectSignature
|
|
||||||
case openSSHReader(OpenSSHReaderError)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
|
public enum SSHAgent {}
|
||||||
|
|
||||||
|
extension SSHAgent {
|
||||||
|
|
||||||
|
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
|
public enum RequestType: UInt8, CustomDebugStringConvertible {
|
||||||
|
|
||||||
|
case requestIdentities = 11
|
||||||
|
case signRequest = 13
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
public var debugDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES"
|
||||||
|
case .signRequest: "SSH_AGENTC_SIGN_REQUEST"
|
||||||
|
case .addIdentity: "SSH_AGENTC_ADD_IDENTITY"
|
||||||
|
case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY"
|
||||||
|
case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES"
|
||||||
|
case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED"
|
||||||
|
case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY"
|
||||||
|
case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY"
|
||||||
|
case .lock: "SSH_AGENTC_LOCK"
|
||||||
|
case .unlock: "SSH_AGENTC_UNLOCK"
|
||||||
|
case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED"
|
||||||
|
case .protocolExtension: "SSH_AGENTC_EXTENSION"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
|
public enum ResponseType: UInt8, CustomDebugStringConvertible {
|
||||||
|
|
||||||
|
case agentFailure = 5
|
||||||
|
case agentSuccess = 6
|
||||||
|
case agentIdentitiesAnswer = 12
|
||||||
|
case agentSignResponse = 14
|
||||||
|
case agentExtensionFailure = 28
|
||||||
|
case agentExtensionResponse = 29
|
||||||
|
|
||||||
|
public var debugDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .agentFailure: "SSH_AGENT_FAILURE"
|
||||||
|
case .agentSuccess: "SSH_AGENT_SUCCESS"
|
||||||
|
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
|
||||||
|
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
|
||||||
|
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
|
||||||
|
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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 {
|
||||||
@@ -9,11 +10,12 @@ struct SigningRequestTracer {
|
|||||||
|
|
||||||
extension SigningRequestTracer {
|
extension SigningRequestTracer {
|
||||||
|
|
||||||
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandle``.
|
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandleReader``.
|
||||||
/// - Parameter fileHandle: The reader involved in processing the request.
|
/// - Parameter fileHandleReader: 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 fileHandle: FileHandle) -> SigningRequestProvenance {
|
func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance {
|
||||||
let firstInfo = process(from: fileHandle.pidOfConnectedProcess)
|
let firstInfo = process(from: fileHandleReader.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!))
|
||||||
@@ -25,30 +27,30 @@ 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 = unsafe MemoryLayout<kinfo_proc>.size
|
var len = 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]
|
||||||
unsafe sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
|
sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
|
||||||
return unsafe infoPointer.load(as: kinfo_proc.self)
|
return 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.
|
||||||
/// - Parameter pid: The process ID to look up.
|
/// - Parameter pid: The process ID to look up.
|
||||||
/// - 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 = unsafe self.pidAndNameInfo(from: pid)
|
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
|
||||||
let ppid = unsafe pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
||||||
let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
||||||
unsafe String(cString: pointer)
|
String(cString: pointer)
|
||||||
}
|
}
|
||||||
|
|
||||||
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
||||||
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
||||||
let path = unsafe String(cString: pathPointer)
|
let path = String(cString: pathPointer)
|
||||||
var secCode: Unmanaged<SecCode>!
|
var secCode: Unmanaged<SecCode>!
|
||||||
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
|
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
|
||||||
unsafe SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
|
SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
|
||||||
let valid = unsafe SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
|
let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
|
||||||
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
|
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +81,3 @@ extension SigningRequestTracer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// from libproc.h
|
|
||||||
@_silgen_name("proc_pidpath")
|
|
||||||
@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32
|
|
||||||
|
|
||||||
//// from SecTask.h
|
|
||||||
@_silgen_name("SecCodeCreateWithPID")
|
|
||||||
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus
|
|
||||||
|
|||||||
@@ -36,21 +36,16 @@ public struct SocketController {
|
|||||||
logger.debug("Socket controller path is clear")
|
logger.debug("Socket controller path is clear")
|
||||||
port = SocketPort(path: path)
|
port = SocketPort(path: path)
|
||||||
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
||||||
Task { @MainActor [fileHandle, sessionsContinuation, logger] in
|
Task { [fileHandle, sessionsContinuation, logger] in
|
||||||
// Create the sequence before triggering the notification to
|
for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) {
|
||||||
// ensure it will not be missed.
|
|
||||||
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted)
|
|
||||||
|
|
||||||
fileHandle.acceptConnectionInBackgroundAndNotify()
|
|
||||||
|
|
||||||
for await notification in connectionAcceptedNotifications {
|
|
||||||
logger.debug("Socket controller accepted connection")
|
logger.debug("Socket controller accepted connection")
|
||||||
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
|
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
|
||||||
let session = Session(fileHandle: new)
|
let session = Session(fileHandle: new)
|
||||||
sessionsContinuation.yield(session)
|
sessionsContinuation.yield(session)
|
||||||
fileHandle.acceptConnectionInBackgroundAndNotify()
|
await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
|
||||||
logger.debug("Socket listening at \(path)")
|
logger.debug("Socket listening at \(path)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,14 +77,8 @@ extension SocketController {
|
|||||||
self.fileHandle = fileHandle
|
self.fileHandle = fileHandle
|
||||||
provenance = SigningRequestTracer().provenance(from: fileHandle)
|
provenance = SigningRequestTracer().provenance(from: fileHandle)
|
||||||
(messages, messagesContinuation) = AsyncStream.makeStream()
|
(messages, messagesContinuation) = AsyncStream.makeStream()
|
||||||
Task { @MainActor [messagesContinuation, logger] in
|
Task { [messagesContinuation, logger] in
|
||||||
// Create the sequence before triggering the notification to
|
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) {
|
||||||
// ensure it will not be missed.
|
|
||||||
let dataAvailableNotifications = NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle)
|
|
||||||
|
|
||||||
fileHandle.waitForDataInBackgroundAndNotify()
|
|
||||||
|
|
||||||
for await _ in dataAvailableNotifications {
|
|
||||||
let data = fileHandle.availableData
|
let data = fileHandle.availableData
|
||||||
guard !data.isEmpty else {
|
guard !data.isEmpty else {
|
||||||
logger.debug("Socket controller received empty data, ending continuation.")
|
logger.debug("Socket controller received empty data, ending continuation.")
|
||||||
@@ -101,13 +90,16 @@ extension SocketController {
|
|||||||
logger.debug("Socket controller yielded data.")
|
logger.debug("Socket controller yielded data.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Task {
|
||||||
|
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes new data to the socket.
|
/// Writes new data to the socket.
|
||||||
/// - Parameter data: The data to write.
|
/// - Parameter data: The data to write.
|
||||||
@MainActor public func write(_ data: Data) throws {
|
public func write(_ data: Data) async throws {
|
||||||
try fileHandle.write(contentsOf: data)
|
try fileHandle.write(contentsOf: data)
|
||||||
fileHandle.waitForDataInBackgroundAndNotify()
|
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Closes the socket and cleans up resources.
|
/// Closes the socket and cleans up resources.
|
||||||
@@ -121,25 +113,42 @@ extension SocketController {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension FileHandle {
|
||||||
|
|
||||||
|
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
|
||||||
|
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
|
||||||
|
waitForDataInBackgroundAndNotify()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
|
||||||
|
/// - Parameter modes: the runloop modes to use.
|
||||||
|
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
|
||||||
|
acceptConnectionInBackgroundAndNotify(forModes: modes)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private extension SocketPort {
|
private extension SocketPort {
|
||||||
|
|
||||||
convenience init(path: String) {
|
convenience init(path: String) {
|
||||||
var addr = sockaddr_un()
|
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)
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
// This mirrors the SUN_LEN macro format.
|
|
||||||
addr.sun_len = UInt8(MemoryLayout<sockaddr_un>.size - MemoryLayout.size(ofValue: addr.sun_path) + length)
|
|
||||||
|
|
||||||
let data = unsafe Data(bytes: &addr, count: MemoryLayout<sockaddr_un>.size)
|
var len: Int = 0
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
||||||
|
path.withCString { cstring in
|
||||||
|
len = strlen(cstring)
|
||||||
|
strncpy(pointer, cstring, len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addr.sun_len = UInt8(len+2)
|
||||||
|
|
||||||
|
var data: Data!
|
||||||
|
withUnsafePointer(to: &addr) { pointer in
|
||||||
|
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
|
||||||
|
}
|
||||||
|
|
||||||
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <Security/Security.h>
|
||||||
|
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
|
||||||
|
// from libproc.h
|
||||||
|
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
|
||||||
|
|
||||||
|
// from SecTask.h
|
||||||
|
OSStatus SecCodeCreateWithPID(int32_t, SecCSFlags, SecCodeRef *);
|
||||||
|
|
||||||
|
//! Project version number for SecretAgentKit.
|
||||||
|
FOUNDATION_EXPORT double SecretAgentKitVersionNumber;
|
||||||
|
|
||||||
|
//! Project version string for SecretAgentKit.
|
||||||
|
FOUNDATION_EXPORT const unsigned char SecretAgentKitVersionString[];
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module SecretAgentKitHeaders [system] {
|
||||||
|
header "include/SecretAgentKit.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import LocalAuthentication
|
|
||||||
|
|
||||||
/// A context describing a persisted authentication.
|
|
||||||
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
|
|
||||||
|
|
||||||
/// The Secret to persist authentication for.
|
|
||||||
let secret: SecretType
|
|
||||||
/// The LAContext used to authorize the persistent context.
|
|
||||||
package nonisolated(unsafe) let context: LAContext
|
|
||||||
/// An expiration date for the context.
|
|
||||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
|
||||||
let monotonicExpiration: UInt64
|
|
||||||
|
|
||||||
/// Initializes a context.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - secret: The Secret to persist authentication for.
|
|
||||||
/// - context: The LAContext used to authorize the persistent context.
|
|
||||||
/// - duration: The duration of the authorization context, in seconds.
|
|
||||||
init(secret: SecretType, context: LAContext, duration: TimeInterval) {
|
|
||||||
self.secret = secret
|
|
||||||
unsafe self.context = context
|
|
||||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
|
||||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A boolean describing whether or not the context is still valid.
|
|
||||||
package var valid: Bool {
|
|
||||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
|
||||||
}
|
|
||||||
|
|
||||||
package var expiration: Date {
|
|
||||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
|
||||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
|
||||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
|
||||||
|
|
||||||
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
|
|
||||||
|
|
||||||
package init() {
|
|
||||||
}
|
|
||||||
|
|
||||||
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? {
|
|
||||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
|
||||||
return persisted
|
|
||||||
}
|
|
||||||
|
|
||||||
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
|
||||||
let newContext = LAContext()
|
|
||||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
|
||||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
|
||||||
|
|
||||||
let formatter = DateComponentsFormatter()
|
|
||||||
formatter.unitsStyle = .spellOut
|
|
||||||
formatter.allowedUnits = [.hour, .minute, .day]
|
|
||||||
|
|
||||||
|
|
||||||
let durationString = formatter.string(from: duration)!
|
|
||||||
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
|
||||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
|
||||||
guard success else { return }
|
|
||||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
|
||||||
persistedAuthenticationContexts[secret] = context
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
|
|||||||
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
|
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
|
||||||
private let _delete: @Sendable (AnySecret) async throws -> Void
|
private let _delete: @Sendable (AnySecret) async throws -> Void
|
||||||
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
||||||
private let _supportedKeyTypes: @Sendable () -> KeyAvailability
|
private let _supportedKeyTypes: @Sendable () -> [KeyType]
|
||||||
|
|
||||||
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
||||||
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
|
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
|
||||||
@@ -87,7 +87,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
|
|||||||
try await _update(secret, name, attributes)
|
try await _update(secret, name, attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var supportedKeyTypes: KeyAvailability {
|
public var supportedKeyTypes: [KeyType] {
|
||||||
_supportedKeyTypes()
|
_supportedKeyTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: CFError?
|
public let error: SecurityError?
|
||||||
|
|
||||||
/// 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 = unsafe error?.takeRetainedValue()
|
self.error = error
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ extension Data {
|
|||||||
package var lengthAndData: Data {
|
package var lengthAndData: Data {
|
||||||
let rawLength = UInt32(count)
|
let rawLength = UInt32(count)
|
||||||
var endian = rawLength.bigEndian
|
var endian = rawLength.bigEndian
|
||||||
return unsafe Data(bytes: &endian, count: MemoryLayout<UInt32>.size) + self
|
return Data(bytes: &endian, count: UInt32.bitWidth/8) + self
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import SecretKit
|
|
||||||
import SSHProtocolKit
|
|
||||||
|
|
||||||
/// Manages storage and lookup for OpenSSH certificates.
|
/// Manages storage and lookup for OpenSSH certificates.
|
||||||
public actor OpenSSHCertificateHandler: Sendable {
|
public actor OpenSSHCertificateHandler: Sendable {
|
||||||
|
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||||
private let writer = OpenSSHPublicKeyWriter()
|
private let writer = OpenSSHPublicKeyWriter()
|
||||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
||||||
@@ -27,6 +25,33 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
|
||||||
|
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
||||||
|
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
|
||||||
|
public func publicKeyHash(from hash: Data) -> Data? {
|
||||||
|
let reader = OpenSSHReader(data: hash)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
/// Generates OpenSSH representations of the public key sof secrets.
|
/// Generates OpenSSH representations of the public key sof secrets.
|
||||||
public struct OpenSSHPublicKeyWriter: Sendable {
|
public struct OpenSSHPublicKeyWriter: Sendable {
|
||||||
@@ -19,7 +18,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
|
|||||||
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
|
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
|
||||||
secret.publicKey.lengthAndData
|
secret.publicKey.lengthAndData
|
||||||
case .mldsa:
|
case .mldsa:
|
||||||
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
|
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
|
||||||
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
||||||
secret.publicKey.lengthAndData
|
secret.publicKey.lengthAndData
|
||||||
case .rsa:
|
case .rsa:
|
||||||
@@ -50,7 +49,9 @@ public struct OpenSSHPublicKeyWriter: Sendable {
|
|||||||
/// Generates an OpenSSH MD5 fingerprint string.
|
/// Generates an OpenSSH MD5 fingerprint string.
|
||||||
/// - Returns: OpenSSH MD5 fingerprint string.
|
/// - Returns: OpenSSH MD5 fingerprint string.
|
||||||
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
Insecure.MD5.hash(data: data(secret: secret)).formatted(.hex(separator: ":"))
|
Insecure.MD5.hash(data: data(secret: secret))
|
||||||
|
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
||||||
|
.joined(separator: ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func comment<SecretType: Secret>(secret: SecretType) -> String {
|
public func comment<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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() throws -> Data {
|
||||||
|
guard remaining.count > UInt32.bitWidth/8 else { throw EndOfData() }
|
||||||
|
let lengthRange = 0..<(UInt32.bitWidth/8)
|
||||||
|
let lengthChunk = remaining[lengthRange]
|
||||||
|
remaining.removeSubrange(lengthRange)
|
||||||
|
let littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self)
|
||||||
|
let length = Int(littleEndianLength.bigEndian)
|
||||||
|
let dataRange = 0..<length
|
||||||
|
let ret = Data(remaining[dataRange])
|
||||||
|
remaining.removeSubrange(dataRange)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextBytes<T>(as: T.Type) throws -> T {
|
||||||
|
let lengthRange = 0..<MemoryLayout<T>.size
|
||||||
|
let lengthChunk = remaining[lengthRange]
|
||||||
|
remaining.removeSubrange(lengthRange)
|
||||||
|
return lengthChunk.bytes.unsafeLoad(as: T.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public func readNextChunkAsString() throws -> String {
|
||||||
|
try String(decoding: readNextChunk(), as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EndOfData: Error {}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Reads OpenSSH representations of Secrets.
|
||||||
|
public struct OpenSSHSignatureReader: Sendable {
|
||||||
|
|
||||||
|
/// Initializes the reader.
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public func verify(_ signatureData: Data, for signedData: Data, with publicKey: Data) throws -> Bool {
|
||||||
|
let reader = OpenSSHReader(data: signatureData)
|
||||||
|
let signatureType = try reader.readNextChunkAsString()
|
||||||
|
let signatureData = try reader.readNextChunk()
|
||||||
|
switch signatureType {
|
||||||
|
case "ssh-rsa":
|
||||||
|
let attributes = KeychainDictionary([
|
||||||
|
kSecAttrKeyType: kSecAttrKeyTypeRSA,
|
||||||
|
kSecAttrKeySizeInBits: 2048,
|
||||||
|
kSecAttrKeyClass: kSecAttrKeyClassPublic
|
||||||
|
])
|
||||||
|
var verifyError: SecurityError?
|
||||||
|
let untyped: CFTypeRef? = SecKeyCreateWithData(publicKey as CFData, attributes, &verifyError)
|
||||||
|
guard let untypedSafe = untyped else {
|
||||||
|
throw KeychainError(statusCode: errSecSuccess)
|
||||||
|
}
|
||||||
|
let key = untypedSafe as! SecKey
|
||||||
|
return SecKeyVerifySignature(key, .rsaSignatureMessagePKCS1v15SHA512, signedData as CFData, signatureData as CFData, nil)
|
||||||
|
case "ecdsa-sha2-nistp256":
|
||||||
|
return try P256.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(.init(rawRepresentation: signatureData), for: signedData)
|
||||||
|
case "ecdsa-sha2-nistp384":
|
||||||
|
return try P384.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(.init(rawRepresentation: signatureData), for: signedData)
|
||||||
|
case "ecdsa-sha2-nistp521":
|
||||||
|
return try P521.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(.init(rawRepresentation: signatureData), for: signedData)
|
||||||
|
case "ssh-ed25519":
|
||||||
|
return try Curve25519.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(signatureData, for: signedData)
|
||||||
|
case "ssh-mldsa-65":
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
return try MLDSA65.PublicKey(rawRepresentation: publicKey).isValidSignature(signatureData, for: signedData)
|
||||||
|
} else {
|
||||||
|
throw UnsupportedSignatureType()
|
||||||
|
}
|
||||||
|
case "ssh-mldsa-87":
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
return try MLDSA87.PublicKey(rawRepresentation: publicKey).isValidSignature(signatureData, for: signedData)
|
||||||
|
} else {
|
||||||
|
throw UnsupportedSignatureType()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw UnsupportedSignatureType()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UnsupportedSignatureType: Error {}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
/// Generates OpenSSH representations of Secrets.
|
/// Generates OpenSSH representations of Secrets.
|
||||||
public struct OpenSSHSignatureWriter: Sendable {
|
public struct OpenSSHSignatureWriter: Sendable {
|
||||||
@@ -17,7 +16,7 @@ public struct OpenSSHSignatureWriter: Sendable {
|
|||||||
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||||
ecdsaSignature(signature, keyType: secret.keyType)
|
ecdsaSignature(signature, keyType: secret.keyType)
|
||||||
case .mldsa:
|
case .mldsa:
|
||||||
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
|
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms
|
||||||
mldsaSignature(signature, keyType: secret.keyType)
|
mldsaSignature(signature, keyType: secret.keyType)
|
||||||
case .rsa:
|
case .rsa:
|
||||||
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
||||||
@@ -30,28 +29,19 @@ public struct OpenSSHSignatureWriter: Sendable {
|
|||||||
|
|
||||||
extension OpenSSHSignatureWriter {
|
extension OpenSSHSignatureWriter {
|
||||||
|
|
||||||
/// Converts a fixed-width big-endian integer (e.g. r/s from CryptoKit rawRepresentation) into an SSH mpint.
|
|
||||||
/// Strips unnecessary leading zeros and prefixes `0x00` if needed to keep the value positive.
|
|
||||||
private func mpint(fromFixedWidthPositiveBytes bytes: Data) -> Data {
|
|
||||||
// mpint zero is encoded as a string with zero bytes of data.
|
|
||||||
guard let firstNonZeroIndex = bytes.firstIndex(where: { $0 != 0x00 }) else {
|
|
||||||
return Data()
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmed = Data(bytes[firstNonZeroIndex...])
|
|
||||||
|
|
||||||
if let first = trimmed.first, first >= 0x80 {
|
|
||||||
var prefixed = Data([0x00])
|
|
||||||
prefixed.append(trimmed)
|
|
||||||
return prefixed
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
||||||
let rawLength = rawRepresentation.count/2
|
let rawLength = rawRepresentation.count/2
|
||||||
let r = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[0..<rawLength]))
|
// Check if we need to pad with 0x00 to prevent certain
|
||||||
let s = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[rawLength...]))
|
// 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()
|
var signatureChunk = Data()
|
||||||
signatureChunk.append(r.lengthAndData)
|
signatureChunk.append(r.lengthAndData)
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import SecretKit
|
|
||||||
import SSHProtocolKit
|
|
||||||
import Common
|
|
||||||
|
|
||||||
/// 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: Sendable {
|
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: URL
|
private let directory: String
|
||||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
|
|
||||||
/// Initializes a PublicKeyFileStoreController.
|
/// Initializes a PublicKeyFileStoreController.
|
||||||
public init(directory: URL) {
|
public init(homeDirectory: String) {
|
||||||
self.directory = directory
|
directory = homeDirectory.appending("/PublicKeys")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes out the keys specified to disk.
|
/// Writes out the keys specified to disk.
|
||||||
@@ -22,33 +19,39 @@ public final class PublicKeyFileStoreController: Sendable {
|
|||||||
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
||||||
logger.log("Writing public keys to disk")
|
logger.log("Writing public keys to disk")
|
||||||
if clear {
|
if clear {
|
||||||
let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: directory) })
|
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(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.appending(path: $0).path() }
|
|
||||||
|
|
||||||
let untracked = Set(fullPathContents)
|
let untracked = Set(fullPathContents)
|
||||||
.subtracting(validPaths)
|
.subtracting(validPaths)
|
||||||
for path in untracked {
|
for path in untracked {
|
||||||
// string instead of fileURLWithPath since we're already using fileURL format.
|
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
|
||||||
try? FileManager.default.removeItem(at: URL(string: path)!)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
|
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let path = URL.publicKeyPath(for: secret, in: directory)
|
let path = publicKeyPath(for: secret)
|
||||||
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path for a Secret's public key.
|
||||||
|
/// - Parameter secret: The Secret to return the path for.
|
||||||
|
/// - Returns: The path to the Secret's public key.
|
||||||
|
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
||||||
|
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||||
|
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||||
|
return directory.appending("/").appending("\(minimalHex).pub")
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.path())
|
.contentsOfDirectory(atPath: directory)
|
||||||
.filter { $0.hasSuffix("-cert.pub") }
|
.filter { $0.hasSuffix("-cert.pub") }
|
||||||
.isEmpty == false
|
.isEmpty == false
|
||||||
} catch {
|
} catch {
|
||||||
@@ -62,7 +65,7 @@ public final class PublicKeyFileStoreController: Sendable {
|
|||||||
/// - 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(component: "\(minimalHex)-cert.pub").path()
|
return directory.appending("/").appending("\(minimalHex)-cert.pub")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -62,37 +62,10 @@ public protocol SecretStoreModifiable<SecretType>: SecretStore {
|
|||||||
/// - attributes: The new attributes for the secret.
|
/// - attributes: The new attributes for the secret.
|
||||||
func update(secret: SecretType, name: String, attributes: Attributes) async throws
|
func update(secret: SecretType, name: String, attributes: Attributes) async throws
|
||||||
|
|
||||||
var supportedKeyTypes: KeyAvailability { get }
|
var supportedKeyTypes: [KeyType] { get }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct KeyAvailability: Sendable {
|
|
||||||
|
|
||||||
public let available: [KeyType]
|
|
||||||
public let unavailable: [UnavailableKeyType]
|
|
||||||
|
|
||||||
public init(available: [KeyType], unavailable: [UnavailableKeyType]) {
|
|
||||||
self.available = available
|
|
||||||
self.unavailable = unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct UnavailableKeyType: Sendable {
|
|
||||||
public let keyType: KeyType
|
|
||||||
public let reason: Reason
|
|
||||||
|
|
||||||
public init(keyType: KeyType, reason: Reason) {
|
|
||||||
self.keyType = keyType
|
|
||||||
self.reason = reason
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum Reason: Sendable {
|
|
||||||
case macOSUpdateRequired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension NSNotification.Name {
|
extension NSNotification.Name {
|
||||||
|
|
||||||
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)
|
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ extension SecureEnclave {
|
|||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var privateUntyped: CFTypeRef?
|
var privateUntyped: CFTypeRef?
|
||||||
unsafe SecItemCopyMatching(privateAttributes, &privateUntyped)
|
SecItemCopyMatching(privateAttributes, &privateUntyped)
|
||||||
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
||||||
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
|
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
|
||||||
var migratedAny = false
|
var migrated = false
|
||||||
for key in privateTyped {
|
for key in privateTyped {
|
||||||
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||||
let id = key[kSecAttrApplicationLabel] as! Data
|
let id = key[kSecAttrApplicationLabel] as! Data
|
||||||
@@ -40,39 +40,35 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
let ref = key[kSecValueRef] as! SecKey
|
let ref = key[kSecValueRef] as! SecKey
|
||||||
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
|
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
|
||||||
let tokenObjectID = unsafe attributes[Constants.tokenObjectID] as! Data
|
let tokenObjectID = attributes[Constants.tokenObjectID] as! Data
|
||||||
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
|
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
|
||||||
// Best guess.
|
// Best guess.
|
||||||
let auth: AuthenticationRequirement = String(describing: accessControl)
|
let auth: AuthenticationRequirement = String(describing: accessControl)
|
||||||
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
|
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
|
||||||
do {
|
|
||||||
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
|
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))
|
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 {
|
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
|
||||||
logger.log("Skipping \(name), public key already present. Marking as migrated.")
|
logger.log("Skipping \(name), public key already present. Marking as migrated.")
|
||||||
markMigrated(secret: secret, oldID: id)
|
try markMigrated(secret: secret, oldID: id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logger.log("Migrating \(name).")
|
logger.log("Migrating \(name).")
|
||||||
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
||||||
logger.log("Migrated \(name).")
|
logger.log("Migrated \(name).")
|
||||||
markMigrated(secret: secret, oldID: id)
|
try markMigrated(secret: secret, oldID: id)
|
||||||
migratedAny = true
|
migrated = true
|
||||||
} catch {
|
|
||||||
logger.error("Failed to migrate \(name): \(error.localizedDescription).")
|
|
||||||
}
|
}
|
||||||
}
|
if migrated {
|
||||||
if migratedAny {
|
|
||||||
store.reloadSecrets()
|
store.reloadSecrets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public func markMigrated(secret: Secret, oldID: Data) {
|
public func markMigrated(secret: Secret, oldID: Data) throws {
|
||||||
let updateQuery = KeychainDictionary([
|
let updateQuery = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrApplicationLabel: oldID
|
kSecAttrApplicationLabel: secret.id
|
||||||
])
|
])
|
||||||
|
|
||||||
let newID = oldID + Constants.migrationMagicNumber
|
let newID = oldID + Constants.migrationMagicNumber
|
||||||
@@ -82,7 +78,7 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
logger.warning("Failed to mark \(secret.name) as migrated: \(status).")
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import LocalAuthentication
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
extension SecureEnclave {
|
||||||
|
|
||||||
|
/// A context describing a persisted authentication.
|
||||||
|
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
||||||
|
|
||||||
|
/// The Secret to persist authentication for.
|
||||||
|
let secret: Secret
|
||||||
|
/// The LAContext used to authorize the persistent context.
|
||||||
|
nonisolated(unsafe) let context: LAContext
|
||||||
|
/// An expiration date for the context.
|
||||||
|
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||||
|
let monotonicExpiration: UInt64
|
||||||
|
|
||||||
|
/// Initializes a context.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - secret: The Secret to persist authentication for.
|
||||||
|
/// - context: The LAContext used to authorize the persistent context.
|
||||||
|
/// - duration: The duration of the authorization context, in seconds.
|
||||||
|
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
||||||
|
self.secret = secret
|
||||||
|
self.context = context
|
||||||
|
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||||
|
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A boolean describing whether or not the context is still valid.
|
||||||
|
var valid: Bool {
|
||||||
|
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiration: Date {
|
||||||
|
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
||||||
|
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
||||||
|
return Date(timeIntervalSinceNow: remainingInSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor PersistentAuthenticationHandler: Sendable {
|
||||||
|
|
||||||
|
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
||||||
|
|
||||||
|
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
|
||||||
|
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||||
|
return persisted
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||||
|
let newContext = LAContext()
|
||||||
|
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||||
|
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
|
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.unitsStyle = .spellOut
|
||||||
|
formatter.allowedUnits = [.hour, .minute, .day]
|
||||||
|
|
||||||
|
if let durationString = formatter.string(from: duration) {
|
||||||
|
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
||||||
|
} else {
|
||||||
|
newContext.localizedReason = String(localized: .authContextPersistForDurationUnknown(secretName: secret.name))
|
||||||
|
}
|
||||||
|
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||||
|
guard success else { return }
|
||||||
|
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||||
|
persistedAuthenticationContexts[secret] = context
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import LocalAuthentication
|
@preconcurrency import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
public let name = String(localized: .secureEnclave)
|
public let name = String(localized: .secureEnclave)
|
||||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
|
||||||
|
|
||||||
/// Initializes a Store.
|
/// Initializes a Store.
|
||||||
@MainActor public init() {
|
@MainActor public init() {
|
||||||
@@ -26,7 +26,7 @@ extension SecureEnclave {
|
|||||||
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||||
guard Constants.notificationToken != (note.object as? String) else {
|
guard Constants.notificationToken != (note.object as? String) else {
|
||||||
// Don't reload if we're the ones triggering this by reloading.
|
// Don't reload if we're the ones triggering this by reloading.
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
reloadSecrets()
|
reloadSecrets()
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ extension SecureEnclave {
|
|||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
var context: LAContext
|
var context: LAContext
|
||||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||||
context = unsafe existing.context
|
context = existing.context
|
||||||
} else {
|
} else {
|
||||||
let newContext = LAContext()
|
let newContext = LAContext()
|
||||||
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||||
@@ -57,7 +57,7 @@ extension SecureEnclave {
|
|||||||
kSecReturnData: true,
|
kSecReturnData: true,
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
let status = unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
let status = SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ extension SecureEnclave {
|
|||||||
var accessError: SecurityError?
|
var accessError: SecurityError?
|
||||||
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
|
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
|
||||||
case .notRequired:
|
case .notRequired:
|
||||||
[.privateKeyUsage]
|
[]
|
||||||
case .presenceRequired:
|
case .presenceRequired:
|
||||||
[.userPresence, .privateKeyUsage]
|
[.userPresence, .privateKeyUsage]
|
||||||
case .biometryCurrent:
|
case .biometryCurrent:
|
||||||
@@ -121,12 +121,12 @@ extension SecureEnclave {
|
|||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
let access =
|
let access =
|
||||||
unsafe SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
||||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
flags,
|
flags,
|
||||||
&accessError)
|
&accessError)
|
||||||
if let error = unsafe accessError {
|
if let error = accessError {
|
||||||
throw unsafe error.takeRetainedValue() as Error
|
throw error.takeRetainedValue() as Error
|
||||||
}
|
}
|
||||||
let dataRep: Data
|
let dataRep: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
@@ -186,22 +186,17 @@ extension SecureEnclave {
|
|||||||
await reloadSecrets()
|
await reloadSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
public let supportedKeyTypes: KeyAvailability = {
|
public var supportedKeyTypes: [KeyType] {
|
||||||
let macOS26Keys: [KeyType] = [.mldsa65, .mldsa87]
|
if #available(macOS 26, *) {
|
||||||
let isAtLeastMacOS26 = if #available(macOS 26, *) {
|
[
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
return KeyAvailability(
|
|
||||||
available: [
|
|
||||||
.ecdsa256,
|
.ecdsa256,
|
||||||
] + (isAtLeastMacOS26 ? macOS26Keys : []),
|
.mldsa65,
|
||||||
unavailable: (isAtLeastMacOS26 ? [] : macOS26Keys).map {
|
.mldsa87,
|
||||||
KeyAvailability.UnavailableKeyType(keyType: $0, reason: .macOSUpdateRequired)
|
]
|
||||||
|
} else {
|
||||||
|
[.ecdsa256]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -219,7 +214,7 @@ extension SecureEnclave.Store {
|
|||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
|
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
@unsafe @preconcurrency import CryptoTokenKit
|
@preconcurrency import CryptoTokenKit
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ extension SmartCard {
|
|||||||
public var secrets: [Secret] {
|
public var secrets: [Secret] {
|
||||||
state.secrets
|
state.secrets
|
||||||
}
|
}
|
||||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
|
||||||
|
|
||||||
/// Initializes a Store.
|
/// Initializes a Store.
|
||||||
public init() {
|
public init() {
|
||||||
@@ -59,15 +58,9 @@ extension SmartCard {
|
|||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
guard let tokenID = await state.tokenID else { fatalError() }
|
guard let tokenID = await state.tokenID else { fatalError() }
|
||||||
var context: LAContext
|
let context = LAContext()
|
||||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||||
context = unsafe existing.context
|
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
} else {
|
|
||||||
let newContext = LAContext()
|
|
||||||
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
|
||||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
|
||||||
context = newContext
|
|
||||||
}
|
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
@@ -77,7 +70,7 @@ extension SmartCard {
|
|||||||
kSecReturnRef: true
|
kSecReturnRef: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
let status = unsafe SecItemCopyMatching(attributes, &untyped)
|
let status = SecItemCopyMatching(attributes, &untyped)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
@@ -87,18 +80,17 @@ extension SmartCard {
|
|||||||
let key = untypedSafe as! SecKey
|
let key = untypedSafe as! SecKey
|
||||||
var signError: SecurityError?
|
var signError: SecurityError?
|
||||||
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
|
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
|
||||||
guard let signature = unsafe SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
|
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
|
||||||
throw unsafe SigningError(error: signError)
|
throw SigningError(error: signError)
|
||||||
}
|
}
|
||||||
return signature as Data
|
return signature as Data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
||||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
|
||||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads all secrets from the store.
|
/// Reloads all secrets from the store.
|
||||||
@@ -160,7 +152,7 @@ extension SmartCard.Store {
|
|||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
unsafe SecItemCopyMatching(attributes, &untyped)
|
SecItemCopyMatching(attributes, &untyped)
|
||||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let wrapped: [SecretType] = typed.compactMap {
|
let wrapped: [SecretType] = typed.compactMap {
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||||
@@ -171,7 +163,7 @@ extension SmartCard.Store {
|
|||||||
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
|
||||||
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .presenceRequired)
|
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
|
||||||
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
|
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
|
||||||
guard signatureAlgorithm(for: secret) != nil else { return nil }
|
guard signatureAlgorithm(for: secret) != nil else { return nil }
|
||||||
return secret
|
return secret
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension ProcessInfo {
|
|
||||||
private static let fallbackTeamID = "Z72PRUAWF6"
|
|
||||||
|
|
||||||
private static let teamID: String = {
|
|
||||||
#if DEBUG
|
|
||||||
guard let task = SecTaskCreateFromSelf(nil) else {
|
|
||||||
assertionFailure("SecTaskCreateFromSelf failed")
|
|
||||||
return fallbackTeamID
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let value = SecTaskCopyValueForEntitlement(task, "com.apple.developer.team-identifier" as CFString, nil) as? String else {
|
|
||||||
assertionFailure("SecTaskCopyValueForEntitlement(com.apple.developer.team-identifier) failed")
|
|
||||||
return fallbackTeamID
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
#else
|
|
||||||
/// Always use hardcoded team ID for release builds, just in case.
|
|
||||||
return fallbackTeamID
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
|
|
||||||
public var teamID: String { Self.teamID }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
@objc protocol _XPCProtocol: Sendable {
|
|
||||||
func process(_ data: Data, with reply: @Sendable @escaping (Data?, Error?) -> Void)
|
|
||||||
}
|
|
||||||
|
|
||||||
public protocol XPCProtocol<Input, Output>: Sendable {
|
|
||||||
|
|
||||||
associatedtype Input: Codable
|
|
||||||
associatedtype Output: Codable
|
|
||||||
|
|
||||||
func process(_ data: Input) async throws -> Output
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
|
|
||||||
|
|
||||||
private let exportedObject: ErasedXPCProtocol
|
|
||||||
|
|
||||||
public init<XPCProtocolType: XPCProtocol>(exportedObject: XPCProtocolType) {
|
|
||||||
self.exportedObject = ErasedXPCProtocol(exportedObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
|
|
||||||
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
|
||||||
let exportedObject = exportedObject
|
|
||||||
newConnection.exportedObject = exportedObject
|
|
||||||
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
|
|
||||||
newConnection.resume()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private final class ErasedXPCProtocol: NSObject, _XPCProtocol {
|
|
||||||
|
|
||||||
let _process: @Sendable (Data, @Sendable @escaping (Data?, (any Error)?) -> Void) -> Void
|
|
||||||
|
|
||||||
public init<XPCProtocolType: XPCProtocol>(_ exportedObject: XPCProtocolType) {
|
|
||||||
_process = { data, reply in
|
|
||||||
Task { [reply] in
|
|
||||||
do {
|
|
||||||
let decoded = try JSONDecoder().decode(XPCProtocolType.Input.self, from: data)
|
|
||||||
let result = try await exportedObject.process(decoded)
|
|
||||||
let encoded = try JSONEncoder().encode(result)
|
|
||||||
reply(encoded, nil)
|
|
||||||
} catch {
|
|
||||||
if let error = error as? Codable & Error {
|
|
||||||
reply(nil, NSError(error))
|
|
||||||
} else {
|
|
||||||
// Sending cast directly tries to serialize it and crashes XPCEncoder.
|
|
||||||
let cast = error as NSError
|
|
||||||
reply(nil, NSError(domain: cast.domain, code: cast.code, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func process(_ data: Data, with reply: @Sendable @escaping (Data?, (any Error)?) -> Void) {
|
|
||||||
_process(data, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NSError {
|
|
||||||
|
|
||||||
private enum Constants {
|
|
||||||
static let domain = "com.maxgoedjen.secretive.xpcwrappers"
|
|
||||||
static let code = -1
|
|
||||||
static let dataKey = "underlying"
|
|
||||||
}
|
|
||||||
|
|
||||||
@nonobjc convenience init<ErrorType: Codable & Error>(_ error: ErrorType) {
|
|
||||||
let encoded = try? JSONEncoder().encode(error)
|
|
||||||
self.init(domain: Constants.domain, code: Constants.code, userInfo: [Constants.dataKey: encoded as Any])
|
|
||||||
}
|
|
||||||
|
|
||||||
@nonobjc public func underlying<ErrorType: Codable & Error>(as errorType: ErrorType.Type) -> ErrorType? {
|
|
||||||
guard domain == Constants.domain && code == Constants.code, let data = userInfo[Constants.dataKey] as? Data else { return nil }
|
|
||||||
return try? JSONDecoder().decode(ErrorType.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error & Codable>: ~Copyable {
|
|
||||||
|
|
||||||
private let connection: NSXPCConnection
|
|
||||||
private let proxy: _XPCProtocol
|
|
||||||
|
|
||||||
public init(serviceName: String, warmup: Bool = false) async throws {
|
|
||||||
let connection = NSXPCConnection(serviceName: serviceName)
|
|
||||||
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
|
||||||
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
|
|
||||||
connection.resume()
|
|
||||||
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
|
|
||||||
self.connection = connection
|
|
||||||
self.proxy = proxy
|
|
||||||
if warmup {
|
|
||||||
_ = try? await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func send(_ message: some Encodable = Data()) async throws -> ResponseType {
|
|
||||||
let encoded = try JSONEncoder().encode(message)
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
proxy.process(encoded) { data, error in
|
|
||||||
do {
|
|
||||||
if let error {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
guard let data else {
|
|
||||||
throw NoDataError()
|
|
||||||
}
|
|
||||||
let decoded = try JSONDecoder().decode(ResponseType.self, from: data)
|
|
||||||
continuation.resume(returning: decoded)
|
|
||||||
} catch {
|
|
||||||
if let typed = (error as NSError).underlying(as: ErrorType.self) {
|
|
||||||
continuation.resume(throwing: typed)
|
|
||||||
} else {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public func complete() {
|
|
||||||
connection.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct NoDataError: Error {}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
import SSHProtocolKit
|
|
||||||
@testable import SecretKit
|
|
||||||
|
|
||||||
@Suite struct OpenSSHSignatureWriterTests {
|
|
||||||
|
|
||||||
private let writer = OpenSSHSignatureWriter()
|
|
||||||
|
|
||||||
@Test func ecdsaMpintStripsUnnecessaryLeadingZeros() throws {
|
|
||||||
let secret = Constants.ecdsa256Secret
|
|
||||||
|
|
||||||
// r has a leading 0x00 followed by 0x01 (< 0x80): the mpint must not keep the leading zero.
|
|
||||||
let rBytes: [UInt8] = [0x00] + (1...31).map { UInt8($0) }
|
|
||||||
let r = Data(rBytes)
|
|
||||||
// s has two leading 0x00 bytes followed by 0x7f (< 0x80): the mpint must not keep the leading zeros.
|
|
||||||
let sBytes: [UInt8] = [0x00, 0x00, 0x7f] + Array(repeating: UInt8(0x01), count: 29)
|
|
||||||
let s = Data(sBytes)
|
|
||||||
let rawRepresentation = r + s
|
|
||||||
|
|
||||||
let response = writer.data(secret: secret, signature: rawRepresentation)
|
|
||||||
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
|
|
||||||
|
|
||||||
#expect(parsedR == Data((1...31).map { UInt8($0) }))
|
|
||||||
#expect(parsedS == Data([0x7f] + Array(repeating: UInt8(0x01), count: 29)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func ecdsaMpintPrefixesZeroWhenHighBitSet() throws {
|
|
||||||
let secret = Constants.ecdsa256Secret
|
|
||||||
|
|
||||||
// r starts with 0x80 (high bit set): mpint must be prefixed with 0x00.
|
|
||||||
let r = Data([UInt8(0x80)] + Array(repeating: UInt8(0x01), count: 31))
|
|
||||||
let s = Data([UInt8(0x01)] + Array(repeating: UInt8(0x02), count: 31))
|
|
||||||
let rawRepresentation = r + s
|
|
||||||
|
|
||||||
let response = writer.data(secret: secret, signature: rawRepresentation)
|
|
||||||
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
|
|
||||||
|
|
||||||
#expect(parsedR == Data([0x00, 0x80] + Array(repeating: UInt8(0x01), count: 31)))
|
|
||||||
#expect(parsedS == Data([0x01] + Array(repeating: UInt8(0x02), count: 31)))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension OpenSSHSignatureWriterTests {
|
|
||||||
|
|
||||||
enum Constants {
|
|
||||||
static let ecdsa256Secret = TestSecret(
|
|
||||||
id: Data(),
|
|
||||||
name: "Test Key (ECDSA 256)",
|
|
||||||
publicKey: Data(repeating: 0x01, count: 65),
|
|
||||||
attributes: Attributes(
|
|
||||||
keyType: KeyType(algorithm: .ecdsa, size: 256),
|
|
||||||
authentication: .notRequired,
|
|
||||||
publicKeyAttribution: "test@example.com"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ParseError: Error {
|
|
||||||
case eof
|
|
||||||
case invalidAlgorithm
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseEcdsaSignatureMpints(from openSSHSignedData: Data) throws -> (r: Data, s: Data) {
|
|
||||||
let reader = OpenSSHReader(data: openSSHSignedData)
|
|
||||||
|
|
||||||
// Prefix
|
|
||||||
_ = try reader.readNextBytes(as: UInt32.self)
|
|
||||||
|
|
||||||
let algorithm = try reader.readNextChunkAsString()
|
|
||||||
guard algorithm == "ecdsa-sha2-nistp256" else {
|
|
||||||
throw ParseError.invalidAlgorithm
|
|
||||||
}
|
|
||||||
|
|
||||||
let sigReader = try reader.readNextChunkAsSubReader()
|
|
||||||
let r = try sigReader.readNextChunk()
|
|
||||||
let s = try sigReader.readNextChunk()
|
|
||||||
return (r, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
public struct TestSecret: SecretKit.Secret {
|
|
||||||
|
|
||||||
public let id: Data
|
|
||||||
public let name: String
|
|
||||||
public let publicKey: Data
|
|
||||||
public var attributes: Attributes
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
@testable import SSHProtocolKit
|
|
||||||
@testable import SecretKit
|
@testable import SecretKit
|
||||||
@testable import SecretAgentKit
|
@testable import SecretAgentKit
|
||||||
|
|
||||||
@@ -9,22 +8,19 @@ import CryptoKit
|
|||||||
|
|
||||||
// MARK: Identity Listing
|
// MARK: Identity Listing
|
||||||
|
|
||||||
|
|
||||||
|
// let testProvenance = SigningRequestProvenance(root: .init(pid: 0, processName: "Test", appName: "Test", iconURL: nil, path: /, validSignature: true, parentPID: nil))
|
||||||
|
|
||||||
@Test func emptyStores() async throws {
|
@Test func emptyStores() async throws {
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
|
||||||
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func identitiesList() async throws {
|
@Test func identitiesList() async throws {
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
||||||
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)
|
#expect(response == Constants.Responses.requestIdentitiesMultiple)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,42 +29,40 @@ import CryptoKit
|
|||||||
@Test func noMatchingIdentities() async throws {
|
@Test func noMatchingIdentities() async throws {
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
|
let response = try await agent.handle(data: Constants.Requests.requestSignatureWithNoneMatching, provenance: .test)
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ecdsaSignature() async throws {
|
// @Test func ecdsaSignature() async throws {
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
// let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||||
guard case SSHAgent.Request.signRequest(let context) = request else { return }
|
// let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
// _ = requestReader.readNextChunk()
|
||||||
let agent = Agent(storeList: list)
|
// let dataToSign = requestReader.readNextChunk()
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
// let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let responseReader = OpenSSHReader(data: response)
|
// let agent = Agent(storeList: list)
|
||||||
let length = try responseReader.readNextBytes(as: UInt32.self)
|
// await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
let type = try responseReader.readNextBytes(as: UInt8.self)
|
// let outer = OpenSSHReader(data: stubWriter.data[5...])
|
||||||
#expect(length == response.count - MemoryLayout<UInt32>.size)
|
// let payload = outer.readNextChunk()
|
||||||
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
|
// let inner = OpenSSHReader(data: payload)
|
||||||
let outer = OpenSSHReader(data: responseReader.remaining)
|
// _ = inner.readNextChunk()
|
||||||
let inner = try outer.readNextChunkAsSubReader()
|
// let signedData = inner.readNextChunk()
|
||||||
_ = try inner.readNextChunk()
|
// let rsData = OpenSSHReader(data: signedData)
|
||||||
let rsData = try inner.readNextChunkAsSubReader()
|
// var r = rsData.readNextChunk()
|
||||||
var r = try rsData.readNextChunk()
|
// var s = 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()
|
// }
|
||||||
}
|
// if s[0] == 0 {
|
||||||
if s[0] == 0 {
|
// s.removeFirst()
|
||||||
s.removeFirst()
|
// }
|
||||||
}
|
// 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)
|
// // Correct signature
|
||||||
// Correct signature
|
// #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||||
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
// .isValidSignature(signature, for: dataToSign))
|
||||||
.isValidSignature(signature, for: context.dataToSign))
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Witness protocol
|
// MARK: Witness protocol
|
||||||
|
|
||||||
@@ -78,7 +72,7 @@ import CryptoKit
|
|||||||
return true
|
return true
|
||||||
}, witness: { _, _ in })
|
}, witness: { _, _ in })
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
|
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +85,7 @@ import CryptoKit
|
|||||||
witnessed = true
|
witnessed = true
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
_ = await agent.handle(request: request, provenance: .test)
|
|
||||||
#expect(witnessed)
|
#expect(witnessed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +100,7 @@ import CryptoKit
|
|||||||
witnessTrace = trace
|
witnessTrace = trace
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
_ = await agent.handle(request: request, provenance: .test)
|
|
||||||
#expect(witnessTrace == speakNowTrace)
|
#expect(witnessTrace == speakNowTrace)
|
||||||
#expect(witnessTrace == .test)
|
#expect(witnessTrace == .test)
|
||||||
}
|
}
|
||||||
@@ -120,8 +112,7 @@ import CryptoKit
|
|||||||
let store = await list.stores.first?.base as! Stub.Store
|
let store = await list.stores.first?.base as! Stub.Store
|
||||||
store.shouldThrow = true
|
store.shouldThrow = true
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +120,7 @@ import CryptoKit
|
|||||||
|
|
||||||
@Test func unhandledAdd() async throws {
|
@Test func unhandledAdd() async throws {
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
let response = await agent.handle(request: .addIdentity, provenance: .test)
|
let response = try await agent.handle(data: Constants.Requests.addIdentity, provenance: .test)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +146,14 @@ 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: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
|
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")!
|
||||||
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import SSHProtocolKit
|
|
||||||
|
|
||||||
struct Stub {}
|
struct Stub {}
|
||||||
|
|
||||||
@@ -83,7 +82,7 @@ extension Stub {
|
|||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
|
|
||||||
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
||||||
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired, publicKeyAttribution: "ecdsa-\(keySize)@example.com")
|
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired)
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import SecretKit
|
@testable import SecretKit
|
||||||
import SSHProtocolKit
|
@testable import SecureEnclaveSecretKit
|
||||||
|
@testable import SmartCardSecretKit
|
||||||
|
|
||||||
@Suite struct OpenSSHPublicKeyWriterTests {
|
@Suite struct OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
@@ -46,8 +47,8 @@ import SSHProtocolKit
|
|||||||
extension OpenSSHPublicKeyWriterTests {
|
extension OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let ecdsa256Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
static let 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 = TestSecret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import SSHProtocolKit
|
@testable import SecretKit
|
||||||
|
@testable import SecureEnclaveSecretKit
|
||||||
|
@testable import SmartCardSecretKit
|
||||||
|
|
||||||
@Suite struct OpenSSHReaderTests {
|
@Suite struct OpenSSHReaderTests {
|
||||||
|
|
||||||
@Test func signatureRequest() throws {
|
@Test func signatureRequest() {
|
||||||
let reader = OpenSSHReader(data: Constants.signatureRequest)
|
let reader = OpenSSHReader(data: Constants.signatureRequest)
|
||||||
let hash = try reader.readNextChunk()
|
let hash = reader.readNextChunk()
|
||||||
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
|
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
|
||||||
let dataToSign = try reader.readNextChunk()
|
let dataToSign = reader.readNextChunk()
|
||||||
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
|
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
|
||||||
let empty = try reader.readNextChunk()
|
let empty = reader.readNextChunk()
|
||||||
#expect(empty.isEmpty)
|
#expect(empty.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ import SmartCardSecretKit
|
|||||||
import SecretAgentKit
|
import SecretAgentKit
|
||||||
import Brief
|
import Brief
|
||||||
import Observation
|
import Observation
|
||||||
import Common
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
@@ -22,12 +21,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}()
|
}()
|
||||||
private let updater = Updater(checkOnLaunch: true)
|
private let updater = Updater(checkOnLaunch: true)
|
||||||
private let notifier = Notifier()
|
private let notifier = Notifier()
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||||
private lazy var agent: Agent = {
|
private lazy var agent: Agent = {
|
||||||
Agent(storeList: storeList, witness: notifier)
|
Agent(storeList: storeList, witness: notifier)
|
||||||
}()
|
}()
|
||||||
private lazy var socketController: SocketController = {
|
private lazy var socketController: SocketController = {
|
||||||
let path = URL.socketPath as String
|
let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String
|
||||||
return SocketController(path: path)
|
return SocketController(path: path)
|
||||||
}()
|
}()
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
|
||||||
@@ -37,12 +36,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
Task {
|
Task {
|
||||||
for await session in socketController.sessions {
|
for await session in socketController.sessions {
|
||||||
Task {
|
Task {
|
||||||
let inputParser = try await XPCAgentInputParser()
|
|
||||||
do {
|
do {
|
||||||
for await message in session.messages {
|
for await message in session.messages {
|
||||||
let request = try await inputParser.parse(data: message)
|
let agentResponse = try await agent.handle(data: message, provenance: session.provenance)
|
||||||
let agentResponse = await agent.handle(request: request, provenance: session.provenance)
|
try await session.write(agentResponse)
|
||||||
try session.write(agentResponse)
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
try session.close()
|
try session.close()
|
||||||
@@ -61,7 +58,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
updater.update
|
updater.update
|
||||||
} onChange: { [updater, notifier] in
|
} onChange: { [updater, notifier] in
|
||||||
Task {
|
Task {
|
||||||
guard !updater.currentVersion.isTestBuild else { return }
|
guard !updater.testBuild else { return }
|
||||||
await notifier.notify(update: updater.update!) { release in
|
await notifier.notify(update: updater.update!) { release in
|
||||||
await updater.ignore(release: release)
|
await updater.ignore(release: release)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,22 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -2,24 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.hardened-process</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.checked-allocations</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
|
|
||||||
<string>1</string>
|
|
||||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.smartcard</key>
|
<key>com.apple.security.smartcard</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
|
||||||
<string>2</string>
|
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>
|
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SecretAgentKit
|
|
||||||
import Brief
|
|
||||||
import XPCWrappers
|
|
||||||
import OSLog
|
|
||||||
import SSHProtocolKit
|
|
||||||
|
|
||||||
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
|
|
||||||
public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "XPCAgentInputParser")
|
|
||||||
private let session: XPCTypedSession<SSHAgent.Request, SSHAgentInputParser.AgentParsingError>
|
|
||||||
|
|
||||||
public init() async throws {
|
|
||||||
logger.debug("Creating XPCAgentInputParser")
|
|
||||||
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentInputParser", warmup: true)
|
|
||||||
logger.debug("XPCAgentInputParser is warmed up.")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func parse(data: Data) async throws -> SSHAgent.Request {
|
|
||||||
logger.debug("Parsing input")
|
|
||||||
defer { logger.debug("Parsed input") }
|
|
||||||
return try await session.send(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
session.complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>XPCService</key>
|
|
||||||
<dict>
|
|
||||||
<key>ServiceType</key>
|
|
||||||
<string>Application</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.hardened-process</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.checked-allocations</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
|
|
||||||
<string>1</string>
|
|
||||||
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
|
||||||
<string>2</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import XPCWrappers
|
|
||||||
import SecretAgentKit
|
|
||||||
import SSHProtocolKit
|
|
||||||
|
|
||||||
final class SecretAgentInputParser: NSObject, XPCProtocol {
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretAgentInputParser", category: "SecretAgentInputParser")
|
|
||||||
|
|
||||||
func process(_ data: Data) async throws -> SSHAgent.Request {
|
|
||||||
let parser = SSHAgentInputParser()
|
|
||||||
let result = try parser.parse(data: data)
|
|
||||||
logger.log("Parser parsed message as type \(result.debugDescription)")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import XPCWrappers
|
|
||||||
|
|
||||||
let delegate = XPCServiceDelegate(exportedObject: SecretAgentInputParser())
|
|
||||||
let listener = NSXPCListener.service()
|
|
||||||
listener.delegate = delegate
|
|
||||||
listener.resume()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2640"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<TestPlans>
|
|
||||||
<TestPlanReference
|
|
||||||
reference = "container:Config/Secretive.xctestplan"
|
|
||||||
default = "YES">
|
|
||||||
</TestPlanReference>
|
|
||||||
</TestPlans>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2640"
|
LastUpgradeVersion = "2600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2640"
|
LastUpgradeVersion = "2600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -4,88 +4,6 @@ import SecureEnclaveSecretKit
|
|||||||
import SmartCardSecretKit
|
import SmartCardSecretKit
|
||||||
import Brief
|
import Brief
|
||||||
|
|
||||||
@main
|
|
||||||
struct Secretive: App {
|
|
||||||
|
|
||||||
@Environment(\.agentLaunchController) var agentLaunchController
|
|
||||||
@Environment(\.justUpdatedChecker) var justUpdatedChecker
|
|
||||||
|
|
||||||
@SceneBuilder var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView()
|
|
||||||
.environment(EnvironmentValues._secretStoreList)
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
|
||||||
Task {
|
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
|
||||||
@AppStorage("explicitlyDisabled") var explicitlyDisabled = false
|
|
||||||
guard hasRunSetup && !explicitlyDisabled else { return }
|
|
||||||
agentLaunchController.check()
|
|
||||||
guard !agentLaunchController.developmentBuild else { return }
|
|
||||||
if justUpdatedChecker.justUpdatedBuild || !agentLaunchController.running {
|
|
||||||
// Relaunch the agent, since it'll be running from earlier update still
|
|
||||||
try await agentLaunchController.forceLaunch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.commands {
|
|
||||||
AppCommands()
|
|
||||||
}
|
|
||||||
WindowGroup(id: String(describing: IntegrationsView.self)) {
|
|
||||||
IntegrationsView()
|
|
||||||
}
|
|
||||||
.windowResizability(.contentMinSize)
|
|
||||||
WindowGroup(id: String(describing: AboutView.self)) {
|
|
||||||
AboutView()
|
|
||||||
}
|
|
||||||
.windowStyle(.hiddenTitleBar)
|
|
||||||
.windowResizability(.contentSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Secretive {
|
|
||||||
|
|
||||||
struct AppCommands: Commands {
|
|
||||||
|
|
||||||
@Environment(\.openWindow) var openWindow
|
|
||||||
@Environment(\.openURL) var openURL
|
|
||||||
@FocusedValue(\.showCreateSecret) var showCreateSecret
|
|
||||||
|
|
||||||
var body: some Commands {
|
|
||||||
CommandGroup(replacing: .appInfo) {
|
|
||||||
Button(.aboutMenuBarTitle, systemImage: "info.circle") {
|
|
||||||
openWindow(id: String(describing: AboutView.self))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CommandGroup(before: CommandGroupPlacement.appSettings) {
|
|
||||||
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
|
|
||||||
openWindow(id: String(describing: IntegrationsView.self))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CommandGroup(after: CommandGroupPlacement.newItem) {
|
|
||||||
Button(.appMenuNewSecretButton, systemImage: "plus") {
|
|
||||||
showCreateSecret?()
|
|
||||||
}
|
|
||||||
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
|
||||||
.disabled(showCreateSecret?.isEnabled == false)
|
|
||||||
}
|
|
||||||
CommandGroup(replacing: .help) {
|
|
||||||
Button(.appMenuHelpButton) {
|
|
||||||
openURL(Constants.helpURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SidebarCommands()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum Constants {
|
|
||||||
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
|
|
||||||
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
||||||
@@ -99,38 +17,98 @@ extension EnvironmentValues {
|
|||||||
return list
|
return list
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let _agentLaunchController = AgentLaunchController()
|
private static let _agentStatusChecker = AgentStatusChecker()
|
||||||
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
|
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
|
||||||
private static let _updater: any UpdaterProtocol = {
|
private static let _updater: any UpdaterProtocol = {
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
return Updater(checkOnLaunch: hasRunSetup)
|
return Updater(checkOnLaunch: hasRunSetup)
|
||||||
}()
|
}()
|
||||||
@Entry var updater: any UpdaterProtocol = _updater
|
@Entry var updater: any UpdaterProtocol = _updater
|
||||||
|
|
||||||
private static let _justUpdatedChecker = JustUpdatedChecker()
|
|
||||||
@Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker
|
|
||||||
|
|
||||||
@MainActor var secretStoreList: SecretStoreList {
|
@MainActor var secretStoreList: SecretStoreList {
|
||||||
EnvironmentValues._secretStoreList
|
EnvironmentValues._secretStoreList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FocusedValues {
|
@main
|
||||||
@Entry var showCreateSecret: OpenSheet?
|
struct Secretive: App {
|
||||||
}
|
|
||||||
|
|
||||||
final class OpenSheet {
|
private let justUpdatedChecker = JustUpdatedChecker()
|
||||||
|
@Environment(\.agentStatusChecker) var agentStatusChecker
|
||||||
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
|
@State private var showingSetup = false
|
||||||
|
@State private var showingCreation = false
|
||||||
|
|
||||||
let closure: () -> Void
|
@SceneBuilder var body: some Scene {
|
||||||
let isEnabled: Bool
|
WindowGroup {
|
||||||
|
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
|
||||||
init(isEnabled: Bool = true, closure: @escaping () -> Void) {
|
.environment(EnvironmentValues._secretStoreList)
|
||||||
self.isEnabled = isEnabled
|
.onAppear {
|
||||||
self.closure = closure
|
if !hasRunSetup {
|
||||||
|
showingSetup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
guard hasRunSetup else { return }
|
||||||
|
agentStatusChecker.check()
|
||||||
|
if agentStatusChecker.running && justUpdatedChecker.justUpdated {
|
||||||
|
// Relaunch the agent, since it'll be running from earlier update still
|
||||||
|
reinstallAgent()
|
||||||
|
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
|
||||||
|
forceLaunchAgent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.commands {
|
||||||
|
CommandGroup(after: CommandGroupPlacement.newItem) {
|
||||||
|
Button(.appMenuNewSecretButton) {
|
||||||
|
showingCreation = true
|
||||||
|
}
|
||||||
|
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
||||||
|
}
|
||||||
|
CommandGroup(replacing: .help) {
|
||||||
|
Button(.appMenuHelpButton) {
|
||||||
|
NSWorkspace.shared.open(Constants.helpURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandGroup(after: .help) {
|
||||||
|
Button(.appMenuSetupButton) {
|
||||||
|
showingSetup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SidebarCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
func callAsFunction() {
|
|
||||||
closure()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Secretive {
|
||||||
|
|
||||||
|
private func reinstallAgent() {
|
||||||
|
justUpdatedChecker.check()
|
||||||
|
Task {
|
||||||
|
await LaunchAgentController().install()
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
agentStatusChecker.check()
|
||||||
|
if !agentStatusChecker.running {
|
||||||
|
forceLaunchAgent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forceLaunchAgent() {
|
||||||
|
// We've run setup, we didn't just update, launchd is just not doing it's thing.
|
||||||
|
// Force a launch directly.
|
||||||
|
Task {
|
||||||
|
_ = await LaunchAgentController().forceLaunch()
|
||||||
|
agentStatusChecker.check()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private enum Constants {
|
||||||
|
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-16x16@1x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-16x16@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-32x32@1x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-32x32@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-128x128@1x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-128x128@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-256x256@1x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-256x256@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-512x512@1x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Icon-macOS-ClearDark-1024x1024@1x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 356 KiB |
6
Sources/Secretive/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,16 @@ import Foundation
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import Observation
|
import Observation
|
||||||
import OSLog
|
|
||||||
import ServiceManagement
|
|
||||||
import Common
|
|
||||||
|
|
||||||
@MainActor protocol AgentLaunchControllerProtocol: Observable, Sendable {
|
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
|
||||||
var running: Bool { get }
|
var running: Bool { get }
|
||||||
var developmentBuild: Bool { get }
|
var developmentBuild: Bool { get }
|
||||||
var process: NSRunningApplication? { get }
|
|
||||||
func check()
|
func check()
|
||||||
func install() async throws
|
|
||||||
func uninstall() async throws
|
|
||||||
func forceLaunch() async throws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable @MainActor final class AgentLaunchController: AgentLaunchControllerProtocol {
|
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
|
||||||
|
|
||||||
var running: Bool = false
|
var running: Bool = false
|
||||||
var process: NSRunningApplication? = nil
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
|
|
||||||
private let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
|
|
||||||
|
|
||||||
nonisolated init() {
|
nonisolated init() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -30,80 +20,32 @@ import Common
|
|||||||
}
|
}
|
||||||
|
|
||||||
func check() {
|
func check() {
|
||||||
process = instanceSecretAgentProcess
|
running = instanceSecretAgentProcess != nil
|
||||||
running = process != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All processes, including ones from older versions, etc
|
// All processes, including ones from older versions, etc
|
||||||
var allSecretAgentProcesses: [NSRunningApplication] {
|
var secretAgentProcesses: [NSRunningApplication] {
|
||||||
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.agentBundleID)
|
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.agentBundleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The process corresponding to this instance of Secretive
|
// The process corresponding to this instance of Secretive
|
||||||
var instanceSecretAgentProcess: NSRunningApplication? {
|
var instanceSecretAgentProcess: NSRunningApplication? {
|
||||||
// TODO: CHECK VERSION
|
let agents = secretAgentProcesses
|
||||||
let agents = allSecretAgentProcesses
|
|
||||||
for agent in agents {
|
for agent in agents {
|
||||||
guard let url = agent.bundleURL else { continue }
|
guard let url = agent.bundleURL else { continue }
|
||||||
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) || (url.isXcodeURL && developmentBuild) {
|
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) {
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Whether Secretive is being run in an Xcode environment.
|
// Whether Secretive is being run in an Xcode environment.
|
||||||
var developmentBuild: Bool {
|
var developmentBuild: Bool {
|
||||||
Bundle.main.bundleURL.isXcodeURL
|
Bundle.main.bundleURL.absoluteString.contains("/Library/Developer/Xcode")
|
||||||
}
|
|
||||||
|
|
||||||
func install() async throws {
|
|
||||||
logger.debug("Installing agent")
|
|
||||||
try? await service.unregister()
|
|
||||||
// This is definitely a bit of a "seems to work better" thing but:
|
|
||||||
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
|
|
||||||
// and start new?
|
|
||||||
try await Task.sleep(for: .seconds(1))
|
|
||||||
try service.register()
|
|
||||||
try await Task.sleep(for: .seconds(1))
|
|
||||||
check()
|
|
||||||
}
|
|
||||||
|
|
||||||
func uninstall() async throws {
|
|
||||||
logger.debug("Uninstalling agent")
|
|
||||||
try await Task.sleep(for: .seconds(1))
|
|
||||||
try await service.unregister()
|
|
||||||
try await Task.sleep(for: .seconds(1))
|
|
||||||
check()
|
|
||||||
}
|
|
||||||
|
|
||||||
func forceLaunch() async throws {
|
|
||||||
logger.debug("Agent is not running, attempting to force launch by reinstalling")
|
|
||||||
try await install()
|
|
||||||
if running {
|
|
||||||
logger.debug("Agent successfully force launched by reinstalling")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.debug("Agent is not running, attempting to force launch by launching directly")
|
|
||||||
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
|
|
||||||
let config = NSWorkspace.OpenConfiguration()
|
|
||||||
config.activates = false
|
|
||||||
do {
|
|
||||||
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
|
|
||||||
logger.debug("Agent force launched")
|
|
||||||
try await Task.sleep(for: .seconds(1))
|
|
||||||
} catch {
|
|
||||||
logger.error("Error force launching \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension URL {
|
|
||||||
|
|
||||||
var isXcodeURL: Bool {
|
|
||||||
absoluteString.contains("/Library/Developer/Xcode")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
@MainActor protocol JustUpdatedCheckerProtocol: Observable {
|
protocol JustUpdatedCheckerProtocol: Observable {
|
||||||
var justUpdatedBuild: Bool { get }
|
var justUpdated: Bool { get }
|
||||||
var justUpdatedOS: Bool { get }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable @MainActor class JustUpdatedChecker: JustUpdatedCheckerProtocol {
|
@Observable class JustUpdatedChecker: JustUpdatedCheckerProtocol {
|
||||||
|
|
||||||
var justUpdatedBuild: Bool = false
|
var justUpdated: Bool = false
|
||||||
var justUpdatedOS: Bool = false
|
|
||||||
|
|
||||||
nonisolated init() {
|
init() {
|
||||||
Task { @MainActor in
|
|
||||||
check()
|
check()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func check() {
|
func check() {
|
||||||
let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String
|
let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String ?? "None"
|
||||||
let lastOS = UserDefaults.standard.object(forKey: Constants.previousOSVersionUserDefaultsKey) as? String
|
|
||||||
let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
|
let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
|
||||||
let osRaw = ProcessInfo.processInfo.operatingSystemVersion
|
|
||||||
let currentOS = "\(osRaw.majorVersion).\(osRaw.minorVersion).\(osRaw.patchVersion)"
|
|
||||||
UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey)
|
UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey)
|
||||||
UserDefaults.standard.set(currentOS, forKey: Constants.previousOSVersionUserDefaultsKey)
|
justUpdated = lastBuild != currentBuild
|
||||||
justUpdatedBuild = lastBuild != currentBuild
|
|
||||||
// To prevent this showing on first lauch for every user, only show if lastBuild is non-nil.
|
|
||||||
justUpdatedOS = lastBuild != nil && lastOS != currentOS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +28,6 @@ extension JustUpdatedChecker {
|
|||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild"
|
static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild"
|
||||||
static let previousOSVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastOS"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
52
Sources/Secretive/Controllers/LaunchAgentController.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
|
import AppKit
|
||||||
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
struct LaunchAgentController {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
|
||||||
|
|
||||||
|
func install() async {
|
||||||
|
logger.debug("Installing agent")
|
||||||
|
_ = setEnabled(false)
|
||||||
|
// This is definitely a bit of a "seems to work better" thing but:
|
||||||
|
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
|
||||||
|
// and start new?
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
await MainActor.run {
|
||||||
|
_ = setEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceLaunch() async -> Bool {
|
||||||
|
logger.debug("Agent is not running, attempting to force launch")
|
||||||
|
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
|
||||||
|
let config = NSWorkspace.OpenConfiguration()
|
||||||
|
config.activates = false
|
||||||
|
do {
|
||||||
|
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
|
||||||
|
logger.debug("Agent force launched")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logger.error("Error force launching \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setEnabled(_ enabled: Bool) -> Bool {
|
||||||
|
let service = SMAppService.loginItem(identifier: Bundle.main.agentBundleID)
|
||||||
|
do {
|
||||||
|
if enabled {
|
||||||
|
try service.register()
|
||||||
|
} else {
|
||||||
|
try service.unregister()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||