Compare commits
129 Commits
stripenc
...
multipleau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782b3b8a51 | ||
|
|
198761f541 | ||
|
|
4033a5b947 | ||
|
|
2cc0157290 | ||
|
|
faa622e379 | ||
|
|
9f2c6d9e84 | ||
|
|
afb48529c7 | ||
|
|
6c56039ece | ||
|
|
2b712864d6 | ||
|
|
845b1ec313 | ||
|
|
595de41f03 | ||
|
|
d82f404166 | ||
|
|
a3bfcb316c | ||
|
|
bba4fb9e7c | ||
|
|
32a1a0bca9 | ||
|
|
bb0b6d8dc3 | ||
|
|
c63d87cbec | ||
|
|
65bc6c1a69 | ||
|
|
275b6ef9bb | ||
|
|
f13bc23991 | ||
|
|
3a67d59519 | ||
|
|
d9a3f0c813 | ||
|
|
84d5a56fb0 | ||
|
|
3bb0cc4a0e | ||
|
|
516e37fdde | ||
|
|
f9dc947b59 | ||
|
|
9c042d1956 | ||
|
|
08e0c4b63b | ||
|
|
f80cbdaf04 | ||
|
|
7a53a85615 | ||
|
|
1f74bd814f | ||
|
|
d9d93574f2 | ||
|
|
15e8ed1ec2 | ||
|
|
1df0c8e96b | ||
|
|
8213a8b451 | ||
|
|
af77fd4a21 | ||
|
|
85d0cab0f5 | ||
|
|
e8cdcdfb7f | ||
|
|
d7f8d5e56b | ||
|
|
3f247d628f | ||
|
|
dae9cead4e | ||
|
|
fe9f8613fa | ||
|
|
5d5ae5bab4 | ||
|
|
f76766a9d5 | ||
|
|
b308b10716 | ||
|
|
0e1e6813a1 | ||
|
|
27bf7c29e4 | ||
|
|
36b6c52979 | ||
|
|
67ec4fee12 | ||
|
|
21fc834fd9 | ||
|
|
726d0580d0 | ||
|
|
4f608ebbc6 | ||
|
|
6e7cf82618 | ||
|
|
96ef91df0c | ||
|
|
aa46d8fa48 | ||
|
|
cf7c6e9fbe | ||
|
|
7c7db56c1e | ||
|
|
cd12e4c828 | ||
|
|
a5b43ea046 | ||
|
|
8c516e128a | ||
|
|
6854c05763 | ||
|
|
5467474d88 | ||
|
|
5c2d039682 | ||
|
|
20e64604d6 | ||
|
|
c5a610d786 | ||
|
|
7d21e3983c | ||
|
|
2c38aaed6f | ||
|
|
63b42bd9df | ||
|
|
cf5ae49ebc | ||
|
|
61705af42f | ||
|
|
558ae15b2d | ||
|
|
902d5c4a1e | ||
|
|
e0c24917f2 | ||
|
|
3d5f0b45bd | ||
|
|
74f4f1c0b1 | ||
|
|
c8cf0db1c5 | ||
|
|
412687467b | ||
|
|
416a7d5f40 | ||
|
|
c4605fb60e | ||
|
|
61eed5987c | ||
|
|
cbef7c6181 | ||
|
|
63a09390b8 | ||
|
|
a4e1ab9eb6 | ||
|
|
147f4d9908 | ||
|
|
ddcb2a36ec | ||
|
|
6dc93806a8 | ||
|
|
99a6d48e53 | ||
|
|
935ac32ea2 | ||
|
|
a0a632f245 | ||
|
|
51fed9e593 | ||
|
|
f652d1d961 | ||
|
|
8aacd428b1 | ||
|
|
c2eb5ce1f6 | ||
|
|
fb4dec383b | ||
|
|
c5052dd457 | ||
|
|
d967c7de07 | ||
|
|
d5b6382dd0 | ||
|
|
e8fcb95db0 | ||
|
|
8ad2d60082 | ||
|
|
6ce0510f21 | ||
|
|
c52728a050 | ||
|
|
8f4d0b8eda | ||
|
|
c37d0c0cba | ||
|
|
6c607f065c | ||
|
|
cff1fde90e | ||
|
|
c32ceb6ad8 | ||
|
|
f0a6f2e43b | ||
|
|
828c61cb2f | ||
|
|
e8c5336888 | ||
|
|
b3bea27f40 | ||
|
|
3d3d123484 | ||
|
|
5b0135d694 | ||
|
|
5067f05558 | ||
|
|
b815c1e5ad | ||
|
|
75c9b5bb62 | ||
|
|
f259cf6bf3 | ||
|
|
2ba73ff680 | ||
|
|
e3938caecb | ||
|
|
bd096c3012 | ||
|
|
2355d3f989 | ||
|
|
45bcb03fef | ||
|
|
e86aa559a4 | ||
|
|
d36537b919 | ||
|
|
8adb4423ac | ||
|
|
8dbf992cce | ||
|
|
f382d72ee5 | ||
|
|
9749cd6f3e | ||
|
|
83ecc15332 | ||
|
|
ecd001a082 |
BIN
.github/readme/app-dark.png
vendored
|
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 668 KiB |
BIN
.github/readme/app-light.png
vendored
|
Before Width: | Height: | Size: 519 KiB After Width: | Height: | Size: 618 KiB |
BIN
.github/readme/localize_add.png
vendored
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
.github/readme/localize_sidebar.png
vendored
|
Before Width: | Height: | Size: 162 KiB |
BIN
.github/readme/localize_translate.png
vendored
|
Before Width: | Height: | Size: 1.7 MiB |
BIN
.github/readme/notification.png
vendored
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 47 KiB |
BIN
.github/readme/touchid.png
vendored
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 230 KiB |
16
.github/templates/release.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
Update description
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
|
||||||
|
## Minimum macOS Version
|
||||||
|
|
||||||
|
|
||||||
|
## Build
|
||||||
|
https://github.com/maxgoedjen/secretive/actions/runs/RUN_ID
|
||||||
|
|
||||||
|
## Attestation
|
||||||
|
https://github.com/maxgoedjen/secretive/attestations/ATTESTATION_ID
|
||||||
16
.github/workflows/add-to-project.yml
vendored
@@ -1,16 +0,0 @@
|
|||||||
name: Add bugs to bugs project
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-to-project:
|
|
||||||
name: Add issue to project
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/add-to-project@v1.0.1
|
|
||||||
with:
|
|
||||||
project-url: https://github.com/users/maxgoedjen/projects/1
|
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
|
||||||
47
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '26 15 * * 3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-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}}"
|
||||||
53
.github/workflows/nightly.yml
vendored
@@ -3,13 +3,18 @@ name: Nightly
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 8 * * *"
|
- cron: "0 8 * * *"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
# runs-on: macOS-latest
|
runs-on: macos-26
|
||||||
runs-on: macos-15
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
attestations: write
|
||||||
|
actions: read
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Setup Signing
|
- name: Setup Signing
|
||||||
env:
|
env:
|
||||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||||
@@ -20,35 +25,41 @@ 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_beta_5.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
run: |
|
run: |
|
||||||
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig
|
DATE=$(date "+%Y-%m-%d")
|
||||||
|
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_nightly-$DATE/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
- name: Build
|
- name: Build
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||||
- name: Create ZIPs
|
- name: 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: |
|
run: |
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
|
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
|
||||||
- name: Notarize
|
- name: Notarize
|
||||||
env:
|
env:
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
||||||
- name: Document SHAs
|
- name: Attest
|
||||||
run: |
|
id: attest
|
||||||
echo "sha-512:"
|
uses: actions/attest-build-provenance@v2
|
||||||
shasum -a 512 Secretive.zip
|
|
||||||
shasum -a 512 Archive.zip
|
|
||||||
echo "sha-256:"
|
|
||||||
shasum -a 256 Secretive.zip
|
|
||||||
shasum -a 256 Archive.zip
|
|
||||||
- name: Upload App to Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: Secretive.zip
|
subject-name: "Secretive.zip"
|
||||||
path: Secretive.zip
|
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
||||||
|
|||||||
64
.github/workflows/oneoff.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 }}
|
||||||
110
.github/workflows/release.yml
vendored
@@ -6,11 +6,12 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
# runs-on: macOS-latest
|
permissions:
|
||||||
runs-on: macos-15
|
contents: read
|
||||||
|
runs-on: macos-26
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Setup Signing
|
- name: Setup Signing
|
||||||
env:
|
env:
|
||||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||||
@@ -21,18 +22,21 @@ 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_beta_5.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||||
pushd Sources/Packages
|
# SPM doesn't seem to pick up on the tests currently?
|
||||||
swift test
|
# run: swift test --build-system swiftbuild --package-path Sources/Packages
|
||||||
popd
|
|
||||||
build:
|
build:
|
||||||
# runs-on: macOS-latest
|
permissions:
|
||||||
runs-on: macos-15
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
attestations: write
|
||||||
|
actions: read
|
||||||
|
runs-on: macos-26
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Setup Signing
|
- name: Setup Signing
|
||||||
env:
|
env:
|
||||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||||
@@ -43,7 +47,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_beta_5.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref }}
|
TAG_NAME: ${{ github.ref }}
|
||||||
@@ -52,67 +56,43 @@ 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/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
sed -i '' -e "s/GITHUB_BUILD_URL/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
- name: Build
|
- name: Build
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||||
- name: Create ZIPs
|
- name: Move to Artifact Folder
|
||||||
|
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
||||||
|
- name: Upload App to Artifacts
|
||||||
|
id: upload
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: Secretive.zip
|
||||||
|
path: Artifact
|
||||||
|
- name: Download Zipped Artifact
|
||||||
|
id: download
|
||||||
|
env:
|
||||||
|
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
|
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
|
||||||
- name: Notarize
|
- name: Notarize
|
||||||
env:
|
env:
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
||||||
- name: Document SHAs
|
- name: Attest
|
||||||
run: |
|
id: attest
|
||||||
echo "sha-512:"
|
uses: actions/attest-build-provenance@v2
|
||||||
shasum -a 512 Secretive.zip
|
with:
|
||||||
shasum -a 512 Archive.zip
|
subject-path: "Secretive.zip"
|
||||||
echo "sha-256:"
|
|
||||||
shasum -a 256 Secretive.zip
|
|
||||||
shasum -a 256 Archive.zip
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
TAG_NAME: ${{ github.ref }}
|
||||||
tag_name: ${{ github.ref }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
release_name: ${{ github.ref }}
|
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
|
||||||
body: |
|
run: |
|
||||||
Update description
|
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
|
||||||
## Features
|
gh release create $TAG_NAME -d -F .github/templates/release.md
|
||||||
|
gh release upload $TAG_NAME Secretive.zip
|
||||||
|
|
||||||
## Fixes
|
|
||||||
|
|
||||||
|
|
||||||
## Minimum macOS Version
|
|
||||||
|
|
||||||
|
|
||||||
## Build
|
|
||||||
https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }}
|
|
||||||
draft: true
|
|
||||||
prerelease: false
|
|
||||||
- name: Upload App to Release
|
|
||||||
id: upload-release-asset-app
|
|
||||||
uses: actions/upload-release-asset@v1.0.1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./Secretive.zip
|
|
||||||
asset_name: Secretive.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
- name: Upload App to Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Secretive.zip
|
|
||||||
path: Secretive.zip
|
|
||||||
- name: Upload Archive to Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Xcode_Archive.zip
|
|
||||||
path: Archive.zip
|
|
||||||
|
|||||||
20
.github/workflows/test.yml
vendored
@@ -3,15 +3,17 @@ name: Test
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
# runs-on: macOS-latest
|
permissions:
|
||||||
runs-on: macos-15
|
contents: read
|
||||||
|
runs-on: macos-26
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta_5.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
||||||
- name: Test
|
- name: Test Main Packages
|
||||||
run: |
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||||
pushd Sources/Packages
|
# SPM doesn't seem to pick up on the tests currently?
|
||||||
swift test
|
# run: swift test --build-system swiftbuild --package-path Sources/Packages
|
||||||
popd
|
- name: Test SecretKit Packages
|
||||||
|
run: swift test --build-system swiftbuild
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -93,3 +93,7 @@ iOSInjectionProject/
|
|||||||
Archive.xcarchive
|
Archive.xcarchive
|
||||||
.DS_Store
|
.DS_Store
|
||||||
contents.xcworkspacedata
|
contents.xcworkspacedata
|
||||||
|
|
||||||
|
# Per-User Configs
|
||||||
|
|
||||||
|
Sources/Config/OpenSource.xcconfig
|
||||||
126
APP_CONFIG.md
@@ -1,125 +1,3 @@
|
|||||||
# Setting up Third Party Apps FAQ
|
# App Configuration
|
||||||
|
|
||||||
## Tower
|
Instructions for setting up apps and shells has moved to [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions)!
|
||||||
|
|
||||||
Tower provides [instructions](https://www.git-tower.com/help/mac/integration/environment).
|
|
||||||
|
|
||||||
## GitHub Desktop
|
|
||||||
|
|
||||||
Should just work, no configuration needed
|
|
||||||
|
|
||||||
## Fork
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
## VS Code
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
## nushell
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cyberduck
|
|
||||||
|
|
||||||
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
|
||||||
|
|
||||||
```
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>link-ssh-auth-sock</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/bin/sh</string>
|
|
||||||
<string>-c</string>
|
|
||||||
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
```
|
|
||||||
|
|
||||||
Log out and log in again before launching Cyberduck.
|
|
||||||
|
|
||||||
## Mountain Duck
|
|
||||||
|
|
||||||
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
|
||||||
|
|
||||||
```
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>link-ssh-auth-sock</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/bin/sh</string>
|
|
||||||
<string>-c</string>
|
|
||||||
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
```
|
|
||||||
|
|
||||||
Log out and log in again before launching Mountain Duck.
|
|
||||||
|
|
||||||
## GitKraken
|
|
||||||
|
|
||||||
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
|
||||||
|
|
||||||
```
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>link-ssh-auth-sock</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/bin/sh</string>
|
|
||||||
<string>-c</string>
|
|
||||||
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
```
|
|
||||||
|
|
||||||
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
|
|
||||||
|
|
||||||
## Retcon
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
# The app I use isn't listed here!
|
|
||||||
|
|
||||||
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
|
|
||||||
If you're not able to get it working, please file a [GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) for it. No guarantees we'll be able to get it working, but chances are someone else in the community might be able to.
|
|
||||||
|
|||||||
2
FAQ.md
@@ -6,7 +6,7 @@ The secure enclave doesn't allow import or export of private keys. For any new c
|
|||||||
|
|
||||||
### Secretive doesn't work with my git client/app
|
### Secretive doesn't work with my git client/app
|
||||||
|
|
||||||
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [App Config FAQ](APP_CONFIG.md).
|
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions) repo.
|
||||||
|
|
||||||
### Secretive isn't working for me
|
### Secretive isn't working for me
|
||||||
|
|
||||||
|
|||||||
@@ -2,36 +2,35 @@
|
|||||||
|
|
||||||
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
|
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
|
||||||
|
|
||||||
## Getting Started
|
## Crowdin
|
||||||
|
|
||||||
### Download Xcode
|
[Secretive uses Crowdin for localization](https://crowdin.com/project/secretive/). Open the link and select your language to translate!
|
||||||
|
|
||||||
Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/).
|
### Manual Translation
|
||||||
|
|
||||||
### Clone Secretive
|
Crowdin is the easiest way to translate Secretive, but I'm happy to accept Pull Requests directly as well.
|
||||||
|
|
||||||
Clone Secretive using [these instructions from GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository).
|
|
||||||
|
|
||||||
### Open Secretive
|
|
||||||
|
|
||||||
Open [Sources/Secretive.xcodeproj](Sources/Secretive.xcodeproj) in Xcode.
|
|
||||||
|
|
||||||
### Translate
|
|
||||||
|
|
||||||
Navigate to [Secretive/Localizable](Sources/Secretive/Localizable.xcstrings).
|
|
||||||
|
|
||||||
<img src="/.github/readme/localize_sidebar.png" alt="Screenshot of Xcode navigating to the Localizable file" width="300">
|
|
||||||
|
|
||||||
If your language already has an in-progress localization, select it from the list. If it isn't there, hit the "+" button and choose your language from the list.
|
|
||||||
|
|
||||||
<img src="/.github/readme/localize_add.png" alt="Screenshot of Xcode adding a new language" width="600">
|
|
||||||
|
|
||||||
Start translating! You'll see a list of english phrases, and a space to add a translation of your language.
|
|
||||||
|
|
||||||
### Create a Pull Request
|
|
||||||
|
|
||||||
Push your changes and open a pull request.
|
|
||||||
|
|
||||||
### Questions
|
### Questions
|
||||||
|
|
||||||
Please open an issue if you have a question about translating the app. I'm more than happy to clarify any terms that are ambiguous or confusing. Thanks for contributing!
|
Please open an issue if you have a question about translating the app. I'm more than happy to clarify any terms that are ambiguous or confusing. Thanks for contributing!
|
||||||
|
|
||||||
|
### Thank You
|
||||||
|
|
||||||
|
Thanks to all the folks who have contributed localizations so far!
|
||||||
|
|
||||||
|
- @mtardy for the French localization
|
||||||
|
- @GravityRyu for the Chinese localization
|
||||||
|
- @Saeger for the Portuguese (Brazil) localization
|
||||||
|
- @moritzsternemann for the German localization
|
||||||
|
- @RoboRich00A16 for the Italian localization
|
||||||
|
- @akx for the Finnish localization
|
||||||
|
- @mog422 for the Korean localization
|
||||||
|
- @niw for the Japanese localization
|
||||||
|
- @truita for the Catalan localization
|
||||||
|
- @Adimac93 for the Polish localization
|
||||||
|
- @alongotv for the Russian localization
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
A special thanks to [Crowdin](https://crowdin.com) for their [generous support of open source projects](https://crowdin.com/page/open-source-project-setup-request).
|
||||||
|
|||||||
85
Package.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// swift-tools-version:6.2
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// This is basically the same package as `Sources/Packages/Package.swift`, but thinned slightly.
|
||||||
|
// Ideally this would be the same package, but SPM requires it to be at the root of the project,
|
||||||
|
// and Xcode does _not_ like that, so they're separate.
|
||||||
|
let package = Package(
|
||||||
|
name: "SecretKit",
|
||||||
|
defaultLocalization: "en",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "SecretKit",
|
||||||
|
targets: ["SecretKit"]),
|
||||||
|
.library(
|
||||||
|
name: "SecureEnclaveSecretKit",
|
||||||
|
targets: ["SecureEnclaveSecretKit"]),
|
||||||
|
.library(
|
||||||
|
name: "SmartCardSecretKit",
|
||||||
|
targets: ["SmartCardSecretKit"]),
|
||||||
|
.library(
|
||||||
|
name: "SSHProtocolKit",
|
||||||
|
targets: ["SSHProtocolKit"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "SecretKit",
|
||||||
|
dependencies: [],
|
||||||
|
path: "Sources/Packages/Sources/SecretKit",
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "SecretKitTests",
|
||||||
|
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
||||||
|
path: "Sources/Packages/Tests/SecretKitTests",
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "SecureEnclaveSecretKit",
|
||||||
|
dependencies: ["SecretKit"],
|
||||||
|
path: "Sources/Packages/Sources/SecureEnclaveSecretKit",
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "SmartCardSecretKit",
|
||||||
|
dependencies: ["SecretKit"],
|
||||||
|
path: "Sources/Packages/Sources/SmartCardSecretKit",
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "SSHProtocolKit",
|
||||||
|
dependencies: ["SecretKit"],
|
||||||
|
path: "Sources/Packages/Sources/SSHProtocolKit",
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "SSHProtocolKitTests",
|
||||||
|
dependencies: ["SSHProtocolKit"],
|
||||||
|
path: "Sources/Packages/Tests/SSHProtocolKitTests",
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
var localization: Resource {
|
||||||
|
.process("../../Resources/Localizable.xcstrings")
|
||||||
|
}
|
||||||
|
|
||||||
|
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||||
|
[
|
||||||
|
.swiftLanguageMode(.v6),
|
||||||
|
// This freaks out Xcode in a dependency context.
|
||||||
|
// .treatAllWarnings(as: .error),
|
||||||
|
]
|
||||||
|
}
|
||||||
24
README.md
@@ -1,11 +1,11 @@
|
|||||||
# Secretive  
|
# Secretive [](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) 
|
||||||
|
|
||||||
|
|
||||||
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
|
Secretive is an app for protecting and managing SSH keys with the Secure Enclave.
|
||||||
|
|
||||||
<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">
|
||||||
<img src="/.github/readme/app-light.png" alt="Screenshot of Secretive" width="600">
|
<source media="(prefers-color-scheme: light)" srcset="/.github/readme/app-light.png">
|
||||||
|
<img src="/.github/readme/app-dark.png" alt="Screenshot of Secretive" width="600">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Secretive is an app for storing and managing SSH keys in the Secure Enclave. It
|
|||||||
|
|
||||||
### 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 store your keys in 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 protect your keys with the Secure Enclave, it's impossible to export them, by design.
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
|
|
||||||
@@ -49,11 +49,11 @@ There's a [FAQ here](FAQ.md).
|
|||||||
|
|
||||||
### Auditable Build Process
|
### Auditable Build Process
|
||||||
|
|
||||||
Builds are produced by GitHub Actions with an auditable build and release generation process. Each build has a "Document SHAs" step, which will output SHA checksums for the build produced by the GitHub Action, so you can verify that the source code for a given build corresponds to any given release.
|
Builds are produced by GitHub Actions with an auditable build and release generation process. Starting with Secretive 3.0, builds are attested using [GitHub Artifact Attestation](https://docs.github.com/en/actions/concepts/security/artifact-attestations). Attestations are viewable in the build log for a build, and also on the [main attestation page](https://github.com/maxgoedjen/secretive/attestations).
|
||||||
|
|
||||||
### A Note Around Code Signing and Keychains
|
### A Note Around Code Signing and Keychains
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Backups and Transfers to New Machines
|
### Backups and Transfers to New Machines
|
||||||
|
|
||||||
@@ -61,4 +61,12 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
If you discover any vulnerabilities in this project, please notify [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with the subject containing "SECRETIVE SECURITY."
|
Secretive's security policy is detailed in [SECURITY.md](SECURITY.md). To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|||||||
20
SECURITY.md
@@ -1,9 +1,27 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
|
## Security Principles
|
||||||
|
|
||||||
|
Secretive is designed with a few general tenets in mind:
|
||||||
|
|
||||||
|
### It's Hard to Leak a Key Secretive Can't Read The Key Material
|
||||||
|
|
||||||
|
Secretive only operates on hardware-backed keys. In general terms, this means that it should be _very_ hard for Secretive to have any sort of bug that causes a key to be shared, because Secretive can't access private key data even if it wants to.
|
||||||
|
|
||||||
|
### Simplicity and Auditability
|
||||||
|
|
||||||
|
Secretive won't expand to have every feature it could possibly have. Part of the goal of the app is that it is possible for consumers to reasonably audit the code, and that often means not implementing features that might be cool, but which would significantly inflate the size of the codebase.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Both in support of the previous principle and to rule out supply chain attacks, Secretive does not rely on any third party dependencies.
|
||||||
|
|
||||||
|
There are limited exceptions to this, particularly in the build process, but the app itself does not depend on any third party code.
|
||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version.
|
The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY."
|
To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
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,12 +13,24 @@
|
|||||||
},
|
},
|
||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
{
|
{
|
||||||
"enabled" : false,
|
|
||||||
"parallelizable" : true,
|
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Secretive.xcodeproj",
|
"containerPath" : "container:Packages",
|
||||||
"identifier" : "50617D9323FCE48E0099B055",
|
"identifier" : "BriefTests",
|
||||||
"name" : "SecretiveTests"
|
"name" : "BriefTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:Packages",
|
||||||
|
"identifier" : "SecretKitTests",
|
||||||
|
"name" : "SecretKitTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:Packages",
|
||||||
|
"identifier" : "SecretAgentKitTests",
|
||||||
|
"name" : "SecretAgentKitTests"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "SecretivePackages",
|
name: "SecretivePackages",
|
||||||
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v14)
|
.macOS(.v14)
|
||||||
],
|
],
|
||||||
@@ -22,11 +23,17 @@ let package = Package(
|
|||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
targets: ["SecretAgentKit"]),
|
targets: ["SecretAgentKit"]),
|
||||||
.library(
|
.library(
|
||||||
name: "SecretAgentKitHeaders",
|
name: "Common",
|
||||||
targets: ["SecretAgentKitHeaders"]),
|
targets: ["Common"]),
|
||||||
.library(
|
.library(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
targets: ["Brief"]),
|
targets: ["Brief"]),
|
||||||
|
.library(
|
||||||
|
name: "XPCWrappers",
|
||||||
|
targets: ["XPCWrappers"]),
|
||||||
|
.library(
|
||||||
|
name: "SSHProtocolKit",
|
||||||
|
targets: ["SSHProtocolKit"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
@@ -34,50 +41,78 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "SecretKit",
|
name: "SecretKit",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
swiftSettings: swiftSettings
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SecretKitTests",
|
name: "SecretKitTests",
|
||||||
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
dependencies: ["SecretKit", "SecretAgentKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SecureEnclaveSecretKit",
|
name: "SecureEnclaveSecretKit",
|
||||||
dependencies: ["SecretKit"],
|
dependencies: ["SecretKit"],
|
||||||
swiftSettings: swiftSettings
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SmartCardSecretKit",
|
name: "SmartCardSecretKit",
|
||||||
dependencies: ["SecretKit"],
|
dependencies: ["SecretKit"],
|
||||||
swiftSettings: swiftSettings
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
|
dependencies: ["SecretKit", "SSHProtocolKit", "Common"],
|
||||||
swiftSettings: swiftSettings
|
resources: [localization],
|
||||||
),
|
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: [],
|
dependencies: ["XPCWrappers", "SSHProtocolKit"],
|
||||||
swiftSettings: swiftSettings
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "BriefTests",
|
name: "BriefTests",
|
||||||
dependencies: ["Brief"]
|
dependencies: ["Brief"],
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "XPCWrappers",
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var localization: Resource {
|
||||||
|
.process("../../Resources/Localizable.xcstrings")
|
||||||
|
}
|
||||||
|
|
||||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||||
[
|
[
|
||||||
.swiftLanguageMode(.v6),
|
.swiftLanguageMode(.v6),
|
||||||
.treatAllWarnings(as: .error),
|
.treatAllWarnings(as: .error),
|
||||||
|
.strictMemorySafety()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
26673
Sources/Packages/Resources/Localizable.xcstrings
Normal file
@@ -1,7 +1,8 @@
|
|||||||
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 {
|
public struct Release: Codable, Sendable, Hashable {
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -15,6 +16,8 @@ public struct Release: Codable, Sendable {
|
|||||||
/// 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.
|
||||||
@@ -26,6 +29,56 @@ public struct Release: Codable, Sendable {
|
|||||||
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,12 +5,20 @@ public struct SemVer: Sendable {
|
|||||||
|
|
||||||
/// The SemVer broken into an array of integers.
|
/// The SemVer broken into an array of integers.
|
||||||
let versionNumbers: [Int]
|
let versionNumbers: [Int]
|
||||||
|
public let previewDescription: String?
|
||||||
|
|
||||||
|
public var isTestBuild: Bool {
|
||||||
|
versionNumbers == [0, 0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
/// Initializes a SemVer from a string representation.
|
/// Initializes a SemVer from a string representation.
|
||||||
/// - Parameter version: A string representation of the SemVer, formatted as "major.minor.patch".
|
/// - Parameter version: A string representation of the SemVer, formatted as "major.minor.patch".
|
||||||
public init(_ version: String) {
|
public init(_ version: String) {
|
||||||
// Betas have the format 1.2.3_beta1
|
// Betas have the format 1.2.3_beta1
|
||||||
let strippedBeta = version.split(separator: "_").first!
|
// Nightlies have the format 0.0.0_nightly-2025-09-03
|
||||||
|
let splitFull = version.split(separator: "_")
|
||||||
|
let strippedBeta = splitFull.first!
|
||||||
|
previewDescription = splitFull.count > 1 ? String(splitFull[1]) : nil
|
||||||
var split = strippedBeta.split(separator: ".").compactMap { Int($0) }
|
var split = strippedBeta.split(separator: ".").compactMap { Int($0) }
|
||||||
while split.count < 3 {
|
while split.count < 3 {
|
||||||
split.append(0)
|
split.append(0)
|
||||||
@@ -22,6 +30,7 @@ public struct SemVer: 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,5 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -13,12 +14,11 @@ import Observation
|
|||||||
state.update
|
state.update
|
||||||
}
|
}
|
||||||
|
|
||||||
public let testBuild: Bool
|
/// The current version of the app that is running.
|
||||||
|
public let currentVersion: SemVer
|
||||||
|
|
||||||
/// The current OS version.
|
/// The current OS version.
|
||||||
private let osVersion: SemVer
|
private let osVersion: SemVer
|
||||||
/// The current version of the app that is running.
|
|
||||||
private let currentVersion: SemVer
|
|
||||||
|
|
||||||
/// Initializes an Updater.
|
/// Initializes an Updater.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -34,28 +34,25 @@ import Observation
|
|||||||
) {
|
) {
|
||||||
self.osVersion = osVersion
|
self.osVersion = osVersion
|
||||||
self.currentVersion = currentVersion
|
self.currentVersion = currentVersion
|
||||||
testBuild = currentVersion == SemVer("0.0.0")
|
Task {
|
||||||
if checkOnLaunch {
|
if checkOnLaunch {
|
||||||
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
|
try await checkForUpdates()
|
||||||
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)))
|
||||||
await checkForUpdates()
|
try await checkForUpdates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manually trigger an update check.
|
/// Manually trigger an update check.
|
||||||
public func checkForUpdates() async {
|
public func checkForUpdates() async throws {
|
||||||
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL) else { return }
|
let session = try await XPCTypedSession<[Release], Never>(serviceName: "com.maxgoedjen.Secretive.SecretiveUpdater")
|
||||||
guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
|
await evaluate(releases: try await session.send())
|
||||||
await evaluate(releases: releases)
|
session.complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -102,11 +99,3 @@ 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 testBuild: Bool { get }
|
var currentVersion: SemVer { get }
|
||||||
|
|
||||||
func ignore(release: Release) async
|
func ignore(release: Release) async
|
||||||
}
|
}
|
||||||
|
|||||||
11
Sources/Packages/Sources/Common/BundleIDs.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Sources/Packages/Sources/Common/URLs.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
37
Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
23
Sources/Packages/Sources/SSHProtocolKit/LengthAndData.swift
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
|
||||||
|
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
|
||||||
|
/// - Returns: OpenSSH data.
|
||||||
|
package var lengthAndData: Data {
|
||||||
|
let rawLength = UInt32(count)
|
||||||
|
var endian = rawLength.bigEndian
|
||||||
|
return unsafe Data(bytes: &endian, count: MemoryLayout<UInt32>.size) + self
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
|
||||||
|
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
|
||||||
|
/// - Returns: OpenSSH data.
|
||||||
|
package var lengthAndData: Data {
|
||||||
|
Data(utf8).lengthAndData
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
/// Generates OpenSSH representations of the public key sof secrets.
|
||||||
|
public struct OpenSSHPublicKeyWriter: Sendable {
|
||||||
|
|
||||||
|
/// Initializes the writer.
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH data payload identifying the secret.
|
||||||
|
/// - Returns: OpenSSH data payload identifying the secret.
|
||||||
|
public func data<SecretType: Secret>(secret: SecretType) -> Data {
|
||||||
|
switch secret.keyType.algorithm {
|
||||||
|
case .ecdsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||||
|
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
||||||
|
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
|
||||||
|
secret.publicKey.lengthAndData
|
||||||
|
case .mldsa:
|
||||||
|
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
|
||||||
|
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
||||||
|
secret.publicKey.lengthAndData
|
||||||
|
case .rsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
||||||
|
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
||||||
|
rsaPublicKeyBlob(secret: secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH string representation of the secret.
|
||||||
|
/// - Returns: OpenSSH string representation of the secret.
|
||||||
|
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
|
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), comment(secret: secret)]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH SHA256 fingerprint string.
|
||||||
|
/// - Returns: OpenSSH SHA256 fingerprint string.
|
||||||
|
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
|
// OpenSSL format seems to strip the padding at the end.
|
||||||
|
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
|
||||||
|
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
|
||||||
|
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
||||||
|
return "SHA256:\(cleaned)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH MD5 fingerprint string.
|
||||||
|
/// - Returns: OpenSSH MD5 fingerprint string.
|
||||||
|
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
|
Insecure.MD5.hash(data: data(secret: secret)).formatted(.hex(separator: ":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func comment<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
|
if let comment = secret.publicKeyAttribution {
|
||||||
|
return comment
|
||||||
|
} else {
|
||||||
|
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
|
||||||
|
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: ".")
|
||||||
|
.replacingOccurrences(of: " ", with: "-")
|
||||||
|
return "\(dashedKeyName)@\(dashedHostName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OpenSSHPublicKeyWriter {
|
||||||
|
|
||||||
|
/// The fully qualified OpenSSH identifier for the algorithm.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - algorithm: The algorithm to identify.
|
||||||
|
/// - length: The key length of the algorithm.
|
||||||
|
/// - Returns: The OpenSSH identifier for the algorithm.
|
||||||
|
public func openSSHIdentifier(for keyType: KeyType) -> String {
|
||||||
|
switch keyType {
|
||||||
|
case .ecdsa256:
|
||||||
|
"ecdsa-sha2-nistp256"
|
||||||
|
case .ecdsa384:
|
||||||
|
"ecdsa-sha2-nistp384"
|
||||||
|
case .mldsa65:
|
||||||
|
"ssh-mldsa-65"
|
||||||
|
case .mldsa87:
|
||||||
|
"ssh-mldsa-87"
|
||||||
|
case .rsa2048:
|
||||||
|
"ssh-rsa"
|
||||||
|
default:
|
||||||
|
"unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OpenSSHPublicKeyWriter {
|
||||||
|
|
||||||
|
func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
|
||||||
|
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
|
||||||
|
// Keychain stores it as a thin ASN.1 wrapper with this format:
|
||||||
|
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
|
||||||
|
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
|
||||||
|
// we only support one key type, and the keychain always gives it in a specific format.
|
||||||
|
guard secret.keyType == .rsa2048 else { fatalError() }
|
||||||
|
let length = secret.keyType.size/8
|
||||||
|
let data = secret.publicKey
|
||||||
|
let n = Data(data[8..<(9+length)])
|
||||||
|
let e = Data(data[(2+9+length)...])
|
||||||
|
return e.lengthAndData + n.lengthAndData
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
54
Sources/Packages/Sources/SSHProtocolKit/OpenSSHReader.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Reads OpenSSH protocol data.
|
||||||
|
public final class OpenSSHReader {
|
||||||
|
|
||||||
|
var remaining: Data
|
||||||
|
var done = false
|
||||||
|
|
||||||
|
/// Initialize the reader with an OpenSSH data payload.
|
||||||
|
/// - Parameter data: The data to read.
|
||||||
|
public init(data: Data) {
|
||||||
|
remaining = Data(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the next chunk of data from the playload.
|
||||||
|
/// - Returns: The next chunk of data.
|
||||||
|
public func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
|
||||||
|
let length = try readNextBytes(as: UInt32.self, convertEndianness: convertEndianness)
|
||||||
|
guard remaining.count >= length else { throw .beyondBounds }
|
||||||
|
let dataRange = 0..<Int(length)
|
||||||
|
let ret = Data(remaining[dataRange])
|
||||||
|
remaining.removeSubrange(dataRange)
|
||||||
|
if remaining.isEmpty {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextBytes<T: FixedWidthInteger>(as: T.Type, convertEndianness: Bool = true) throws(OpenSSHReaderError) -> T {
|
||||||
|
let size = MemoryLayout<T>.size
|
||||||
|
guard remaining.count >= size else { throw .beyondBounds }
|
||||||
|
let lengthRange = 0..<size
|
||||||
|
let lengthChunk = remaining[lengthRange]
|
||||||
|
remaining.removeSubrange(lengthRange)
|
||||||
|
if remaining.isEmpty {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
let value = unsafe lengthChunk.bytes.unsafeLoad(as: T.self)
|
||||||
|
return convertEndianness ? T(value.bigEndian) : T(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
|
||||||
|
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
|
||||||
|
OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenSSHReaderError: Error, Codable {
|
||||||
|
case beyondBounds
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
/// Generates OpenSSH representations of Secrets.
|
||||||
|
public struct OpenSSHSignatureWriter: Sendable {
|
||||||
|
|
||||||
|
/// Initializes the writer.
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH data payload identifying the secret.
|
||||||
|
/// - Returns: OpenSSH data payload identifying the secret.
|
||||||
|
public func data<SecretType: Secret>(secret: SecretType, signature: Data) -> Data {
|
||||||
|
switch secret.keyType.algorithm {
|
||||||
|
case .ecdsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||||
|
ecdsaSignature(signature, keyType: secret.keyType)
|
||||||
|
case .mldsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms
|
||||||
|
mldsaSignature(signature, keyType: secret.keyType)
|
||||||
|
case .rsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
||||||
|
rsaSignature(signature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension OpenSSHSignatureWriter {
|
||||||
|
|
||||||
|
/// Converts a fixed-width big-endian integer (e.g. r/s from CryptoKit rawRepresentation) into an SSH mpint.
|
||||||
|
/// Strips unnecessary leading zeros and prefixes `0x00` if needed to keep the value positive.
|
||||||
|
private func mpint(fromFixedWidthPositiveBytes bytes: Data) -> Data {
|
||||||
|
// mpint zero is encoded as a string with zero bytes of data.
|
||||||
|
guard let firstNonZeroIndex = bytes.firstIndex(where: { $0 != 0x00 }) else {
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = Data(bytes[firstNonZeroIndex...])
|
||||||
|
|
||||||
|
if let first = trimmed.first, first >= 0x80 {
|
||||||
|
var prefixed = Data([0x00])
|
||||||
|
prefixed.append(trimmed)
|
||||||
|
return prefixed
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
||||||
|
let rawLength = rawRepresentation.count/2
|
||||||
|
let r = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[0..<rawLength]))
|
||||||
|
let s = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[rawLength...]))
|
||||||
|
|
||||||
|
var signatureChunk = Data()
|
||||||
|
signatureChunk.append(r.lengthAndData)
|
||||||
|
signatureChunk.append(s.lengthAndData)
|
||||||
|
var mutSignedData = Data()
|
||||||
|
var sub = Data()
|
||||||
|
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
|
||||||
|
sub.append(signatureChunk.lengthAndData)
|
||||||
|
mutSignedData.append(sub.lengthAndData)
|
||||||
|
return mutSignedData
|
||||||
|
}
|
||||||
|
|
||||||
|
func mldsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
||||||
|
var mutSignedData = Data()
|
||||||
|
var sub = Data()
|
||||||
|
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
|
||||||
|
sub.append(rawRepresentation.lengthAndData)
|
||||||
|
mutSignedData.append(sub.lengthAndData)
|
||||||
|
return mutSignedData
|
||||||
|
}
|
||||||
|
|
||||||
|
func rsaSignature(_ rawRepresentation: Data) -> Data {
|
||||||
|
var mutSignedData = Data()
|
||||||
|
var sub = Data()
|
||||||
|
sub.append("rsa-sha2-512".lengthAndData)
|
||||||
|
sub.append(rawRepresentation.lengthAndData)
|
||||||
|
mutSignedData.append(sub.lengthAndData)
|
||||||
|
return mutSignedData
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
|
public enum SSHAgent {}
|
||||||
|
|
||||||
|
extension SSHAgent {
|
||||||
|
|
||||||
|
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
|
public enum Request: CustomDebugStringConvertible, Codable, Sendable {
|
||||||
|
|
||||||
|
case requestIdentities
|
||||||
|
case signRequest(SignatureRequestContext)
|
||||||
|
case addIdentity
|
||||||
|
case removeIdentity
|
||||||
|
case removeAllIdentities
|
||||||
|
case addIDConstrained
|
||||||
|
case addSmartcardKey
|
||||||
|
case removeSmartcardKey
|
||||||
|
case lock
|
||||||
|
case unlock
|
||||||
|
case addSmartcardKeyConstrained
|
||||||
|
case protocolExtension
|
||||||
|
case unknown(UInt8)
|
||||||
|
|
||||||
|
public var protocolID: UInt8 {
|
||||||
|
switch self {
|
||||||
|
case .requestIdentities: 11
|
||||||
|
case .signRequest: 13
|
||||||
|
case .addIdentity: 17
|
||||||
|
case .removeIdentity: 18
|
||||||
|
case .removeAllIdentities: 19
|
||||||
|
case .addIDConstrained: 25
|
||||||
|
case .addSmartcardKey: 20
|
||||||
|
case .removeSmartcardKey: 21
|
||||||
|
case .lock: 22
|
||||||
|
case .unlock: 23
|
||||||
|
case .addSmartcardKeyConstrained: 26
|
||||||
|
case .protocolExtension: 27
|
||||||
|
case .unknown(let value): value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var debugDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES"
|
||||||
|
case .signRequest: "SSH_AGENTC_SIGN_REQUEST"
|
||||||
|
case .addIdentity: "SSH_AGENTC_ADD_IDENTITY"
|
||||||
|
case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY"
|
||||||
|
case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES"
|
||||||
|
case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED"
|
||||||
|
case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY"
|
||||||
|
case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY"
|
||||||
|
case .lock: "SSH_AGENTC_LOCK"
|
||||||
|
case .unlock: "SSH_AGENTC_UNLOCK"
|
||||||
|
case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED"
|
||||||
|
case .protocolExtension: "SSH_AGENTC_EXTENSION"
|
||||||
|
case .unknown: "UNKNOWN_MESSAGE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SignatureRequestContext: Sendable, Codable {
|
||||||
|
public let keyBlob: Data
|
||||||
|
public let dataToSign: Data
|
||||||
|
|
||||||
|
public init(keyBlob: Data, dataToSign: Data) {
|
||||||
|
self.keyBlob = keyBlob
|
||||||
|
self.dataToSign = dataToSign
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var empty: SignatureRequestContext {
|
||||||
|
SignatureRequestContext(keyBlob: Data(), dataToSign: Data())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
|
public enum Response: UInt8, CustomDebugStringConvertible {
|
||||||
|
|
||||||
|
case agentFailure = 5
|
||||||
|
case agentSuccess = 6
|
||||||
|
case agentIdentitiesAnswer = 12
|
||||||
|
case agentSignResponse = 14
|
||||||
|
case agentExtensionFailure = 28
|
||||||
|
case agentExtensionResponse = 29
|
||||||
|
|
||||||
|
public var debugDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .agentFailure: "SSH_AGENT_FAILURE"
|
||||||
|
case .agentSuccess: "SSH_AGENT_SUCCESS"
|
||||||
|
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
|
||||||
|
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
|
||||||
|
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
|
||||||
|
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@ 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 {
|
||||||
|
|
||||||
private let storeList: SecretStoreList
|
private let storeList: SecretStoreList
|
||||||
private let witness: SigningWitness?
|
private let witness: SigningWitness?
|
||||||
private let writer = OpenSSHKeyWriter()
|
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
||||||
private let requestTracer = SigningRequestTracer()
|
private let signatureWriter = OpenSSHSignatureWriter()
|
||||||
private let certificateHandler = OpenSSHCertificateHandler()
|
private let certificateHandler = OpenSSHCertificateHandler()
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
||||||
|
private let authorizationCoordinator = AuthorizationCoordinator()
|
||||||
|
|
||||||
/// Initializes an agent with a store list and a witness.
|
/// Initializes an agent with a store list and a witness.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -31,52 +33,32 @@ public final class Agent: Sendable {
|
|||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
/// Handles an incoming request.
|
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
|
||||||
/// - Parameters:
|
|
||||||
/// - reader: A ``FileHandleReader`` to read the content of the request.
|
|
||||||
/// - writer: A ``FileHandleWriter`` to write the response to.
|
|
||||||
/// - Return value:
|
|
||||||
/// - Boolean if data could be read
|
|
||||||
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) async -> Bool {
|
|
||||||
logger.debug("Agent handling new data")
|
|
||||||
let data = Data(reader.availableData)
|
|
||||||
guard data.count > 4 else { return false}
|
|
||||||
let requestTypeInt = data[4]
|
|
||||||
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
|
||||||
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
|
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
logger.debug("Agent handling request of type \(requestType.debugDescription)")
|
|
||||||
let subData = Data(data[5...])
|
|
||||||
let response = await handle(requestType: requestType, data: subData, reader: reader)
|
|
||||||
writer.write(response)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) async -> Data {
|
|
||||||
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
||||||
await reloadSecretsIfNeccessary()
|
await reloadSecretsIfNeccessary()
|
||||||
var response = Data()
|
var response = Data()
|
||||||
do {
|
do {
|
||||||
switch requestType {
|
switch request {
|
||||||
case .requestIdentities:
|
case .requestIdentities:
|
||||||
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
response.append(SSHAgent.Response.agentIdentitiesAnswer.data)
|
||||||
response.append(await identities())
|
response.append(await identities())
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
|
||||||
case .signRequest:
|
case .signRequest(let context):
|
||||||
let provenance = requestTracer.provenance(from: reader)
|
response.append(SSHAgent.Response.agentSignResponse.data)
|
||||||
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
|
||||||
response.append(try await sign(data: data, provenance: provenance))
|
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
|
case .unknown(let value):
|
||||||
|
logger.error("Agent received unknown request of type \(value).")
|
||||||
|
throw UnhandledRequestError()
|
||||||
|
default:
|
||||||
|
logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.")
|
||||||
|
throw UnhandledRequestError()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
response.removeAll()
|
response = SSHAgent.Response.agentFailure.data
|
||||||
response.append(SSHAgent.ResponseType.agentFailure.data)
|
logger.debug("Agent returned \(SSHAgent.Response.agentFailure.debugDescription)")
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
|
||||||
}
|
}
|
||||||
let full = OpenSSHKeyWriter().lengthAndData(of: response)
|
return response.lengthAndData
|
||||||
return full
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -88,24 +70,24 @@ extension Agent {
|
|||||||
func identities() async -> Data {
|
func identities() async -> Data {
|
||||||
let secrets = await storeList.allSecrets
|
let secrets = await storeList.allSecrets
|
||||||
await certificateHandler.reloadCertificates(for: secrets)
|
await certificateHandler.reloadCertificates(for: secrets)
|
||||||
var count = secrets.count
|
var count = 0
|
||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
|
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let keyBlob = writer.data(secret: secret)
|
let keyBlob = publicKeyWriter.data(secret: secret)
|
||||||
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
keyData.append(keyBlob.lengthAndData)
|
||||||
keyData.append(writer.lengthAndData(of: keyBlob))
|
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
|
||||||
keyData.append(writer.lengthAndData(of: curveData))
|
count += 1
|
||||||
|
|
||||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
||||||
keyData.append(writer.lengthAndData(of: certificateData))
|
keyData.append(certificateData.lengthAndData)
|
||||||
keyData.append(writer.lengthAndData(of: name))
|
keyData.append(name.lengthAndData)
|
||||||
count += 1
|
count += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.log("Agent enumerated \(count) identities")
|
logger.log("Agent enumerated \(count) identities")
|
||||||
var countBigEndian = UInt32(count).bigEndian
|
var countBigEndian = UInt32(count).bigEndian
|
||||||
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
|
let countData = unsafe Data(bytes: &countBigEndian, count: MemoryLayout<UInt32>.size)
|
||||||
return countData + keyData
|
return countData + keyData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,71 +96,30 @@ extension Agent {
|
|||||||
/// - data: The data to sign.
|
/// - data: The data to sign.
|
||||||
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
|
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
|
||||||
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
||||||
func sign(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
let reader = OpenSSHReader(data: data)
|
guard let (secret, store) = await secret(matching: keyBlob) else {
|
||||||
let payloadHash = reader.readNextChunk()
|
let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined()
|
||||||
let hash: Data
|
logger.debug("Agent did not have a key matching \(keyBlobHex)")
|
||||||
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
throw NoMatchingKeyError()
|
||||||
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
|
||||||
hash = certificatePublicKey
|
|
||||||
} else {
|
|
||||||
hash = payloadHash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let (store, secret) = await secret(matching: hash) else {
|
let decision = try await authorizationCoordinator.waitForAccessIfNeeded(to: secret, provenance: provenance)
|
||||||
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
switch decision {
|
||||||
throw AgentError.noMatchingKey
|
case .proceed:
|
||||||
|
break
|
||||||
|
case .promptForSharedAuth:
|
||||||
|
do {
|
||||||
|
try await store.persistAuthentication(secret: secret, forProvenance: provenance)
|
||||||
|
await authorizationCoordinator.completedPersistence(secret: secret, forProvenance: provenance)
|
||||||
|
} catch {
|
||||||
|
await authorizationCoordinator.didNotCompletePersistence(secret: secret, forProvenance: provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let witness = witness {
|
|
||||||
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 signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||||
|
|
||||||
let dataToSign = reader.readNextChunk()
|
try await witness?.witness(accessTo: secret, from: store, by: provenance)
|
||||||
let signed = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
|
||||||
let derSignature = signed
|
|
||||||
|
|
||||||
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
|
||||||
|
|
||||||
// Convert from DER formatted rep to raw (r||s)
|
|
||||||
|
|
||||||
let rawRepresentation: Data
|
|
||||||
switch (secret.algorithm, secret.keySize) {
|
|
||||||
case (.ellipticCurve, 256):
|
|
||||||
rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
|
|
||||||
case (.ellipticCurve, 384):
|
|
||||||
rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
|
|
||||||
default:
|
|
||||||
throw AgentError.unsupportedKeyType
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let rawLength = rawRepresentation.count/2
|
|
||||||
// Check if we need to pad with 0x00 to prevent certain
|
|
||||||
// ssh servers from thinking r or s is negative
|
|
||||||
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
|
|
||||||
var r = Data(rawRepresentation[0..<rawLength])
|
|
||||||
if paddingRange ~= r.first! {
|
|
||||||
r.insert(0x00, at: 0)
|
|
||||||
}
|
|
||||||
var s = Data(rawRepresentation[rawLength...])
|
|
||||||
if paddingRange ~= s.first! {
|
|
||||||
s.insert(0x00, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var signatureChunk = Data()
|
|
||||||
signatureChunk.append(writer.lengthAndData(of: r))
|
|
||||||
signatureChunk.append(writer.lengthAndData(of: s))
|
|
||||||
|
|
||||||
var signedData = Data()
|
|
||||||
var sub = Data()
|
|
||||||
sub.append(writer.lengthAndData(of: curveData))
|
|
||||||
sub.append(writer.lengthAndData(of: signatureChunk))
|
|
||||||
signedData.append(writer.lengthAndData(of: sub))
|
|
||||||
|
|
||||||
if let witness = witness {
|
|
||||||
try await witness.witness(accessTo: secret, from: store, by: provenance)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Agent signed request")
|
logger.debug("Agent signed request")
|
||||||
|
|
||||||
@@ -203,16 +144,10 @@ extension Agent {
|
|||||||
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
|
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
|
||||||
/// - Parameter hash: The hash to match against.
|
/// - Parameter hash: The hash to match against.
|
||||||
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
||||||
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
func secret(matching hash: Data) async -> (AnySecret, AnySecretStore)? {
|
||||||
for store in await storeList.stores {
|
await storeList.allSecretsWithStores.first {
|
||||||
let allMatching = await store.secrets.filter { secret in
|
hash == publicKeyWriter.data(secret: $0.0)
|
||||||
hash == writer.data(secret: secret)
|
|
||||||
}
|
}
|
||||||
if let matching = allMatching.first {
|
|
||||||
return (store, matching)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -220,21 +155,16 @@ extension Agent {
|
|||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
/// An error involving agent operations..
|
struct NoMatchingKeyError: Error {}
|
||||||
enum AgentError: Error {
|
struct UnhandledRequestError: Error {}
|
||||||
case unhandledType
|
|
||||||
case noMatchingKey
|
|
||||||
case unsupportedKeyType
|
|
||||||
case notOpenSSHCertificate
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SSHAgent.ResponseType {
|
extension SSHAgent.Response {
|
||||||
|
|
||||||
var data: Data {
|
var data: Data {
|
||||||
var raw = self.rawValue
|
var raw = self.rawValue
|
||||||
return Data(bytes: &raw, count: UInt8.bitWidth/8)
|
return unsafe Data(bytes: &raw, count: MemoryLayout<UInt8>.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Foundation
|
||||||
|
import SecretKit
|
||||||
|
import os
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
struct PendingRequest: Identifiable, Hashable, CustomStringConvertible {
|
||||||
|
let id: UUID = UUID()
|
||||||
|
let secret: AnySecret
|
||||||
|
let provenance: SigningRequestProvenance
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"\(id.uuidString) - \(secret.name) \(provenance.origin.displayName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchable(with request: PendingRequest) -> Bool {
|
||||||
|
secret == request.secret &&
|
||||||
|
provenance.isSameProvenance(as: request.provenance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Decision {
|
||||||
|
case proceed
|
||||||
|
case promptForSharedAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
actor RequestHolder {
|
||||||
|
|
||||||
|
var pending: [PendingRequest] = []
|
||||||
|
var authorizing: PendingRequest?
|
||||||
|
var preauthorized: PendingRequest?
|
||||||
|
|
||||||
|
func addPending(_ request: PendingRequest) {
|
||||||
|
pending.append(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func advanceIfIdle() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldBlock(_ request: PendingRequest) -> Bool {
|
||||||
|
guard request != authorizing else { return false }
|
||||||
|
if let preauthorized, preauthorized.batchable(with: request) {
|
||||||
|
print("Batching: \(request)")
|
||||||
|
pending.removeAll(where: { $0 == request })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return authorizing == nil && authorizing.
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
if let preauthorized, allBatchable(with: preauthorized).isEmpty {
|
||||||
|
self.preauthorized = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allBatchable(with request: PendingRequest) -> [PendingRequest] {
|
||||||
|
pending.filter { $0.batchable(with: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func completedPersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) {
|
||||||
|
self.preauthorized = PendingRequest(secret: secret, provenance: provenance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didNotCompletePersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) {
|
||||||
|
self.preauthorized = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AuthorizationCoordinator: Sendable {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AuthorizationCoordinator")
|
||||||
|
private let holder = RequestHolder()
|
||||||
|
|
||||||
|
public func waitForAccessIfNeeded(to secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Decision {
|
||||||
|
// Block on unknown, since we don't really have any way to check.
|
||||||
|
if secret.authenticationRequirement == .unknown {
|
||||||
|
logger.warning("\(secret.name) has unknown authentication requirement.")
|
||||||
|
}
|
||||||
|
guard secret.authenticationRequirement != .notRequired else {
|
||||||
|
logger.debug("\(secret.name) does not require authentication, continuing.")
|
||||||
|
return .proceed
|
||||||
|
}
|
||||||
|
logger.debug("\(secret.name) requires authentication.")
|
||||||
|
let pending = PendingRequest(secret: secret, provenance: provenance)
|
||||||
|
await holder.addPending(pending)
|
||||||
|
while await holder.shouldBlock(pending) {
|
||||||
|
logger.debug("\(pending) waiting.")
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
if await holder.preauthorized == nil, await holder.allBatchable(with: pending).count > 0 {
|
||||||
|
logger.debug("\(pending) batch suggestion.")
|
||||||
|
return .promptForSharedAuth
|
||||||
|
}
|
||||||
|
logger.debug("\(pending) continuing")
|
||||||
|
return .proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
func completedPersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async {
|
||||||
|
await holder.completedPersistence(secret: secret, forProvenance: provenance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didNotCompletePersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async {
|
||||||
|
await holder.didNotCompletePersistence(secret: secret, forProvenance: provenance)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol abstraction of the reading aspects of FileHandle.
|
extension FileHandle {
|
||||||
public protocol FileHandleReader: Sendable {
|
|
||||||
|
|
||||||
/// Gets data that is available for reading.
|
|
||||||
var availableData: Data { get }
|
|
||||||
/// A file descriptor of the handle.
|
|
||||||
var fileDescriptor: Int32 { get }
|
|
||||||
/// The process ID of the process coonnected to the other end of the FileHandle.
|
|
||||||
var pidOfConnectedProcess: Int32 { get }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol abstraction of the writing aspects of FileHandle.
|
|
||||||
public protocol FileHandleWriter: Sendable {
|
|
||||||
|
|
||||||
/// Writes data to the handle.
|
|
||||||
func write(_ data: Data)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FileHandle: FileHandleReader, FileHandleWriter {
|
|
||||||
|
|
||||||
public var pidOfConnectedProcess: Int32 {
|
public var pidOfConnectedProcess: Int32 {
|
||||||
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1)
|
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: MemoryLayout<Int32>.size, alignment: 1)
|
||||||
var len = socklen_t(MemoryLayout<Int32>.size)
|
var len = socklen_t(MemoryLayout<Int32>.size)
|
||||||
getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
|
unsafe getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
|
||||||
return pidPointer.load(as: Int32.self)
|
return unsafe pidPointer.load(as: Int32.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
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(homeDirectory: NSHomeDirectory())
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||||
private let writer = OpenSSHKeyWriter()
|
private let writer = OpenSSHPublicKeyWriter()
|
||||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
||||||
|
|
||||||
/// Initializes an OpenSSHCertificateHandler.
|
/// Initializes an OpenSSHCertificateHandler.
|
||||||
@@ -25,30 +27,6 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
|
|
||||||
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
|
||||||
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
|
|
||||||
public func publicKeyHash(from hash: Data) -> Data? {
|
|
||||||
let reader = OpenSSHReader(data: hash)
|
|
||||||
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
|
|
||||||
|
|
||||||
switch certType {
|
|
||||||
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
||||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
|
||||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
|
|
||||||
_ = reader.readNextChunk() // nonce
|
|
||||||
let curveIdentifier = reader.readNextChunk()
|
|
||||||
let publicKey = reader.readNextChunk()
|
|
||||||
|
|
||||||
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
|
|
||||||
return writer.lengthAndData(of: curveType) +
|
|
||||||
writer.lengthAndData(of: curveIdentifier) +
|
|
||||||
writer.lengthAndData(of: publicKey)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||||
/// - Parameter secret: The secret to search for a certificate with
|
/// - Parameter secret: The secret to search for a certificate with
|
||||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
||||||
@@ -78,14 +56,13 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
throw OpenSSHCertificateError.parsingFailed
|
throw OpenSSHCertificateError.parsingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) {
|
if certElements.count >= 3 {
|
||||||
|
let certName = Data(certElements[2].utf8)
|
||||||
return (certDecoded, certName)
|
return (certDecoded, certName)
|
||||||
} else if let certName = secret.name.data(using: .utf8) {
|
}
|
||||||
|
let certName = Data(secret.name.utf8)
|
||||||
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
||||||
return (certDecoded, certName)
|
return (certDecoded, certName)
|
||||||
} else {
|
|
||||||
throw OpenSSHCertificateError.parsingFailed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
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: String
|
private let directory: URL
|
||||||
private let keyWriter = OpenSSHKeyWriter()
|
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
|
|
||||||
/// Initializes a PublicKeyFileStoreController.
|
/// Initializes a PublicKeyFileStoreController.
|
||||||
public init(homeDirectory: String) {
|
public init(directory: URL) {
|
||||||
directory = homeDirectory.appending("/PublicKeys")
|
self.directory = directory
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes out the keys specified to disk.
|
/// Writes out the keys specified to disk.
|
||||||
@@ -19,39 +22,33 @@ 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 { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
|
let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: directory) })
|
||||||
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []
|
.union(Set(secrets.map { sshCertificatePath(for: $0) }))
|
||||||
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
|
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
|
||||||
|
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 {
|
||||||
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
|
// string instead of fileURLWithPath since we're already using fileURL format.
|
||||||
|
try? FileManager.default.removeItem(at: URL(string: path)!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let path = publicKeyPath(for: secret)
|
let path = URL.publicKeyPath(for: secret, in: directory)
|
||||||
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
|
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
||||||
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
||||||
}
|
}
|
||||||
logger.log("Finished writing public keys")
|
logger.log("Finished writing public keys")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)
|
.contentsOfDirectory(atPath: directory.path())
|
||||||
.filter { $0.hasSuffix("-cert.pub") }
|
.filter { $0.hasSuffix("-cert.pub") }
|
||||||
.isEmpty == false
|
.isEmpty == false
|
||||||
} catch {
|
} catch {
|
||||||
@@ -65,7 +62,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("/").appending("\(minimalHex)-cert.pub")
|
return directory.appending(component: "\(minimalHex)-cert.pub").path()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
|
public protocol SSHAgentInputParserProtocol {
|
||||||
|
|
||||||
|
func parse(data: Data) async throws -> SSHAgent.Request
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
|
||||||
|
logger.debug("Parsing new data")
|
||||||
|
guard data.count > 4 else {
|
||||||
|
throw .invalidData
|
||||||
|
}
|
||||||
|
let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4
|
||||||
|
let rawRequestInt = data[4]
|
||||||
|
let remainingDataRange = 5..<min(Int(specifiedLength), data.count)
|
||||||
|
lazy var body: Data = { Data(data[remainingDataRange]) }()
|
||||||
|
switch rawRequestInt {
|
||||||
|
case SSHAgent.Request.requestIdentities.protocolID:
|
||||||
|
return .requestIdentities
|
||||||
|
case SSHAgent.Request.signRequest(.empty).protocolID:
|
||||||
|
do {
|
||||||
|
return .signRequest(try signatureRequestContext(from: body))
|
||||||
|
} catch {
|
||||||
|
throw .openSSHReader(error)
|
||||||
|
}
|
||||||
|
case SSHAgent.Request.addIdentity.protocolID:
|
||||||
|
return .addIdentity
|
||||||
|
case SSHAgent.Request.removeIdentity.protocolID:
|
||||||
|
return .removeIdentity
|
||||||
|
case SSHAgent.Request.removeAllIdentities.protocolID:
|
||||||
|
return .removeAllIdentities
|
||||||
|
case SSHAgent.Request.addIDConstrained.protocolID:
|
||||||
|
return .addIDConstrained
|
||||||
|
case SSHAgent.Request.addSmartcardKey.protocolID:
|
||||||
|
return .addSmartcardKey
|
||||||
|
case SSHAgent.Request.removeSmartcardKey.protocolID:
|
||||||
|
return .removeSmartcardKey
|
||||||
|
case SSHAgent.Request.lock.protocolID:
|
||||||
|
return .lock
|
||||||
|
case SSHAgent.Request.unlock.protocolID:
|
||||||
|
return .unlock
|
||||||
|
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
|
||||||
|
return .addSmartcardKeyConstrained
|
||||||
|
case SSHAgent.Request.protocolExtension.protocolID:
|
||||||
|
return .protocolExtension
|
||||||
|
default:
|
||||||
|
return .unknown(rawRequestInt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SSHAgentInputParser {
|
||||||
|
|
||||||
|
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
|
||||||
|
let reader = OpenSSHReader(data: data)
|
||||||
|
let rawKeyBlob = try reader.readNextChunk()
|
||||||
|
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
|
||||||
|
let dataToSign = try reader.readNextChunk()
|
||||||
|
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign)
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificatePublicKeyBlob(from hash: Data) -> Data? {
|
||||||
|
let reader = OpenSSHReader(data: hash)
|
||||||
|
do {
|
||||||
|
let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self)
|
||||||
|
switch certType {
|
||||||
|
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||||
|
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||||
|
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
|
||||||
|
_ = try reader.readNextChunk() // nonce
|
||||||
|
let curveIdentifier = try reader.readNextChunk()
|
||||||
|
let publicKey = try reader.readNextChunk()
|
||||||
|
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||||
|
return openSSHIdentifier.lengthAndData +
|
||||||
|
curveIdentifier.lengthAndData +
|
||||||
|
publicKey.lengthAndData
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension SSHAgentInputParser {
|
||||||
|
|
||||||
|
public enum AgentParsingError: Error, Codable {
|
||||||
|
case unknownRequest
|
||||||
|
case unhandledRequest
|
||||||
|
case invalidData
|
||||||
|
case openSSHReader(OpenSSHReaderError)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,46 +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 RequestType: UInt8, CustomDebugStringConvertible {
|
|
||||||
|
|
||||||
case requestIdentities = 11
|
|
||||||
case signRequest = 13
|
|
||||||
|
|
||||||
public var debugDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .requestIdentities:
|
|
||||||
return "RequestIdentities"
|
|
||||||
case .signRequest:
|
|
||||||
return "SignRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
|
||||||
public enum ResponseType: UInt8, CustomDebugStringConvertible {
|
|
||||||
|
|
||||||
case agentFailure = 5
|
|
||||||
case agentSuccess = 6
|
|
||||||
case agentIdentitiesAnswer = 12
|
|
||||||
case agentSignResponse = 14
|
|
||||||
|
|
||||||
public var debugDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .agentFailure:
|
|
||||||
return "AgentFailure"
|
|
||||||
case .agentSuccess:
|
|
||||||
return "AgentSuccess"
|
|
||||||
case .agentIdentitiesAnswer:
|
|
||||||
return "AgentIdentitiesAnswer"
|
|
||||||
case .agentSignResponse:
|
|
||||||
return "AgentSignResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct UncheckedSendable<T>: @unchecked Sendable {
|
|
||||||
|
|
||||||
let value: T
|
|
||||||
|
|
||||||
init(_ value: T) {
|
|
||||||
self.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import Foundation
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Security
|
import Security
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import SecretAgentKitHeaders
|
|
||||||
|
|
||||||
/// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects.
|
/// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects.
|
||||||
struct SigningRequestTracer {
|
struct SigningRequestTracer {
|
||||||
@@ -10,12 +9,11 @@ struct SigningRequestTracer {
|
|||||||
|
|
||||||
extension SigningRequestTracer {
|
extension SigningRequestTracer {
|
||||||
|
|
||||||
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandleReader``.
|
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandle``.
|
||||||
/// - Parameter fileHandleReader: The reader involved in processing the request.
|
/// - Parameter fileHandle: The reader involved in processing the request.
|
||||||
/// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request.
|
/// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request.
|
||||||
func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance {
|
func provenance(from fileHandle: FileHandle) -> SigningRequestProvenance {
|
||||||
let firstInfo = process(from: fileHandleReader.pidOfConnectedProcess)
|
let firstInfo = process(from: fileHandle.pidOfConnectedProcess)
|
||||||
|
|
||||||
var provenance = SigningRequestProvenance(root: firstInfo)
|
var provenance = SigningRequestProvenance(root: firstInfo)
|
||||||
while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil {
|
while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil {
|
||||||
provenance.chain.append(process(from: provenance.origin.parentPID!))
|
provenance.chain.append(process(from: provenance.origin.parentPID!))
|
||||||
@@ -27,30 +25,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 = MemoryLayout<kinfo_proc>.size
|
var len = unsafe MemoryLayout<kinfo_proc>.size
|
||||||
let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1)
|
let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1)
|
||||||
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
||||||
sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
|
unsafe sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
|
||||||
return infoPointer.load(as: kinfo_proc.self)
|
return unsafe infoPointer.load(as: kinfo_proc.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID.
|
/// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID.
|
||||||
/// - 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 = self.pidAndNameInfo(from: pid)
|
var pidAndNameInfo = unsafe self.pidAndNameInfo(from: pid)
|
||||||
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
let ppid = unsafe pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
||||||
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
||||||
String(cString: pointer)
|
unsafe String(cString: pointer)
|
||||||
}
|
}
|
||||||
|
|
||||||
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
||||||
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
||||||
let path = String(cString: pathPointer)
|
let path = unsafe String(cString: pathPointer)
|
||||||
var secCode: Unmanaged<SecCode>!
|
var secCode: Unmanaged<SecCode>!
|
||||||
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
|
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
|
||||||
SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
|
unsafe SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
|
||||||
let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
|
let valid = unsafe SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
|
||||||
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
|
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,3 +79,11 @@ extension SigningRequestTracer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// from libproc.h
|
||||||
|
@_silgen_name("proc_pidpath")
|
||||||
|
@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32
|
||||||
|
|
||||||
|
//// from SecTask.h
|
||||||
|
@_silgen_name("SecCodeCreateWithPID")
|
||||||
|
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
/// A controller that manages socket configuration and request dispatching.
|
/// A controller that manages socket configuration and request dispatching.
|
||||||
public final class SocketController {
|
public struct SocketController {
|
||||||
|
|
||||||
/// The active FileHandle.
|
/// A stream of Sessions. Each session represents one connection to a class communicating with the socket. Multiple Sessions may be active simultaneously.
|
||||||
private var fileHandle: FileHandle?
|
public let sessions: AsyncStream<Session>
|
||||||
/// The active SocketPort.
|
|
||||||
private var port: SocketPort?
|
/// A continuation to create new sessions.
|
||||||
/// A handler that will be notified when a new read/write handle is available.
|
private let sessionsContinuation: AsyncStream<Session>.Continuation
|
||||||
/// False if no data could be read
|
|
||||||
public var handler: (@Sendable (FileHandleReader, FileHandleWriter) async -> Bool)?
|
/// The active SocketPort. Must be retained to be kept valid.
|
||||||
/// Logger.
|
private let port: SocketPort
|
||||||
|
|
||||||
|
/// The FileHandle for the main socket.
|
||||||
|
private let fileHandle: FileHandle
|
||||||
|
|
||||||
|
/// Logger for the socket controller.
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController")
|
||||||
|
|
||||||
|
/// Tracer which determines who originates a socket connection.
|
||||||
|
private let requestTracer = SigningRequestTracer()
|
||||||
|
|
||||||
/// Initializes a socket controller with a specified path.
|
/// Initializes a socket controller with a specified path.
|
||||||
/// - Parameter path: The path to use as a socket.
|
/// - Parameter path: The path to use as a socket.
|
||||||
public init(path: String) {
|
public init(path: String) {
|
||||||
|
(sessions, sessionsContinuation) = AsyncStream<Session>.makeStream()
|
||||||
logger.debug("Socket controller setting up at \(path)")
|
logger.debug("Socket controller setting up at \(path)")
|
||||||
if let _ = try? FileManager.default.removeItem(atPath: path) {
|
if let _ = try? FileManager.default.removeItem(atPath: path) {
|
||||||
logger.debug("Socket controller removed existing socket")
|
logger.debug("Socket controller removed existing socket")
|
||||||
@@ -25,87 +34,113 @@ public final class SocketController {
|
|||||||
let exists = FileManager.default.fileExists(atPath: path)
|
let exists = FileManager.default.fileExists(atPath: path)
|
||||||
assert(!exists)
|
assert(!exists)
|
||||||
logger.debug("Socket controller path is clear")
|
logger.debug("Socket controller path is clear")
|
||||||
port = socketPort(at: path)
|
port = SocketPort(path: path)
|
||||||
configureSocket(at: path)
|
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
||||||
|
Task { @MainActor [fileHandle, sessionsContinuation, logger] in
|
||||||
|
// Create the sequence before triggering the notification to
|
||||||
|
// ensure it will not be missed.
|
||||||
|
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted)
|
||||||
|
|
||||||
|
fileHandle.acceptConnectionInBackgroundAndNotify()
|
||||||
|
|
||||||
|
for await notification in connectionAcceptedNotifications {
|
||||||
|
logger.debug("Socket controller accepted connection")
|
||||||
|
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
|
||||||
|
let session = Session(fileHandle: new)
|
||||||
|
sessionsContinuation.yield(session)
|
||||||
|
fileHandle.acceptConnectionInBackgroundAndNotify()
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.debug("Socket listening at \(path)")
|
logger.debug("Socket listening at \(path)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configures the socket and a corresponding FileHandle.
|
}
|
||||||
/// - Parameter path: The path to use as a socket.
|
|
||||||
func configureSocket(at path: String) {
|
extension SocketController {
|
||||||
guard let port = port else { return }
|
|
||||||
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
/// A session represents a connection that has been established between the two ends of the socket.
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionAccept(notification:)), name: .NSFileHandleConnectionAccepted, object: nil)
|
public struct Session: Sendable {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionDataAvailable(notification:)), name: .NSFileHandleDataAvailable, object: nil)
|
|
||||||
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
|
/// Data received by the socket.
|
||||||
|
public let messages: AsyncStream<Data>
|
||||||
|
|
||||||
|
/// The provenance of the process that established the session.
|
||||||
|
public let provenance: SigningRequestProvenance
|
||||||
|
|
||||||
|
/// A FileHandle used to communicate with the socket.
|
||||||
|
private let fileHandle: FileHandle
|
||||||
|
|
||||||
|
/// A continuation for issuing new messages.
|
||||||
|
private let messagesContinuation: AsyncStream<Data>.Continuation
|
||||||
|
|
||||||
|
/// A logger for the session.
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Session")
|
||||||
|
|
||||||
|
/// Initializes a new Session.
|
||||||
|
/// - Parameter fileHandle: The FileHandle used to communicate with the socket.
|
||||||
|
init(fileHandle: FileHandle) {
|
||||||
|
self.fileHandle = fileHandle
|
||||||
|
provenance = SigningRequestTracer().provenance(from: fileHandle)
|
||||||
|
(messages, messagesContinuation) = AsyncStream.makeStream()
|
||||||
|
Task { @MainActor [messagesContinuation, logger] in
|
||||||
|
// Create the sequence before triggering the notification to
|
||||||
|
// ensure it will not be missed.
|
||||||
|
let dataAvailableNotifications = NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle)
|
||||||
|
|
||||||
|
fileHandle.waitForDataInBackgroundAndNotify()
|
||||||
|
|
||||||
|
for await _ in dataAvailableNotifications {
|
||||||
|
let data = fileHandle.availableData
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
logger.debug("Socket controller received empty data, ending continuation.")
|
||||||
|
messagesContinuation.finish()
|
||||||
|
try fileHandle.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messagesContinuation.yield(data)
|
||||||
|
logger.debug("Socket controller yielded data.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a SocketPort for a path.
|
/// Writes new data to the socket.
|
||||||
/// - Parameter path: The path to use as a socket.
|
/// - Parameter data: The data to write.
|
||||||
/// - Returns: A configured SocketPort.
|
@MainActor public func write(_ data: Data) throws {
|
||||||
func socketPort(at path: String) -> SocketPort {
|
try fileHandle.write(contentsOf: data)
|
||||||
|
fileHandle.waitForDataInBackgroundAndNotify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the socket and cleans up resources.
|
||||||
|
public func close() throws {
|
||||||
|
logger.debug("Session closed.")
|
||||||
|
messagesContinuation.finish()
|
||||||
|
try fileHandle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension SocketPort {
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
var len: Int = 0
|
let data = unsafe Data(bytes: &addr, count: MemoryLayout<sockaddr_un>.size)
|
||||||
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||||
path.withCString { cstring in
|
|
||||||
len = strlen(cstring)
|
|
||||||
strncpy(pointer, cstring, len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addr.sun_len = UInt8(len+2)
|
|
||||||
|
|
||||||
var data: Data!
|
|
||||||
withUnsafePointer(to: &addr) { pointer in
|
|
||||||
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles a new connection being accepted, invokes the handler, and prepares to accept new connections.
|
|
||||||
/// - Parameter notification: A `Notification` that triggered the call.
|
|
||||||
@objc func handleConnectionAccept(notification: Notification) {
|
|
||||||
logger.debug("Socket controller accepted connection")
|
|
||||||
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return }
|
|
||||||
Task { [handler, fileHandle] in
|
|
||||||
_ = await handler?(new, new)
|
|
||||||
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
|
||||||
await fileHandle?.acceptConnectionInBackgroundAndNotifyOnMainActor()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles a new connection providing data and invokes the handler callback.
|
|
||||||
/// - Parameter notification: A `Notification` that triggered the call.
|
|
||||||
@objc func handleConnectionDataAvailable(notification: Notification) {
|
|
||||||
logger.debug("Socket controller has new data available")
|
|
||||||
guard let new = notification.object as? FileHandle else { return }
|
|
||||||
logger.debug("Socket controller received new file handle")
|
|
||||||
Task { [handler, logger = UncheckedSendable(logger)] in
|
|
||||||
if((await handler?(new, new)) == true) {
|
|
||||||
logger.value.debug("Socket controller handled data, wait for more data")
|
|
||||||
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
|
||||||
} else {
|
|
||||||
logger.value.debug("Socket controller called with empty data, socked closed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <Security/Security.h>
|
|
||||||
|
|
||||||
|
|
||||||
// Forward declarations
|
|
||||||
|
|
||||||
// from libproc.h
|
|
||||||
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
|
|
||||||
|
|
||||||
// from SecTask.h
|
|
||||||
OSStatus SecCodeCreateWithPID(int32_t, SecCSFlags, SecCodeRef *);
|
|
||||||
|
|
||||||
//! Project version number for SecretAgentKit.
|
|
||||||
FOUNDATION_EXPORT double SecretAgentKitVersionNumber;
|
|
||||||
|
|
||||||
//! Project version string for SecretAgentKit.
|
|
||||||
FOUNDATION_EXPORT const unsigned char SecretAgentKitVersionString[];
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
module SecretAgentKitHeaders [system] {
|
|
||||||
header "include/SecretAgentKit.h"
|
|
||||||
export *
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
/// A context describing a persisted authentication.
|
||||||
|
package struct PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
|
||||||
|
|
||||||
|
/// The Secret to persist authentication for.
|
||||||
|
let secret: SecretType
|
||||||
|
/// The LAContext used to authorize the persistent context.
|
||||||
|
package nonisolated(unsafe) let context: LAContext
|
||||||
|
/// An expiration date for the context.
|
||||||
|
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||||
|
let monotonicExpiration: UInt64
|
||||||
|
|
||||||
|
/// Initializes a context.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - secret: The Secret to persist authentication for.
|
||||||
|
/// - context: The LAContext used to authorize the persistent context.
|
||||||
|
/// - duration: The duration of the authorization context, in seconds.
|
||||||
|
init(secret: SecretType, context: LAContext, duration: TimeInterval) {
|
||||||
|
self.secret = secret
|
||||||
|
unsafe self.context = context
|
||||||
|
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||||
|
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A boolean describing whether or not the context is still valid.
|
||||||
|
package var valid: Bool {
|
||||||
|
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
package var expiration: Date {
|
||||||
|
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
||||||
|
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
||||||
|
return Date(timeIntervalSinceNow: remainingInSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScopedPersistentAuthenticationContext<SecretType: Secret>: Hashable {
|
||||||
|
let provenance: SigningRequestProvenance
|
||||||
|
let secret: SecretType
|
||||||
|
}
|
||||||
|
|
||||||
|
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
||||||
|
|
||||||
|
private var unscopedPersistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
|
||||||
|
private var scopedPersistedAuthenticationContexts: [ScopedPersistentAuthenticationContext<SecretType>: PersistentAuthenticationContext<SecretType>] = [:]
|
||||||
|
|
||||||
|
package init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
package func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) -> PersistentAuthenticationContext<SecretType>? {
|
||||||
|
if let unscopedPersistence = unscopedPersistedAuthenticationContexts[secret], unscopedPersistence.valid {
|
||||||
|
return unscopedPersistence
|
||||||
|
}
|
||||||
|
if let scopedPersistence = scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)], scopedPersistence.valid {
|
||||||
|
return scopedPersistence
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
||||||
|
let newContext = LAContext()
|
||||||
|
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||||
|
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
|
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.unitsStyle = .spellOut
|
||||||
|
formatter.allowedUnits = [.hour, .minute, .day]
|
||||||
|
|
||||||
|
|
||||||
|
let durationString = formatter.string(from: duration)!
|
||||||
|
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
||||||
|
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||||
|
guard success else { return }
|
||||||
|
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||||
|
unscopedPersistedAuthenticationContexts[secret] = context
|
||||||
|
}
|
||||||
|
|
||||||
|
package func persistAuthentication(secret: SecretType, provenance: SigningRequestProvenance) async throws {
|
||||||
|
let newContext = LAContext()
|
||||||
|
|
||||||
|
// FIXME: TEMPORARY
|
||||||
|
let duration: TimeInterval = 10000
|
||||||
|
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||||
|
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
|
|
||||||
|
newContext.localizedReason = "Batch requests"
|
||||||
|
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||||
|
guard success else { return }
|
||||||
|
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||||
|
scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)] = context
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores.
|
|||||||
|
|
||||||
### OpenSSH
|
### OpenSSH
|
||||||
|
|
||||||
- ``OpenSSHKeyWriter``
|
- ``OpenSSHPublicKeyWriter``
|
||||||
- ``OpenSSHReader``
|
- ``OpenSSHReader``
|
||||||
|
|
||||||
### Signing Process
|
### Signing Process
|
||||||
|
|||||||
@@ -3,34 +3,28 @@ import Foundation
|
|||||||
/// Type eraser for Secret.
|
/// Type eraser for Secret.
|
||||||
public struct AnySecret: Secret, @unchecked Sendable {
|
public struct AnySecret: Secret, @unchecked Sendable {
|
||||||
|
|
||||||
let base: Any
|
public let base: any Secret
|
||||||
private let hashable: AnyHashable
|
|
||||||
private let _id: () -> AnyHashable
|
private let _id: () -> AnyHashable
|
||||||
private let _name: () -> String
|
private let _name: () -> String
|
||||||
private let _algorithm: () -> Algorithm
|
|
||||||
private let _keySize: () -> Int
|
|
||||||
private let _requiresAuthentication: () -> Bool
|
|
||||||
private let _publicKey: () -> Data
|
private let _publicKey: () -> Data
|
||||||
|
private let _attributes: () -> Attributes
|
||||||
|
private let _eq: (AnySecret) -> Bool
|
||||||
|
|
||||||
public init<T>(_ secret: T) where T: Secret {
|
public init<T>(_ secret: T) where T: Secret {
|
||||||
if let secret = secret as? AnySecret {
|
if let secret = secret as? AnySecret {
|
||||||
base = secret.base
|
base = secret.base
|
||||||
hashable = secret.hashable
|
|
||||||
_id = secret._id
|
_id = secret._id
|
||||||
_name = secret._name
|
_name = secret._name
|
||||||
_algorithm = secret._algorithm
|
|
||||||
_keySize = secret._keySize
|
|
||||||
_requiresAuthentication = secret._requiresAuthentication
|
|
||||||
_publicKey = secret._publicKey
|
_publicKey = secret._publicKey
|
||||||
|
_attributes = secret._attributes
|
||||||
|
_eq = secret._eq
|
||||||
} else {
|
} else {
|
||||||
base = secret as Any
|
base = secret
|
||||||
self.hashable = secret
|
|
||||||
_id = { secret.id as AnyHashable }
|
_id = { secret.id as AnyHashable }
|
||||||
_name = { secret.name }
|
_name = { secret.name }
|
||||||
_algorithm = { secret.algorithm }
|
|
||||||
_keySize = { secret.keySize }
|
|
||||||
_requiresAuthentication = { secret.requiresAuthentication }
|
|
||||||
_publicKey = { secret.publicKey }
|
_publicKey = { secret.publicKey }
|
||||||
|
_attributes = { secret.attributes }
|
||||||
|
_eq = { secret == $0.base as? T }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,28 +36,20 @@ public struct AnySecret: Secret, @unchecked Sendable {
|
|||||||
_name()
|
_name()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var algorithm: Algorithm {
|
|
||||||
_algorithm()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var keySize: Int {
|
|
||||||
_keySize()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var requiresAuthentication: Bool {
|
|
||||||
_requiresAuthentication()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var publicKey: Data {
|
public var publicKey: Data {
|
||||||
_publicKey()
|
_publicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var attributes: Attributes {
|
||||||
|
_attributes()
|
||||||
|
}
|
||||||
|
|
||||||
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
|
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
|
||||||
lhs.hashable == rhs.hashable
|
lhs._eq(rhs)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
hashable.hash(into: &hasher)
|
id.hash(into: &hasher)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Type eraser for SecretStore.
|
/// Type eraser for SecretStore.
|
||||||
public class AnySecretStore: SecretStore, @unchecked Sendable {
|
open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||||
|
|
||||||
let base: any Sendable
|
let base: any SecretStore
|
||||||
private let _isAvailable: @MainActor @Sendable () -> Bool
|
private let _isAvailable: @MainActor @Sendable () -> Bool
|
||||||
private let _id: @Sendable () -> UUID
|
private let _id: @Sendable () -> UUID
|
||||||
private let _name: @MainActor @Sendable () -> String
|
private let _name: @MainActor @Sendable () -> String
|
||||||
private let _secrets: @MainActor @Sendable () -> [AnySecret]
|
private let _secrets: @MainActor @Sendable () -> [AnySecret]
|
||||||
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
|
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
|
||||||
private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool
|
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret, SigningRequestProvenance) async -> PersistedAuthenticationContext?
|
||||||
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
|
private let _persistAuthenticationForDuration: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
||||||
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
private let _persistAuthenticationForProvenance: @Sendable (AnySecret, SigningRequestProvenance) async throws -> Void
|
||||||
private let _reloadSecrets: @Sendable () async -> Void
|
private let _reloadSecrets: @Sendable () async -> Void
|
||||||
|
|
||||||
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
|
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
|
||||||
@@ -22,9 +21,9 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
_id = { secretStore.id }
|
_id = { secretStore.id }
|
||||||
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
||||||
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
||||||
_verify = { try await secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) }
|
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType, provenance: $1) }
|
||||||
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
|
_persistAuthenticationForDuration = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
||||||
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
_persistAuthenticationForProvenance = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forProvenance: $1) }
|
||||||
_reloadSecrets = { await secretStore.reloadSecrets() }
|
_reloadSecrets = { await secretStore.reloadSecrets() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,16 +47,16 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
try await _sign(data, secret, provenance)
|
try await _sign(data, secret, provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func verify(signature: Data, for data: Data, with secret: AnySecret) async throws -> Bool {
|
public func existingPersistedAuthenticationContext(secret: AnySecret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
|
||||||
try await _verify(signature, data, secret)
|
await _existingPersistedAuthenticationContext(secret, provenance)
|
||||||
}
|
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
|
|
||||||
await _existingPersistedAuthenticationContext(secret)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
|
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
|
||||||
try await _persistAuthentication(secret, duration)
|
try await _persistAuthenticationForDuration(secret, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func persistAuthentication(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async throws {
|
||||||
|
try await _persistAuthenticationForProvenance(secret, provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reloadSecrets() async {
|
public func reloadSecrets() async {
|
||||||
@@ -68,27 +67,34 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
|
|
||||||
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
|
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
|
||||||
|
|
||||||
private let _create: @Sendable (String, Bool) async throws -> Void
|
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) async throws -> Void
|
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
||||||
|
private let _supportedKeyTypes: @Sendable () -> KeyAvailability
|
||||||
|
|
||||||
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
||||||
_create = { try await secretStore.create(name: $0, requiresAuthentication: $1) }
|
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
|
||||||
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
|
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
|
||||||
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) }
|
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
|
||||||
|
_supportedKeyTypes = { secretStore.supportedKeyTypes }
|
||||||
super.init(secretStore)
|
super.init(secretStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func create(name: String, requiresAuthentication: Bool) async throws {
|
@discardableResult
|
||||||
try await _create(name, requiresAuthentication)
|
public func create(name: String, attributes: Attributes) async throws -> SecretType {
|
||||||
|
try await _create(name, attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func delete(secret: AnySecret) async throws {
|
public func delete(secret: AnySecret) async throws {
|
||||||
try await _delete(secret)
|
try await _delete(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(secret: AnySecret, name: String) async throws {
|
public func update(secret: AnySecret, name: String, attributes: Attributes) async throws {
|
||||||
try await _update(secret, name)
|
try await _update(secret, name, attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var supportedKeyTypes: KeyAvailability {
|
||||||
|
_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: SecurityError?
|
public let error: CFError?
|
||||||
|
|
||||||
/// Initializes a SigningError with an optional SecurityError.
|
/// Initializes a SigningError with an optional SecurityError.
|
||||||
/// - Parameter statusCode: The SecurityError, if one is applicable.
|
/// - Parameter statusCode: The SecurityError, if one is applicable.
|
||||||
public init(error: SecurityError?) {
|
public init(error: SecurityError?) {
|
||||||
self.error = error
|
self.error = unsafe error?.takeRetainedValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -51,19 +51,17 @@ public extension SecretStore {
|
|||||||
/// Returns the appropriate keychian signature algorithm to use for a given secret.
|
/// Returns the appropriate keychian signature algorithm to use for a given secret.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - secret: The secret which will be used for signing.
|
/// - secret: The secret which will be used for signing.
|
||||||
/// - allowRSA: Whether or not RSA key types should be permited.
|
|
||||||
/// - Returns: The appropriate algorithm.
|
/// - Returns: The appropriate algorithm.
|
||||||
func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm {
|
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
|
||||||
switch (secret.algorithm, secret.keySize) {
|
switch secret.keyType {
|
||||||
case (.ellipticCurve, 256):
|
case .ecdsa256:
|
||||||
return .ecdsaSignatureMessageX962SHA256
|
.ecdsaSignatureMessageX962SHA256
|
||||||
case (.ellipticCurve, 384):
|
case .ecdsa384:
|
||||||
return .ecdsaSignatureMessageX962SHA384
|
.ecdsaSignatureMessageX962SHA384
|
||||||
case (.rsa, 1024), (.rsa, 2048):
|
case .rsa2048:
|
||||||
guard allowRSA else { fatalError() }
|
.rsaSignatureMessagePKCS1v15SHA512
|
||||||
return .rsaSignatureMessagePKCS1v15SHA512
|
|
||||||
default:
|
default:
|
||||||
fatalError()
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Generates OpenSSH representations of Secrets.
|
|
||||||
public struct OpenSSHKeyWriter: Sendable {
|
|
||||||
|
|
||||||
/// Initializes the writer.
|
|
||||||
public init() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH data payload identifying the secret.
|
|
||||||
/// - Returns: OpenSSH data payload identifying the secret.
|
|
||||||
public func data<SecretType: Secret>(secret: SecretType) -> Data {
|
|
||||||
lengthAndData(of: curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
|
|
||||||
lengthAndData(of: curveIdentifier(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
|
|
||||||
lengthAndData(of: secret.publicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH string representation of the secret.
|
|
||||||
/// - Returns: OpenSSH string representation of the secret.
|
|
||||||
public func openSSHString<SecretType: Secret>(secret: SecretType, comment: String? = nil) -> String {
|
|
||||||
[curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment]
|
|
||||||
.compactMap { $0 }
|
|
||||||
.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH SHA256 fingerprint string.
|
|
||||||
/// - Returns: OpenSSH SHA256 fingerprint string.
|
|
||||||
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
|
||||||
// OpenSSL format seems to strip the padding at the end.
|
|
||||||
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
|
|
||||||
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
|
|
||||||
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
|
||||||
return "SHA256:\(cleaned)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH MD5 fingerprint string.
|
|
||||||
/// - Returns: OpenSSH MD5 fingerprint string.
|
|
||||||
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
|
||||||
Insecure.MD5.hash(data: data(secret: secret))
|
|
||||||
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
|
||||||
.joined(separator: ":")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension OpenSSHKeyWriter {
|
|
||||||
|
|
||||||
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
|
|
||||||
/// - Parameter data: The data payload.
|
|
||||||
/// - Returns: OpenSSH data.
|
|
||||||
public func lengthAndData(of data: Data) -> Data {
|
|
||||||
let rawLength = UInt32(data.count)
|
|
||||||
var endian = rawLength.bigEndian
|
|
||||||
return Data(bytes: &endian, count: UInt32.bitWidth/8) + data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fully qualified OpenSSH identifier for the algorithm.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - algorithm: The algorithm to identify.
|
|
||||||
/// - length: The key length of the algorithm.
|
|
||||||
/// - Returns: The OpenSSH identifier for the algorithm.
|
|
||||||
public func curveType(for algorithm: Algorithm, length: Int) -> String {
|
|
||||||
switch algorithm {
|
|
||||||
case .ellipticCurve:
|
|
||||||
return "ecdsa-sha2-nistp" + String(describing: length)
|
|
||||||
case .rsa:
|
|
||||||
// All RSA keys use the same 512 bit hash function, per
|
|
||||||
// https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key
|
|
||||||
return "rsa-sha2-512"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The OpenSSH identifier for an algorithm.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - algorithm: The algorithm to identify.
|
|
||||||
/// - length: The key length of the algorithm.
|
|
||||||
/// - Returns: The OpenSSH identifier for the algorithm.
|
|
||||||
private func curveIdentifier(for algorithm: Algorithm, length: Int) -> String {
|
|
||||||
switch algorithm {
|
|
||||||
case .ellipticCurve:
|
|
||||||
return "nistp" + String(describing: length)
|
|
||||||
case .rsa:
|
|
||||||
// All RSA keys use the same 512 bit hash function
|
|
||||||
return "rsa-sha2-512"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Reads OpenSSH protocol data.
|
|
||||||
public final class OpenSSHReader {
|
|
||||||
|
|
||||||
var remaining: Data
|
|
||||||
|
|
||||||
/// Initialize the reader with an OpenSSH data payload.
|
|
||||||
/// - Parameter data: The data to read.
|
|
||||||
public init(data: Data) {
|
|
||||||
remaining = Data(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the next chunk of data from the playload.
|
|
||||||
/// - Returns: The next chunk of data.
|
|
||||||
public func readNextChunk() -> Data {
|
|
||||||
let lengthRange = 0..<(UInt32.bitWidth/8)
|
|
||||||
let lengthChunk = remaining[lengthRange]
|
|
||||||
remaining.removeSubrange(lengthRange)
|
|
||||||
let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in
|
|
||||||
return pointer.load(as: UInt32.self)
|
|
||||||
}
|
|
||||||
let length = Int(littleEndianLength.bigEndian)
|
|
||||||
let dataRange = 0..<length
|
|
||||||
let ret = Data(remaining[dataRange])
|
|
||||||
remaining.removeSubrange(dataRange)
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ import Observation
|
|||||||
|
|
||||||
/// Adds a non-type-erased modifiable SecretStore.
|
/// Adds a non-type-erased modifiable SecretStore.
|
||||||
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
|
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
|
||||||
let modifiable = AnySecretStoreModifiable(modifiable: store)
|
let modifiable = AnySecretStoreModifiable(store)
|
||||||
if modifiableStore == nil {
|
if modifiableStore == nil {
|
||||||
modifiableStore = modifiable
|
modifiableStore = modifiable
|
||||||
}
|
}
|
||||||
@@ -36,4 +36,12 @@ import Observation
|
|||||||
stores.flatMap(\.secrets)
|
stores.flatMap(\.secrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var allSecretsWithStores: [(AnySecret, AnySecretStore)] {
|
||||||
|
stores.flatMap { store in
|
||||||
|
store.secrets.map { secret in
|
||||||
|
(secret, store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Attributes: Sendable, Codable, Hashable {
|
||||||
|
|
||||||
|
/// The type of key involved.
|
||||||
|
public let keyType: KeyType
|
||||||
|
|
||||||
|
/// The authentication requirements for the key. This is simply a description of the option recorded at creation – modifying it doers not modify the key's authentication requirements.
|
||||||
|
public let authentication: AuthenticationRequirement
|
||||||
|
|
||||||
|
/// The string appended to the end of the SSH Public Key.
|
||||||
|
/// If nil, a default value will be used.
|
||||||
|
public var publicKeyAttribution: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
keyType: KeyType,
|
||||||
|
authentication: AuthenticationRequirement,
|
||||||
|
publicKeyAttribution: String? = nil
|
||||||
|
) {
|
||||||
|
self.keyType = keyType
|
||||||
|
self.authentication = authentication
|
||||||
|
self.publicKeyAttribution = publicKeyAttribution
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UnsupportedOptionError: Error {
|
||||||
|
package init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The option specified
|
||||||
|
public enum AuthenticationRequirement: String, Hashable, Sendable, Codable, Identifiable {
|
||||||
|
|
||||||
|
/// Authentication is not required for usage.
|
||||||
|
case notRequired
|
||||||
|
|
||||||
|
/// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry..
|
||||||
|
case presenceRequired
|
||||||
|
|
||||||
|
/// ONLY the current set of biometric data, as matching at time of creation, is accepted.
|
||||||
|
/// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password.
|
||||||
|
case biometryCurrent
|
||||||
|
|
||||||
|
/// The authentication requirement was not recorded at creation, and is unknown.
|
||||||
|
case unknown
|
||||||
|
|
||||||
|
/// Whether or not the key is known to require authentication.
|
||||||
|
public var required: Bool {
|
||||||
|
self == .presenceRequired || self == .biometryCurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: AuthenticationRequirement {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,43 +5,81 @@ public protocol Secret: Identifiable, Hashable, Sendable {
|
|||||||
|
|
||||||
/// A user-facing string identifying the Secret.
|
/// A user-facing string identifying the Secret.
|
||||||
var name: String { get }
|
var name: String { get }
|
||||||
/// The algorithm this secret uses.
|
|
||||||
var algorithm: Algorithm { get }
|
|
||||||
/// The key size for the secret.
|
|
||||||
var keySize: Int { get }
|
|
||||||
/// Whether the secret requires authentication before use.
|
|
||||||
var requiresAuthentication: Bool { get }
|
|
||||||
/// The public key data for the secret.
|
/// The public key data for the secret.
|
||||||
var publicKey: Data { get }
|
var publicKey: Data { get }
|
||||||
|
/// The attributes of the key.
|
||||||
|
var attributes: Attributes { get }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported.
|
public extension Secret {
|
||||||
public enum Algorithm: Hashable, Sendable {
|
|
||||||
|
|
||||||
case ellipticCurve
|
/// The algorithm and key size this secret uses.
|
||||||
|
var keyType: KeyType {
|
||||||
|
attributes.keyType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the secret requires authentication before use.
|
||||||
|
var authenticationRequirement: AuthenticationRequirement {
|
||||||
|
attributes.authentication
|
||||||
|
}
|
||||||
|
/// An attribution string to apply to the generated public key.
|
||||||
|
var publicKeyAttribution: String? {
|
||||||
|
attributes.publicKeyAttribution
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of algorithm the Secret uses.
|
||||||
|
public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
|
||||||
|
|
||||||
|
public static let ecdsa256 = KeyType(algorithm: .ecdsa, size: 256)
|
||||||
|
public static let ecdsa384 = KeyType(algorithm: .ecdsa, size: 384)
|
||||||
|
public static let mldsa65 = KeyType(algorithm: .mldsa, size: 65)
|
||||||
|
public static let mldsa87 = KeyType(algorithm: .mldsa, size: 87)
|
||||||
|
public static let rsa2048 = KeyType(algorithm: .rsa, size: 2048)
|
||||||
|
|
||||||
|
public enum Algorithm: Hashable, Sendable, Codable {
|
||||||
|
case ecdsa
|
||||||
|
case mldsa
|
||||||
case rsa
|
case rsa
|
||||||
|
}
|
||||||
|
|
||||||
|
public var algorithm: Algorithm
|
||||||
|
public var size: Int
|
||||||
|
|
||||||
|
public init(algorithm: Algorithm, size: Int) {
|
||||||
|
self.algorithm = algorithm
|
||||||
|
self.size = size
|
||||||
|
}
|
||||||
|
|
||||||
/// Initializes the Algorithm with a secAttr representation of an algorithm.
|
/// Initializes the Algorithm with a secAttr representation of an algorithm.
|
||||||
/// - Parameter secAttr: the secAttr, represented as an NSNumber.
|
/// - Parameter secAttr: the secAttr, represented as an NSNumber.
|
||||||
public init(secAttr: NSNumber) {
|
public init?(secAttr: NSNumber, size: Int) {
|
||||||
let secAttrString = secAttr.stringValue as CFString
|
let secAttrString = secAttr.stringValue as CFString
|
||||||
switch secAttrString {
|
switch secAttrString {
|
||||||
case kSecAttrKeyTypeEC:
|
case kSecAttrKeyTypeEC:
|
||||||
self = .ellipticCurve
|
algorithm = .ecdsa
|
||||||
case kSecAttrKeyTypeRSA:
|
case kSecAttrKeyTypeRSA:
|
||||||
self = .rsa
|
algorithm = .rsa
|
||||||
default:
|
default:
|
||||||
fatalError()
|
return nil
|
||||||
|
}
|
||||||
|
self.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
public var secAttrKeyType: CFString? {
|
||||||
|
switch algorithm {
|
||||||
|
case .ecdsa:
|
||||||
|
kSecAttrKeyTypeEC
|
||||||
|
case .rsa:
|
||||||
|
kSecAttrKeyTypeRSA
|
||||||
|
case .mldsa:
|
||||||
|
nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var secAttrKeyType: CFString {
|
public var description: String {
|
||||||
switch self {
|
"\(algorithm)-\(size)"
|
||||||
case .ellipticCurve:
|
|
||||||
return kSecAttrKeyTypeEC
|
|
||||||
case .rsa:
|
|
||||||
return kSecAttrKeyTypeRSA
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
|
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
|
||||||
public protocol SecretStore: Identifiable, Sendable {
|
public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||||
|
|
||||||
associatedtype SecretType: Secret
|
associatedtype SecretType: Secret
|
||||||
|
|
||||||
@@ -23,19 +22,11 @@ public protocol SecretStore: Identifiable, Sendable {
|
|||||||
/// - Returns: The signed data.
|
/// - Returns: The signed data.
|
||||||
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
|
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
|
||||||
|
|
||||||
/// Verifies that a signature is valid over a specified payload.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - signature: The signature over the data.
|
|
||||||
/// - data: The data to verify the signature of.
|
|
||||||
/// - secret: The secret whose signature to verify.
|
|
||||||
/// - Returns: Whether the signature was verified.
|
|
||||||
func verify(signature: Data, for data: Data, with secret: SecretType) async throws -> Bool
|
|
||||||
|
|
||||||
/// Checks to see if there is currently a valid persisted authentication for a given secret.
|
/// Checks to see if there is currently a valid persisted authentication for a given secret.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
|
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
|
||||||
/// - Returns: A persisted authentication context, if a valid one exists.
|
/// - Returns: A persisted authentication context, if a valid one exists.
|
||||||
func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
|
func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext?
|
||||||
|
|
||||||
/// Persists user authorization for access to a secret.
|
/// Persists user authorization for access to a secret.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -44,19 +35,22 @@ public protocol SecretStore: Identifiable, Sendable {
|
|||||||
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
|
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
|
||||||
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
|
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
|
||||||
|
|
||||||
|
func persistAuthentication(secret: SecretType, forProvenance provenance: SigningRequestProvenance) async throws
|
||||||
|
|
||||||
/// Requests that the store reload secrets from any backing store, if neccessary.
|
/// Requests that the store reload secrets from any backing store, if neccessary.
|
||||||
func reloadSecrets() async
|
func reloadSecrets() async
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A SecretStore that the Secretive admin app can modify.
|
/// A SecretStore that the Secretive admin app can modify.
|
||||||
public protocol SecretStoreModifiable: SecretStore {
|
public protocol SecretStoreModifiable<SecretType>: SecretStore {
|
||||||
|
|
||||||
/// Creates a new ``Secret`` in the store.
|
/// Creates a new ``Secret`` in the store.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: The user-facing name for the ``Secret``.
|
/// - name: The user-facing name for the ``Secret``.
|
||||||
/// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret.
|
/// - attributes: A struct describing the options for creating the key.'
|
||||||
func create(name: String, requiresAuthentication: Bool) async throws
|
@discardableResult
|
||||||
|
func create(name: String, attributes: Attributes) async throws -> SecretType
|
||||||
|
|
||||||
/// Deletes a Secret in the store.
|
/// Deletes a Secret in the store.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -67,10 +61,40 @@ public protocol SecretStoreModifiable: SecretStore {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - secret: The ``Secret`` to update.
|
/// - secret: The ``Secret`` to update.
|
||||||
/// - name: The new name for the Secret.
|
/// - name: The new name for the Secret.
|
||||||
func update(secret: SecretType, name: String) async throws
|
/// - attributes: The new attributes for the secret.
|
||||||
|
func update(secret: SecretType, name: String, attributes: Attributes) async throws
|
||||||
|
|
||||||
|
var supportedKeyTypes: KeyAvailability { 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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
/// Describes the chain of applications that requested a signature operation.
|
/// Describes the chain of applications that requested a signature operation.
|
||||||
public struct SigningRequestProvenance: Equatable, Sendable {
|
public struct SigningRequestProvenance: Equatable, Sendable, Hashable {
|
||||||
|
|
||||||
/// A list of processes involved in the request.
|
/// A list of processes involved in the request.
|
||||||
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
|
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
|
||||||
@@ -25,12 +25,16 @@ extension SigningRequestProvenance {
|
|||||||
chain.allSatisfy { $0.validSignature }
|
chain.allSatisfy { $0.validSignature }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func isSameProvenance(as other: SigningRequestProvenance) -> Bool {
|
||||||
|
zip(chain, other.chain).allSatisfy { $0.isSameProcess(as: $1) }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SigningRequestProvenance {
|
extension SigningRequestProvenance {
|
||||||
|
|
||||||
/// Describes a process in a `SigningRequestProvenance` chain.
|
/// Describes a process in a `SigningRequestProvenance` chain.
|
||||||
public struct Process: Equatable, Sendable {
|
public struct Process: Equatable, Sendable, Hashable {
|
||||||
|
|
||||||
/// The pid of the process.
|
/// The pid of the process.
|
||||||
public let pid: Int32
|
public let pid: Int32
|
||||||
@@ -71,6 +75,15 @@ extension SigningRequestProvenance {
|
|||||||
appName ?? processName
|
appName ?? processName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the
|
||||||
|
public func isSameProcess(as other: Process) -> Bool {
|
||||||
|
processName == other.processName &&
|
||||||
|
appName == other.appName &&
|
||||||
|
iconURL == other.iconURL &&
|
||||||
|
path == other.path &&
|
||||||
|
validSignature == other.validSignature
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import CryptoTokenKit
|
||||||
|
import CryptoKit
|
||||||
|
import SecretKit
|
||||||
|
import os
|
||||||
|
|
||||||
|
extension SecureEnclave {
|
||||||
|
|
||||||
|
public struct CryptoKitMigrator {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CryptoKitMigrator")
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys prior to 3.0 were created and stored directly using the keychain as kSecClassKey items. CryptoKit operates a little differently, in that it creates a key on your behalf which you can persist using an opaque data blob to a generic keychain item. Keychain created keys _also_ use this blob under the hood, but it's stored in the "toid" attribute. This migrates the old keys from kSecClassKey to generic items, copying the "toid" to be the main stored data. If the key is migrated successfully, the old key's identifier is renamed to indicate it's been migrated.
|
||||||
|
/// - Note: Migration is non-destructive – users can still see and use their keys in older versions of Secretive.
|
||||||
|
@MainActor public func migrate(to store: Store) throws {
|
||||||
|
let privateAttributes = KeychainDictionary([
|
||||||
|
kSecClass: kSecClassKey,
|
||||||
|
kSecAttrKeyType: Constants.oldKeyType,
|
||||||
|
kSecAttrApplicationTag: SecureEnclave.Store.Constants.keyTag,
|
||||||
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
|
kSecReturnRef: true,
|
||||||
|
kSecMatchLimit: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes: true
|
||||||
|
])
|
||||||
|
var privateUntyped: CFTypeRef?
|
||||||
|
unsafe SecItemCopyMatching(privateAttributes, &privateUntyped)
|
||||||
|
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
||||||
|
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
|
||||||
|
var migratedAny = false
|
||||||
|
for key in privateTyped {
|
||||||
|
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||||
|
let id = key[kSecAttrApplicationLabel] as! Data
|
||||||
|
guard !id.contains(Constants.migrationMagicNumber) else {
|
||||||
|
logger.log("Skipping \(name), already migrated.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let ref = key[kSecValueRef] as! SecKey
|
||||||
|
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
|
||||||
|
let tokenObjectID = unsafe attributes[Constants.tokenObjectID] as! Data
|
||||||
|
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
|
||||||
|
// Best guess.
|
||||||
|
let auth: AuthenticationRequirement = String(describing: accessControl)
|
||||||
|
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
|
||||||
|
do {
|
||||||
|
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
|
||||||
|
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
|
||||||
|
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
|
||||||
|
logger.log("Skipping \(name), public key already present. Marking as migrated.")
|
||||||
|
markMigrated(secret: secret, oldID: id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.log("Migrating \(name).")
|
||||||
|
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
||||||
|
logger.log("Migrated \(name).")
|
||||||
|
markMigrated(secret: secret, oldID: id)
|
||||||
|
migratedAny = true
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to migrate \(name): \(error.localizedDescription).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if migratedAny {
|
||||||
|
store.reloadSecrets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public func markMigrated(secret: Secret, oldID: Data) {
|
||||||
|
let updateQuery = KeychainDictionary([
|
||||||
|
kSecClass: kSecClassKey,
|
||||||
|
kSecAttrApplicationLabel: oldID
|
||||||
|
])
|
||||||
|
|
||||||
|
let newID = oldID + Constants.migrationMagicNumber
|
||||||
|
let updatedAttributes = KeychainDictionary([
|
||||||
|
kSecAttrApplicationLabel: newID as CFData
|
||||||
|
])
|
||||||
|
|
||||||
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
logger.warning("Failed to mark \(secret.name) as migrated: \(status).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SecureEnclave.CryptoKitMigrator {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
public static let oldKeyType = kSecAttrKeyTypeECSECPrimeRandom as String
|
||||||
|
public static let migrationMagicNumber = Data("_cryptokit_1".utf8)
|
||||||
|
// https://github.com/apple-opensource/Security/blob/5e9101b3bd1fb096bae4f40e79d50426ba1db8e9/OSX/sec/Security/SecItemConstants.c#L111
|
||||||
|
public static nonisolated(unsafe) let tokenObjectID = "toid" as CFString
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import LocalAuthentication
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
extension SecureEnclave {
|
|
||||||
|
|
||||||
/// A context describing a persisted authentication.
|
|
||||||
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
|
||||||
|
|
||||||
/// The Secret to persist authentication for.
|
|
||||||
let secret: Secret
|
|
||||||
/// The LAContext used to authorize the persistent context.
|
|
||||||
nonisolated(unsafe) let context: LAContext
|
|
||||||
/// An expiration date for the context.
|
|
||||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
|
||||||
let monotonicExpiration: UInt64
|
|
||||||
|
|
||||||
/// Initializes a context.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - secret: The Secret to persist authentication for.
|
|
||||||
/// - context: The LAContext used to authorize the persistent context.
|
|
||||||
/// - duration: The duration of the authorization context, in seconds.
|
|
||||||
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
|
||||||
self.secret = secret
|
|
||||||
self.context = context
|
|
||||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
|
||||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A boolean describing whether or not the context is still valid.
|
|
||||||
var valid: Bool {
|
|
||||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiration: Date {
|
|
||||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
|
||||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
|
||||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor PersistentAuthenticationHandler: Sendable {
|
|
||||||
|
|
||||||
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
|
||||||
|
|
||||||
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
|
|
||||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
|
||||||
return persisted
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
|
||||||
let newContext = LAContext()
|
|
||||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
|
||||||
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
|
||||||
|
|
||||||
let formatter = DateComponentsFormatter()
|
|
||||||
formatter.unitsStyle = .spellOut
|
|
||||||
formatter.allowedUnits = [.hour, .minute, .day]
|
|
||||||
|
|
||||||
if let durationString = formatter.string(from: duration) {
|
|
||||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
|
|
||||||
} else {
|
|
||||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave {
|
||||||
@@ -7,12 +6,26 @@ extension SecureEnclave {
|
|||||||
/// An implementation of Secret backed by the Secure Enclave.
|
/// An implementation of Secret backed by the Secure Enclave.
|
||||||
public struct Secret: SecretKit.Secret {
|
public struct Secret: SecretKit.Secret {
|
||||||
|
|
||||||
public let id: Data
|
public let id: String
|
||||||
public let name: String
|
public let name: String
|
||||||
public let algorithm = Algorithm.ellipticCurve
|
|
||||||
public let keySize = 256
|
|
||||||
public let requiresAuthentication: Bool
|
|
||||||
public let publicKey: Data
|
public let publicKey: Data
|
||||||
|
public let attributes: Attributes
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
publicKey: Data,
|
||||||
|
attributes: Attributes
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.publicKey = publicKey
|
||||||
|
self.attributes = attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import Security
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import os
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave {
|
||||||
|
|
||||||
/// An implementation of Store backed by the Secure Enclave.
|
/// An implementation of Store backed by the Secure Enclave using CryptoKit API.
|
||||||
@Observable public final class Store: SecretStoreModifiable {
|
@Observable public final class Store: SecretStoreModifiable {
|
||||||
|
|
||||||
@MainActor public var secrets: [Secret] = []
|
@MainActor public var secrets: [Secret] = []
|
||||||
@@ -15,278 +16,286 @@ extension SecureEnclave {
|
|||||||
CryptoKit.SecureEnclave.isAvailable
|
CryptoKit.SecureEnclave.isAvailable
|
||||||
}
|
}
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
public let name = String(localized: "secure_enclave")
|
public let name = String(localized: .secureEnclave)
|
||||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
|
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
||||||
|
|
||||||
/// Initializes a Store.
|
/// Initializes a Store.
|
||||||
@MainActor public init() {
|
@MainActor public init() {
|
||||||
loadSecrets()
|
loadSecrets()
|
||||||
Task {
|
Task {
|
||||||
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||||
await reloadSecretsInternal(notifyAgent: false)
|
guard Constants.notificationToken != (note.object as? String) else {
|
||||||
|
// Don't reload if we're the ones triggering this by reloading.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reloadSecrets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
public func create(name: String, requiresAuthentication: Bool) async throws {
|
// MARK: SecretStore
|
||||||
var accessError: SecurityError?
|
|
||||||
let flags: SecAccessControlCreateFlags
|
|
||||||
if requiresAuthentication {
|
|
||||||
flags = [.privateKeyUsage, .userPresence]
|
|
||||||
} else {
|
|
||||||
flags = .privateKeyUsage
|
|
||||||
}
|
|
||||||
let access =
|
|
||||||
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
|
||||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
|
||||||
flags,
|
|
||||||
&accessError) as Any
|
|
||||||
if let error = accessError {
|
|
||||||
throw error.takeRetainedValue() as Error
|
|
||||||
}
|
|
||||||
|
|
||||||
let attributes = KeychainDictionary([
|
|
||||||
kSecAttrLabel: name,
|
|
||||||
kSecAttrKeyType: Constants.keyType,
|
|
||||||
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
|
||||||
kSecAttrApplicationTag: Constants.keyTag,
|
|
||||||
kSecPrivateKeyAttrs: [
|
|
||||||
kSecAttrIsPermanent: true,
|
|
||||||
kSecAttrAccessControl: access
|
|
||||||
]
|
|
||||||
])
|
|
||||||
|
|
||||||
var createKeyError: SecurityError?
|
|
||||||
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
|
|
||||||
if let error = createKeyError {
|
|
||||||
throw error.takeRetainedValue() as Error
|
|
||||||
}
|
|
||||||
guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else {
|
|
||||||
throw KeychainError(statusCode: nil)
|
|
||||||
}
|
|
||||||
try savePublicKey(publicKey, name: name)
|
|
||||||
await reloadSecretsInternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func delete(secret: Secret) async throws {
|
|
||||||
let deleteAttributes = KeychainDictionary([
|
|
||||||
kSecClass: kSecClassKey,
|
|
||||||
kSecAttrApplicationLabel: secret.id as CFData
|
|
||||||
])
|
|
||||||
let status = SecItemDelete(deleteAttributes)
|
|
||||||
if status != errSecSuccess {
|
|
||||||
throw KeychainError(statusCode: status)
|
|
||||||
}
|
|
||||||
await reloadSecretsInternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func update(secret: Secret, name: String) async throws {
|
|
||||||
let updateQuery = KeychainDictionary([
|
|
||||||
kSecClass: kSecClassKey,
|
|
||||||
kSecAttrApplicationLabel: secret.id as CFData
|
|
||||||
])
|
|
||||||
|
|
||||||
let updatedAttributes = KeychainDictionary([
|
|
||||||
kSecAttrLabel: name,
|
|
||||||
])
|
|
||||||
|
|
||||||
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
|
||||||
if status != errSecSuccess {
|
|
||||||
throw KeychainError(statusCode: status)
|
|
||||||
}
|
|
||||||
await reloadSecretsInternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
let context: LAContext
|
var context: LAContext
|
||||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) {
|
||||||
context = existing.context
|
context = unsafe existing.context
|
||||||
} else {
|
} else {
|
||||||
let newContext = LAContext()
|
let newContext = LAContext()
|
||||||
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||||
|
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
context = newContext
|
context = newContext
|
||||||
}
|
}
|
||||||
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
|
|
||||||
let attributes = KeychainDictionary([
|
let queryAttributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: Constants.keyClass,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrService: Constants.keyTag,
|
||||||
kSecAttrApplicationLabel: secret.id as CFData,
|
kSecUseDataProtectionKeychain: true,
|
||||||
kSecAttrKeyType: Constants.keyType,
|
kSecAttrAccount: secret.id,
|
||||||
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
kSecReturnAttributes: true,
|
||||||
kSecAttrApplicationTag: Constants.keyTag,
|
kSecReturnData: true,
|
||||||
kSecUseAuthenticationContext: context,
|
|
||||||
kSecReturnRef: true
|
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(attributes, &untyped)
|
let status = unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
guard let untypedSafe = untyped else {
|
guard let untypedSafe = untyped as? [CFString: Any] else {
|
||||||
throw KeychainError(statusCode: errSecSuccess)
|
throw KeychainError(statusCode: errSecSuccess)
|
||||||
}
|
}
|
||||||
let key = untypedSafe as! SecKey
|
guard let attributesData = untypedSafe[kSecAttrGeneric] as? Data,
|
||||||
var signError: SecurityError?
|
let keyData = untypedSafe[kSecValueData] as? Data else {
|
||||||
|
throw MissingAttributesError()
|
||||||
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
|
|
||||||
throw SigningError(error: signError)
|
|
||||||
}
|
}
|
||||||
return signature as Data
|
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||||
|
|
||||||
|
switch attributes.keyType {
|
||||||
|
case .ecdsa256:
|
||||||
|
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
||||||
|
return try key.signature(for: data).rawRepresentation
|
||||||
|
case .mldsa65:
|
||||||
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
|
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
||||||
|
return try key.signature(for: data)
|
||||||
|
case .mldsa87:
|
||||||
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
|
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
||||||
|
return try key.signature(for: data)
|
||||||
|
default:
|
||||||
|
throw UnsupportedAlgorithmError()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
|
|
||||||
let context = LAContext()
|
|
||||||
context.localizedReason = String(localized: "auth_context_request_verify_description_\(secret.name)")
|
|
||||||
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
|
||||||
let attributes = KeychainDictionary([
|
|
||||||
kSecClass: kSecClassKey,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
|
||||||
kSecAttrApplicationLabel: secret.id as CFData,
|
|
||||||
kSecAttrKeyType: Constants.keyType,
|
|
||||||
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
|
||||||
kSecAttrApplicationTag: Constants.keyTag,
|
|
||||||
kSecUseAuthenticationContext: context,
|
|
||||||
kSecReturnRef: true
|
|
||||||
])
|
|
||||||
var verifyError: SecurityError?
|
|
||||||
var untyped: CFTypeRef?
|
|
||||||
let status = SecItemCopyMatching(attributes, &untyped)
|
|
||||||
if status != errSecSuccess {
|
|
||||||
throw KeychainError(statusCode: status)
|
|
||||||
}
|
|
||||||
guard let untypedSafe = untyped else {
|
|
||||||
throw KeychainError(statusCode: errSecSuccess)
|
|
||||||
}
|
|
||||||
let key = untypedSafe as! SecKey
|
|
||||||
let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError)
|
|
||||||
if !verified, let verifyError {
|
|
||||||
if verifyError.takeUnretainedValue() ~= .verifyError {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
throw SigningError(error: verifyError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verified
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
|
||||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reloadSecrets() async {
|
public func persistAuthentication(secret: SecureEnclave.Secret, forProvenance provenance: SigningRequestProvenance) async throws {
|
||||||
await reloadSecretsInternal(notifyAgent: false)
|
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor public func reloadSecrets() {
|
||||||
|
let before = secrets
|
||||||
|
secrets.removeAll()
|
||||||
|
loadSecrets()
|
||||||
|
if secrets != before {
|
||||||
|
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||||
|
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SecretStoreModifiable
|
||||||
|
|
||||||
|
public func create(name: String, attributes: Attributes) async throws -> Secret {
|
||||||
|
var accessError: SecurityError?
|
||||||
|
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
|
||||||
|
case .notRequired:
|
||||||
|
[.privateKeyUsage]
|
||||||
|
case .presenceRequired:
|
||||||
|
[.userPresence, .privateKeyUsage]
|
||||||
|
case .biometryCurrent:
|
||||||
|
[.biometryCurrentSet, .privateKeyUsage]
|
||||||
|
case .unknown:
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
let access =
|
||||||
|
unsafe SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
||||||
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
flags,
|
||||||
|
&accessError)
|
||||||
|
if let error = unsafe accessError {
|
||||||
|
throw unsafe error.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
let dataRep: Data
|
||||||
|
let publicKey: Data
|
||||||
|
switch attributes.keyType {
|
||||||
|
case .ecdsa256:
|
||||||
|
let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
|
||||||
|
dataRep = created.dataRepresentation
|
||||||
|
publicKey = created.publicKey.x963Representation
|
||||||
|
case .mldsa65:
|
||||||
|
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
||||||
|
let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!)
|
||||||
|
dataRep = created.dataRepresentation
|
||||||
|
publicKey = created.publicKey.rawRepresentation
|
||||||
|
case .mldsa87:
|
||||||
|
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
||||||
|
let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!)
|
||||||
|
dataRep = created.dataRepresentation
|
||||||
|
publicKey = created.publicKey.rawRepresentation
|
||||||
|
default:
|
||||||
|
throw Attributes.UnsupportedOptionError()
|
||||||
|
}
|
||||||
|
let id = try saveKey(dataRep, name: name, attributes: attributes)
|
||||||
|
await reloadSecrets()
|
||||||
|
return Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delete(secret: Secret) async throws {
|
||||||
|
let deleteAttributes = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecAttrAccount: secret.id,
|
||||||
|
])
|
||||||
|
let status = SecItemDelete(deleteAttributes)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
await reloadSecrets()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update(secret: Secret, name: String, attributes: Attributes) async throws {
|
||||||
|
let updateQuery = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrAccount: secret.id,
|
||||||
|
])
|
||||||
|
|
||||||
|
let attributes = try JSONEncoder().encode(attributes)
|
||||||
|
let updatedAttributes = KeychainDictionary([
|
||||||
|
kSecAttrLabel: name,
|
||||||
|
kSecAttrGeneric: attributes,
|
||||||
|
])
|
||||||
|
|
||||||
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
await reloadSecrets()
|
||||||
|
}
|
||||||
|
|
||||||
|
public let supportedKeyTypes: KeyAvailability = {
|
||||||
|
let macOS26Keys: [KeyType] = [.mldsa65, .mldsa87]
|
||||||
|
let isAtLeastMacOS26 = if #available(macOS 26, *) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
return KeyAvailability(
|
||||||
|
available: [
|
||||||
|
.ecdsa256,
|
||||||
|
] + (isAtLeastMacOS26 ? macOS26Keys : []),
|
||||||
|
unavailable: (isAtLeastMacOS26 ? [] : macOS26Keys).map {
|
||||||
|
KeyAvailability.UnavailableKeyType(keyType: $0, reason: .macOSUpdateRequired)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SecureEnclave.Store {
|
extension SecureEnclave.Store {
|
||||||
|
|
||||||
/// Reloads all secrets from the store.
|
|
||||||
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
|
|
||||||
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
|
||||||
let before = secrets
|
|
||||||
secrets.removeAll()
|
|
||||||
loadSecrets()
|
|
||||||
if secrets != before {
|
|
||||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
|
||||||
if notifyAgent {
|
|
||||||
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all secrets from the store.
|
/// Loads all secrets from the store.
|
||||||
@MainActor private func loadSecrets() {
|
@MainActor private func loadSecrets() {
|
||||||
let publicAttributes = KeychainDictionary([
|
let queryAttributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: Constants.keyClass,
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
kSecAttrService: Constants.keyTag,
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
kSecUseDataProtectionKeychain: true,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
kSecReturnData: true,
|
||||||
kSecReturnRef: true,
|
|
||||||
kSecMatchLimit: kSecMatchLimitAll,
|
kSecMatchLimit: kSecMatchLimitAll,
|
||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var publicUntyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
SecItemCopyMatching(publicAttributes, &publicUntyped)
|
unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let privateAttributes = KeychainDictionary([
|
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
|
||||||
kSecClass: kSecClassKey,
|
do {
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
|
||||||
kSecReturnRef: true,
|
|
||||||
kSecMatchLimit: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes: true
|
|
||||||
])
|
|
||||||
var privateUntyped: CFTypeRef?
|
|
||||||
SecItemCopyMatching(privateAttributes, &privateUntyped)
|
|
||||||
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
|
||||||
let privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in
|
|
||||||
let id = next[kSecAttrApplicationLabel] as! Data
|
|
||||||
partialResult[id] = next
|
|
||||||
}
|
|
||||||
let authNotRequiredAccessControl: SecAccessControl =
|
|
||||||
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
|
||||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
|
||||||
[.privateKeyUsage],
|
|
||||||
nil)!
|
|
||||||
|
|
||||||
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
|
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
|
||||||
let id = $0[kSecAttrApplicationLabel] as! Data
|
guard let attributesData = $0[kSecAttrGeneric] as? Data,
|
||||||
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
let id = $0[kSecAttrAccount] as? String else {
|
||||||
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
|
throw MissingAttributesError()
|
||||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
}
|
||||||
let privateKey = privateMapped[id]
|
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||||
let requiresAuth: Bool
|
let keyData = $0[kSecValueData] as! Data
|
||||||
if let authRequirements = privateKey?[kSecAttrAccessControl] {
|
let publicKey: Data
|
||||||
// Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality.
|
switch attributes.keyType {
|
||||||
requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl
|
case .ecdsa256:
|
||||||
} else {
|
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
|
||||||
requiresAuth = false
|
publicKey = key.publicKey.x963Representation
|
||||||
|
case .mldsa65:
|
||||||
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
|
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
|
||||||
|
publicKey = key.publicKey.rawRepresentation
|
||||||
|
case .mldsa87:
|
||||||
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
|
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
|
||||||
|
publicKey = key.publicKey.rawRepresentation
|
||||||
|
default:
|
||||||
|
throw UnsupportedAlgorithmError()
|
||||||
|
}
|
||||||
|
return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
|
|
||||||
}
|
}
|
||||||
secrets.append(contentsOf: wrapped)
|
secrets.append(contentsOf: wrapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves a public key.
|
/// Saves a public key.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - publicKey: The public key to save.
|
/// - key: The data representation key to save.
|
||||||
/// - name: A user-facing name for the key.
|
/// - name: A user-facing name for the key.
|
||||||
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
|
/// - attributes: Attributes of the key.
|
||||||
let attributes = KeychainDictionary([
|
/// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate.
|
||||||
kSecClass: kSecClassKey,
|
@discardableResult
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
func saveKey(_ key: Data, name: String, attributes: Attributes) throws -> String {
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
let attributes = try JSONEncoder().encode(attributes)
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
let id = UUID().uuidString
|
||||||
kSecValueRef: publicKey,
|
let keychainAttributes = KeychainDictionary([
|
||||||
kSecAttrIsPermanent: true,
|
kSecClass: Constants.keyClass,
|
||||||
kSecReturnData: true,
|
kSecAttrService: Constants.keyTag,
|
||||||
kSecAttrLabel: name
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
kSecAttrAccount: id,
|
||||||
|
kSecValueData: key,
|
||||||
|
kSecAttrLabel: name,
|
||||||
|
kSecAttrGeneric: attributes
|
||||||
])
|
])
|
||||||
let status = SecItemAdd(attributes, nil)
|
let status = SecItemAdd(keychainAttributes, nil)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave.Store {
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
|
static let keyClass = kSecClassGenericPassword as String
|
||||||
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
||||||
static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String
|
static let notificationToken = UUID().uuidString
|
||||||
static let unauthenticatedThreshold: TimeInterval = 0.05
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UnsupportedAlgorithmError: Error {}
|
||||||
|
struct MissingAttributesError: Error {}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
extension SmartCard {
|
extension SmartCard {
|
||||||
@@ -9,10 +8,8 @@ extension SmartCard {
|
|||||||
|
|
||||||
public let id: Data
|
public let id: Data
|
||||||
public let name: String
|
public let name: String
|
||||||
public let algorithm: Algorithm
|
|
||||||
public let keySize: Int
|
|
||||||
public let requiresAuthentication: Bool = false
|
|
||||||
public let publicKey: Data
|
public let publicKey: Data
|
||||||
|
public var attributes: Attributes
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
import CryptoTokenKit
|
@unsafe @preconcurrency import CryptoTokenKit
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ extension SmartCard {
|
|||||||
|
|
||||||
@MainActor @Observable fileprivate final class State {
|
@MainActor @Observable fileprivate final class State {
|
||||||
var isAvailable = false
|
var isAvailable = false
|
||||||
var name = String(localized: "smart_card")
|
var name = String(localized: .smartCard)
|
||||||
var secrets: [Secret] = []
|
var secrets: [Secret] = []
|
||||||
let watcher = TKTokenWatcher()
|
let watcher = TKTokenWatcher()
|
||||||
var tokenID: String? = nil
|
var tokenID: String? = nil
|
||||||
@@ -23,6 +23,9 @@ extension SmartCard {
|
|||||||
public var isAvailable: Bool {
|
public var isAvailable: Bool {
|
||||||
state.isAvailable
|
state.isAvailable
|
||||||
}
|
}
|
||||||
|
@MainActor public var smartcardTokenID: String? {
|
||||||
|
state.tokenID
|
||||||
|
}
|
||||||
|
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
@MainActor public var name: String {
|
@MainActor public var name: String {
|
||||||
@@ -31,20 +34,22 @@ 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() {
|
||||||
Task { @MainActor in
|
Task {
|
||||||
if let tokenID = state.tokenID {
|
await MainActor.run {
|
||||||
|
if let tokenID = smartcardTokenID {
|
||||||
state.isAvailable = true
|
state.isAvailable = true
|
||||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
|
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
|
||||||
}
|
}
|
||||||
loadSecrets()
|
loadSecrets()
|
||||||
state.watcher.setInsertionHandler { id in
|
}
|
||||||
// Setting insertion handler will cause it to be called immediately.
|
// Doing this inside a regular mainactor handler casues thread assertions in CryptoTokenKit to blow up when the handler executes.
|
||||||
// Make a thread jump so we don't hit a recursive lock attempt.
|
await state.watcher.setInsertionHandler { id in
|
||||||
Task {
|
Task {
|
||||||
self.smartcardInserted(for: id)
|
await self.smartcardInserted(for: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,19 +57,17 @@ extension SmartCard {
|
|||||||
|
|
||||||
// MARK: Public API
|
// MARK: Public API
|
||||||
|
|
||||||
public func create(name: String) throws {
|
|
||||||
fatalError("Keys must be created on the smart card.")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func delete(secret: Secret) throws {
|
|
||||||
fatalError("Keys must be deleted on the smart card.")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) 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() }
|
||||||
let context = LAContext()
|
var context: LAContext
|
||||||
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
|
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) {
|
||||||
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
context = unsafe existing.context
|
||||||
|
} else {
|
||||||
|
let newContext = LAContext()
|
||||||
|
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||||
|
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
|
context = newContext
|
||||||
|
}
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
@@ -74,7 +77,7 @@ extension SmartCard {
|
|||||||
kSecReturnRef: true
|
kSecReturnRef: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(attributes, &untyped)
|
let status = unsafe SecItemCopyMatching(attributes, &untyped)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
@@ -83,40 +86,23 @@ extension SmartCard {
|
|||||||
}
|
}
|
||||||
let key = untypedSafe as! SecKey
|
let key = untypedSafe as! SecKey
|
||||||
var signError: SecurityError?
|
var signError: SecurityError?
|
||||||
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else {
|
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
|
||||||
throw SigningError(error: signError)
|
guard let signature = unsafe SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
|
||||||
|
throw unsafe SigningError(error: signError)
|
||||||
}
|
}
|
||||||
return signature as Data
|
return signature as Data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
|
public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
|
||||||
let attributes = KeychainDictionary([
|
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
|
||||||
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
|
||||||
kSecAttrKeySizeInBits: secret.keySize,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic
|
|
||||||
])
|
|
||||||
var verifyError: SecurityError?
|
|
||||||
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
|
|
||||||
guard let untypedSafe = untyped else {
|
|
||||||
throw KeychainError(statusCode: errSecSuccess)
|
|
||||||
}
|
|
||||||
let key = untypedSafe as! SecKey
|
|
||||||
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, signature as CFData, &verifyError)
|
|
||||||
if !verified, let verifyError {
|
|
||||||
if verifyError.takeUnretainedValue() ~= .verifyError {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
throw SigningError(error: verifyError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verified
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||||
nil
|
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
|
public func persistAuthentication(secret: Secret, forProvenance provenance: SigningRequestProvenance) async throws {
|
||||||
|
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads all secrets from the store.
|
/// Reloads all secrets from the store.
|
||||||
@@ -149,6 +135,7 @@ extension SmartCard.Store {
|
|||||||
state.tokenID = string
|
state.tokenID = string
|
||||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
|
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
|
||||||
state.tokenID = string
|
state.tokenID = string
|
||||||
|
reloadSecretsInternal()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets the token ID and reloads secrets.
|
/// Resets the token ID and reloads secrets.
|
||||||
@@ -162,7 +149,7 @@ extension SmartCard.Store {
|
|||||||
@MainActor private func loadSecrets() {
|
@MainActor private func loadSecrets() {
|
||||||
guard let tokenID = state.tokenID else { return }
|
guard let tokenID = state.tokenID else { return }
|
||||||
|
|
||||||
let fallbackName = String(localized: "smart_card")
|
let fallbackName = String(localized: .smartCard)
|
||||||
if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
||||||
state.name = driverName
|
state.name = driverName
|
||||||
} else {
|
} else {
|
||||||
@@ -177,106 +164,27 @@ extension SmartCard.Store {
|
|||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
SecItemCopyMatching(attributes, &untyped)
|
unsafe SecItemCopyMatching(attributes, &untyped)
|
||||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let wrapped = typed.map {
|
let wrapped: [SecretType] = typed.compactMap {
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||||
let tokenID = $0[kSecAttrApplicationLabel] as! Data
|
let tokenID = $0[kSecAttrApplicationLabel] as! Data
|
||||||
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
|
let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber
|
||||||
let keySize = $0[kSecAttrKeySizeInBits] as! Int
|
let keySize = $0[kSecAttrKeySizeInBits] as! Int
|
||||||
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
||||||
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
|
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
|
||||||
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
|
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
|
||||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
||||||
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
|
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .presenceRequired)
|
||||||
|
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
|
||||||
|
guard signatureAlgorithm(for: secret) != nil else { return nil }
|
||||||
|
return secret
|
||||||
}
|
}
|
||||||
state.secrets.append(contentsOf: wrapped)
|
state.secrets.append(contentsOf: wrapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: Smart Card specific encryption/decryption/verification
|
|
||||||
extension SmartCard.Store {
|
|
||||||
|
|
||||||
/// Encrypts a payload with a specified key.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - data: The payload to encrypt.
|
|
||||||
/// - secret: The secret to encrypt with.
|
|
||||||
/// - Returns: The encrypted data.
|
|
||||||
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
|
||||||
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
|
|
||||||
let context = LAContext()
|
|
||||||
context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)")
|
|
||||||
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
|
||||||
let attributes = KeychainDictionary([
|
|
||||||
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
|
||||||
kSecAttrKeySizeInBits: secret.keySize,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
|
||||||
kSecUseAuthenticationContext: context
|
|
||||||
])
|
|
||||||
var encryptError: SecurityError?
|
|
||||||
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &encryptError)
|
|
||||||
guard let untypedSafe = untyped else {
|
|
||||||
throw KeychainError(statusCode: errSecSuccess)
|
|
||||||
}
|
|
||||||
let key = untypedSafe as! SecKey
|
|
||||||
guard let signature = SecKeyCreateEncryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
|
|
||||||
throw SigningError(error: encryptError)
|
|
||||||
}
|
|
||||||
return signature as Data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypts a payload with a specified key.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - data: The payload to decrypt.
|
|
||||||
/// - secret: The secret to decrypt with.
|
|
||||||
/// - Returns: The decrypted data.
|
|
||||||
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
|
||||||
public func decrypt(data: Data, with secret: SecretType) async throws -> Data {
|
|
||||||
guard let tokenID = await state.tokenID else { fatalError() }
|
|
||||||
let context = LAContext()
|
|
||||||
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
|
|
||||||
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
|
||||||
let attributes = KeychainDictionary([
|
|
||||||
kSecClass: kSecClassKey,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
|
||||||
kSecAttrApplicationLabel: secret.id as CFData,
|
|
||||||
kSecAttrTokenID: tokenID,
|
|
||||||
kSecUseAuthenticationContext: context,
|
|
||||||
kSecReturnRef: true
|
|
||||||
])
|
|
||||||
var untyped: CFTypeRef?
|
|
||||||
let status = SecItemCopyMatching(attributes, &untyped)
|
|
||||||
if status != errSecSuccess {
|
|
||||||
throw KeychainError(statusCode: status)
|
|
||||||
}
|
|
||||||
guard let untypedSafe = untyped else {
|
|
||||||
throw KeychainError(statusCode: errSecSuccess)
|
|
||||||
}
|
|
||||||
let key = untypedSafe as! SecKey
|
|
||||||
var encryptError: SecurityError?
|
|
||||||
guard let signature = SecKeyCreateDecryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
|
|
||||||
throw SigningError(error: encryptError)
|
|
||||||
}
|
|
||||||
return signature as Data
|
|
||||||
}
|
|
||||||
|
|
||||||
private func encryptionAlgorithm(for secret: SecretType) -> SecKeyAlgorithm {
|
|
||||||
switch (secret.algorithm, secret.keySize) {
|
|
||||||
case (.ellipticCurve, 256):
|
|
||||||
return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM
|
|
||||||
case (.ellipticCurve, 384):
|
|
||||||
return .eciesEncryptionCofactorVariableIVX963SHA384AESGCM
|
|
||||||
case (.rsa, 1024), (.rsa, 2048):
|
|
||||||
return .rsaEncryptionOAEPSHA512AESGCM
|
|
||||||
default:
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TKTokenWatcher {
|
extension TKTokenWatcher {
|
||||||
|
|
||||||
/// All available tokens, excluding the Secure Enclave.
|
/// All available tokens, excluding the Secure Enclave.
|
||||||
@@ -285,3 +193,9 @@ extension TKTokenWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SmartCard {
|
||||||
|
|
||||||
|
public struct UnsupportKeyType: Error {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
26
Sources/Packages/Sources/XPCWrappers/TeamID.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
14
Sources/Packages/Sources/XPCWrappers/XPCProtocol.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc protocol _XPCProtocol: Sendable {
|
||||||
|
func process(_ data: Data, with reply: @Sendable @escaping (Data?, Error?) -> Void)
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol XPCProtocol<Input, Output>: Sendable {
|
||||||
|
|
||||||
|
associatedtype Input: Codable
|
||||||
|
associatedtype Output: Codable
|
||||||
|
|
||||||
|
func process(_ data: Input) async throws -> Output
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
53
Sources/Packages/Sources/XPCWrappers/XPCTypedSession.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error & Codable>: ~Copyable {
|
||||||
|
|
||||||
|
private let connection: NSXPCConnection
|
||||||
|
private let proxy: _XPCProtocol
|
||||||
|
|
||||||
|
public init(serviceName: String, warmup: Bool = false) async throws {
|
||||||
|
let connection = NSXPCConnection(serviceName: serviceName)
|
||||||
|
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
||||||
|
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(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,12 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import SecretKit
|
@testable import SecretKit
|
||||||
@testable import SecureEnclaveSecretKit
|
import SSHProtocolKit
|
||||||
@testable import SmartCardSecretKit
|
|
||||||
|
|
||||||
@Suite struct OpenSSHWriterTests {
|
@Suite struct OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
let writer = OpenSSHKeyWriter()
|
let writer = OpenSSHPublicKeyWriter()
|
||||||
|
|
||||||
@Test func ecdsa256MD5Fingerprint() {
|
@Test func ecdsa256MD5Fingerprint() {
|
||||||
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
|
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
|
||||||
@@ -18,7 +17,7 @@ import Testing
|
|||||||
|
|
||||||
@Test func ecdsa256PublicKey() {
|
@Test func ecdsa256PublicKey() {
|
||||||
#expect(writer.openSSHString(secret: Constants.ecdsa256Secret) ==
|
#expect(writer.openSSHString(secret: Constants.ecdsa256Secret) ==
|
||||||
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")
|
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo= test@example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ecdsa256Hash() {
|
@Test func ecdsa256Hash() {
|
||||||
@@ -35,7 +34,7 @@ import Testing
|
|||||||
|
|
||||||
@Test func ecdsa384PublicKey() {
|
@Test func ecdsa384PublicKey() {
|
||||||
#expect(writer.openSSHString(secret: Constants.ecdsa384Secret) ==
|
#expect(writer.openSSHString(secret: Constants.ecdsa384Secret) ==
|
||||||
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")
|
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ== test@example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ecdsa384Hash() {
|
@Test func ecdsa384Hash() {
|
||||||
@@ -44,11 +43,11 @@ import Testing
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OpenSSHWriterTests {
|
extension OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", algorithm: .ellipticCurve, keySize: 256, publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!)
|
static let ecdsa256Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
||||||
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!)
|
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"))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import SecretKit
|
import SSHProtocolKit
|
||||||
@testable import SecureEnclaveSecretKit
|
|
||||||
@testable import SmartCardSecretKit
|
|
||||||
|
|
||||||
@Suite struct OpenSSHReaderTests {
|
@Suite struct OpenSSHReaderTests {
|
||||||
|
|
||||||
@Test func signatureRequest() {
|
@Test func signatureRequest() throws {
|
||||||
let reader = OpenSSHReader(data: Constants.signatureRequest)
|
let reader = OpenSSHReader(data: Constants.signatureRequest)
|
||||||
let hash = reader.readNextChunk()
|
let hash = try reader.readNextChunk()
|
||||||
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
|
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
|
||||||
let dataToSign = reader.readNextChunk()
|
let dataToSign = try reader.readNextChunk()
|
||||||
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
|
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
|
||||||
let empty = reader.readNextChunk()
|
let empty = try reader.readNextChunk()
|
||||||
#expect(empty.isEmpty)
|
#expect(empty.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import SSHProtocolKit
|
||||||
|
@testable import SecretKit
|
||||||
|
|
||||||
|
@Suite struct OpenSSHSignatureWriterTests {
|
||||||
|
|
||||||
|
private let writer = OpenSSHSignatureWriter()
|
||||||
|
|
||||||
|
@Test func ecdsaMpintStripsUnnecessaryLeadingZeros() throws {
|
||||||
|
let secret = Constants.ecdsa256Secret
|
||||||
|
|
||||||
|
// r has a leading 0x00 followed by 0x01 (< 0x80): the mpint must not keep the leading zero.
|
||||||
|
let rBytes: [UInt8] = [0x00] + (1...31).map { UInt8($0) }
|
||||||
|
let r = Data(rBytes)
|
||||||
|
// s has two leading 0x00 bytes followed by 0x7f (< 0x80): the mpint must not keep the leading zeros.
|
||||||
|
let sBytes: [UInt8] = [0x00, 0x00, 0x7f] + Array(repeating: UInt8(0x01), count: 29)
|
||||||
|
let s = Data(sBytes)
|
||||||
|
let rawRepresentation = r + s
|
||||||
|
|
||||||
|
let response = writer.data(secret: secret, signature: rawRepresentation)
|
||||||
|
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
|
||||||
|
|
||||||
|
#expect(parsedR == Data((1...31).map { UInt8($0) }))
|
||||||
|
#expect(parsedS == Data([0x7f] + Array(repeating: UInt8(0x01), count: 29)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ecdsaMpintPrefixesZeroWhenHighBitSet() throws {
|
||||||
|
let secret = Constants.ecdsa256Secret
|
||||||
|
|
||||||
|
// r starts with 0x80 (high bit set): mpint must be prefixed with 0x00.
|
||||||
|
let r = Data([UInt8(0x80)] + Array(repeating: UInt8(0x01), count: 31))
|
||||||
|
let s = Data([UInt8(0x01)] + Array(repeating: UInt8(0x02), count: 31))
|
||||||
|
let rawRepresentation = r + s
|
||||||
|
|
||||||
|
let response = writer.data(secret: secret, signature: rawRepresentation)
|
||||||
|
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
|
||||||
|
|
||||||
|
#expect(parsedR == Data([0x00, 0x80] + Array(repeating: UInt8(0x01), count: 31)))
|
||||||
|
#expect(parsedS == Data([0x01] + Array(repeating: UInt8(0x02), count: 31)))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension OpenSSHSignatureWriterTests {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
static let ecdsa256Secret = TestSecret(
|
||||||
|
id: Data(),
|
||||||
|
name: "Test Key (ECDSA 256)",
|
||||||
|
publicKey: Data(repeating: 0x01, count: 65),
|
||||||
|
attributes: Attributes(
|
||||||
|
keyType: KeyType(algorithm: .ecdsa, size: 256),
|
||||||
|
authentication: .notRequired,
|
||||||
|
publicKeyAttribution: "test@example.com"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ParseError: Error {
|
||||||
|
case eof
|
||||||
|
case invalidAlgorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEcdsaSignatureMpints(from openSSHSignedData: Data) throws -> (r: Data, s: Data) {
|
||||||
|
let reader = OpenSSHReader(data: openSSHSignedData)
|
||||||
|
|
||||||
|
// Prefix
|
||||||
|
_ = try reader.readNextBytes(as: UInt32.self)
|
||||||
|
|
||||||
|
let algorithm = try reader.readNextChunkAsString()
|
||||||
|
guard algorithm == "ecdsa-sha2-nistp256" else {
|
||||||
|
throw ParseError.invalidAlgorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
let sigReader = try reader.readNextChunkAsSubReader()
|
||||||
|
let r = try sigReader.readNextChunk()
|
||||||
|
let s = try sigReader.readNextChunk()
|
||||||
|
return (r, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
11
Sources/Packages/Tests/SSHProtocolKitTests/TestSecret.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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,56 +1,60 @@
|
|||||||
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
|
||||||
|
|
||||||
@Suite struct AgentTests {
|
@Suite struct AgentTests {
|
||||||
|
|
||||||
let stubWriter = StubFileHandleWriter()
|
|
||||||
|
|
||||||
// MARK: Identity Listing
|
// MARK: Identity Listing
|
||||||
|
|
||||||
@Test func emptyStores() async {
|
@Test func emptyStores() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesEmpty)
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
|
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func identitiesList() async {
|
@Test func identitiesList() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
|
|
||||||
|
let actual = OpenSSHReader(data: response)
|
||||||
|
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
|
||||||
|
print(actual, expected)
|
||||||
|
#expect(response == Constants.Responses.requestIdentitiesMultiple)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Signatures
|
// MARK: Signatures
|
||||||
|
|
||||||
@Test func noMatchingIdentities() async {
|
@Test func noMatchingIdentities() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func signature() async throws {
|
@Test func ecdsaSignature() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
guard case SSHAgent.Request.signRequest(let context) = request else { return }
|
||||||
_ = requestReader.readNextChunk()
|
|
||||||
let dataToSign = requestReader.readNextChunk()
|
|
||||||
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)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
let outer = OpenSSHReader(data: stubWriter.data[5...])
|
let responseReader = OpenSSHReader(data: response)
|
||||||
let payload = outer.readNextChunk()
|
let length = try responseReader.readNextBytes(as: UInt32.self)
|
||||||
let inner = OpenSSHReader(data: payload)
|
let type = try responseReader.readNextBytes(as: UInt8.self)
|
||||||
_ = inner.readNextChunk()
|
#expect(length == response.count - MemoryLayout<UInt32>.size)
|
||||||
let signedData = inner.readNextChunk()
|
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
|
||||||
let rsData = OpenSSHReader(data: signedData)
|
let outer = OpenSSHReader(data: responseReader.remaining)
|
||||||
var r = rsData.readNextChunk()
|
let inner = try outer.readNextChunkAsSubReader()
|
||||||
var s = rsData.readNextChunk()
|
_ = try inner.readNextChunk()
|
||||||
|
let rsData = try inner.readNextChunkAsSubReader()
|
||||||
|
var r = try rsData.readNextChunk()
|
||||||
|
var s = try rsData.readNextChunk()
|
||||||
// This is fine IRL, but it freaks out CryptoKit
|
// This is fine IRL, but it freaks out CryptoKit
|
||||||
if r[0] == 0 {
|
if r[0] == 0 {
|
||||||
r.removeFirst()
|
r.removeFirst()
|
||||||
@@ -60,35 +64,25 @@ import CryptoKit
|
|||||||
}
|
}
|
||||||
var rs = r
|
var rs = r
|
||||||
rs.append(s)
|
rs.append(s)
|
||||||
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
|
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||||
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
|
// Correct signature
|
||||||
let store = await list.stores.first!
|
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||||
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
.isValidSignature(signature, for: context.dataToSign))
|
||||||
let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
|
||||||
let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
|
||||||
let invalidWrongKey = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa384Secret))
|
|
||||||
#expect(referenceValid)
|
|
||||||
#expect(derVerifies)
|
|
||||||
#expect(invalidRandomSignature == false)
|
|
||||||
#expect(invalidRandomData == false)
|
|
||||||
#expect(invalidWrongKey == false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Witness protocol
|
// MARK: Witness protocol
|
||||||
|
|
||||||
@Test func witnessObjectionStopsRequest() async {
|
@Test func witnessObjectionStopsRequest() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
let witness = StubWitness(speakNow: { _,_ in
|
let witness = StubWitness(speakNow: { _,_ in
|
||||||
return true
|
return true
|
||||||
}, witness: { _, _ in })
|
}, witness: { _, _ in })
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func witnessSignature() async {
|
@Test func witnessSignature() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
nonisolated(unsafe) var witnessed = false
|
nonisolated(unsafe) var witnessed = false
|
||||||
let witness = StubWitness(speakNow: { _, trace in
|
let witness = StubWitness(speakNow: { _, trace in
|
||||||
@@ -97,12 +91,12 @@ import CryptoKit
|
|||||||
witnessed = true
|
witnessed = true
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
|
_ = await agent.handle(request: request, provenance: .test)
|
||||||
#expect(witnessed)
|
#expect(witnessed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func requestTracing() async {
|
@Test func requestTracing() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
||||||
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
||||||
@@ -113,36 +107,40 @@ import CryptoKit
|
|||||||
witnessTrace = trace
|
witnessTrace = trace
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
|
_ = await agent.handle(request: request, provenance: .test)
|
||||||
#expect(witnessTrace == speakNowTrace)
|
#expect(witnessTrace == speakNowTrace)
|
||||||
#expect(witnessTrace?.origin.displayName == "Finder")
|
#expect(witnessTrace == .test)
|
||||||
#expect(witnessTrace?.origin.validSignature == true)
|
|
||||||
#expect(witnessTrace?.origin.parentPID == 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Exception Handling
|
// MARK: Exception Handling
|
||||||
|
|
||||||
@Test func signatureException() async {
|
@Test func signatureException() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
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)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Unsupported
|
// MARK: Unsupported
|
||||||
|
|
||||||
@Test func unhandledAdd() async {
|
@Test func unhandledAdd() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity)
|
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = await agent.handle(request: .addIdentity, provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SigningRequestProvenance {
|
||||||
|
|
||||||
|
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
extension AgentTests {
|
extension AgentTests {
|
||||||
|
|
||||||
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||||
@@ -157,14 +155,13 @@ extension AgentTests {
|
|||||||
|
|
||||||
enum Requests {
|
enum Requests {
|
||||||
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
||||||
static let addIdentity = Data(base64Encoded: "AAAAARE=")!
|
|
||||||
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
||||||
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Responses {
|
enum Responses {
|
||||||
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
||||||
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")!
|
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
|
||||||
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import SecretAgentKit
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
struct StubFileHandleReader: FileHandleReader {
|
|
||||||
|
|
||||||
let availableData: Data
|
|
||||||
var fileDescriptor: Int32 {
|
|
||||||
NSWorkspace.shared.runningApplications.filter({ $0.localizedName == "Finder" }).first!.processIdentifier
|
|
||||||
}
|
|
||||||
var pidOfConnectedProcess: Int32 {
|
|
||||||
fileDescriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SecretAgentKit
|
|
||||||
|
|
||||||
class StubFileHandleWriter: FileHandleWriter, @unchecked Sendable {
|
|
||||||
|
|
||||||
var data = Data()
|
|
||||||
|
|
||||||
func write(_ data: Data) {
|
|
||||||
self.data.append(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
struct Stub {}
|
struct Stub {}
|
||||||
|
|
||||||
@@ -45,43 +46,15 @@ extension Stub {
|
|||||||
let privateData = (privateAttributes[kSecValueData] as! Data)
|
let privateData = (privateAttributes[kSecValueData] as! Data)
|
||||||
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
|
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
|
||||||
print(secret)
|
print(secret)
|
||||||
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))")
|
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||||
guard !shouldThrow else {
|
guard !shouldThrow else {
|
||||||
throw NSError(domain: "test", code: 0, userInfo: nil)
|
throw NSError(domain: "test", code: 0, userInfo: nil)
|
||||||
}
|
}
|
||||||
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([
|
let privateKey = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey)
|
||||||
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
return try privateKey.signature(for: data).rawRepresentation
|
||||||
kSecAttrKeySizeInBits: secret.keySize,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate
|
|
||||||
])
|
|
||||||
, nil)!
|
|
||||||
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
|
|
||||||
}
|
|
||||||
|
|
||||||
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool {
|
|
||||||
let attributes = KeychainDictionary([
|
|
||||||
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
|
||||||
kSecAttrKeySizeInBits: secret.keySize,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic
|
|
||||||
])
|
|
||||||
var verifyError: Unmanaged<CFError>?
|
|
||||||
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
|
|
||||||
guard let untypedSafe = untyped else {
|
|
||||||
throw NSError(domain: "test", code: 0, userInfo: nil)
|
|
||||||
}
|
|
||||||
let key = untypedSafe as! SecKey
|
|
||||||
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError)
|
|
||||||
if let verifyError {
|
|
||||||
if verifyError.takeUnretainedValue() ~= .verifyError {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
throw NSError(domain: "test", code: 0, userInfo: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verified
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
||||||
@@ -102,24 +75,22 @@ extension Stub {
|
|||||||
|
|
||||||
struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
|
struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
|
||||||
|
|
||||||
let id = UUID().uuidString.data(using: .utf8)!
|
let id = Data(UUID().uuidString.utf8)
|
||||||
let name = UUID().uuidString
|
let name = UUID().uuidString
|
||||||
let algorithm = Algorithm.ellipticCurve
|
let attributes: Attributes
|
||||||
|
|
||||||
let keySize: Int
|
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
let requiresAuthentication = false
|
let requiresAuthentication = false
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
|
|
||||||
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
||||||
self.keySize = keySize
|
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired, publicKeyAttribution: "ecdsa-\(keySize)@example.com")
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
"""
|
"""
|
||||||
Key Size \(keySize)
|
Key Size \(attributes.keyType.size)
|
||||||
Private: \(privateKey.base64EncodedString())
|
Private: \(privateKey.base64EncodedString())
|
||||||
Public: \(publicKey.base64EncodedString())
|
Public: \(publicKey.base64EncodedString())
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import Testing
|
|||||||
@testable import SecureEnclaveSecretKit
|
@testable import SecureEnclaveSecretKit
|
||||||
@testable import SmartCardSecretKit
|
@testable import SmartCardSecretKit
|
||||||
|
|
||||||
|
|
||||||
@Suite struct AnySecretTests {
|
@Suite struct AnySecretTests {
|
||||||
|
|
||||||
@Test func eraser() {
|
@Test func eraser() {
|
||||||
let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!)
|
let data = Data(UUID().uuidString.utf8)
|
||||||
|
let secret = SmartCard.Secret(id: data, name: "Name", publicKey: data, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired))
|
||||||
let erased = AnySecret(secret)
|
let erased = AnySecret(secret)
|
||||||
#expect(erased.id == secret.id as AnyHashable)
|
#expect(erased.id == secret.id as AnyHashable)
|
||||||
#expect(erased.name == secret.name)
|
#expect(erased.name == secret.name)
|
||||||
#expect(erased.algorithm == secret.algorithm)
|
#expect(erased.keyType == secret.keyType)
|
||||||
#expect(erased.keySize == secret.keySize)
|
|
||||||
#expect(erased.publicKey == secret.publicKey)
|
#expect(erased.publicKey == secret.publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,53 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import OSLog
|
import OSLog
|
||||||
import Combine
|
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import SecureEnclaveSecretKit
|
import SecureEnclaveSecretKit
|
||||||
import SmartCardSecretKit
|
import SmartCardSecretKit
|
||||||
import SecretAgentKit
|
import SecretAgentKit
|
||||||
import Brief
|
import Brief
|
||||||
import Observation
|
import Observation
|
||||||
|
import Common
|
||||||
|
|
||||||
@main
|
@main
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
@MainActor private let storeList: SecretStoreList = {
|
@MainActor private let storeList: SecretStoreList = {
|
||||||
let list = SecretStoreList()
|
let list = SecretStoreList()
|
||||||
list.add(store: SecureEnclave.Store())
|
let cryptoKit = SecureEnclave.Store()
|
||||||
|
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||||
|
try? migrator.migrate(to: cryptoKit)
|
||||||
|
list.add(store: cryptoKit)
|
||||||
list.add(store: SmartCard.Store())
|
list.add(store: SmartCard.Store())
|
||||||
return list
|
return list
|
||||||
}()
|
}()
|
||||||
private let updater = Updater(checkOnLaunch: true)
|
private let updater = Updater(checkOnLaunch: true)
|
||||||
private let notifier = Notifier()
|
private let notifier = Notifier()
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
|
||||||
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 = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String
|
let path = URL.socketPath as String
|
||||||
return SocketController(path: path)
|
return SocketController(path: path)
|
||||||
}()
|
}()
|
||||||
private var updateSink: AnyCancellable?
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
logger.debug("SecretAgent finished launching")
|
logger.debug("SecretAgent finished launching")
|
||||||
Task { @MainActor in
|
Task {
|
||||||
socketController.handler = { [agent] reader, writer in
|
for await session in socketController.sessions {
|
||||||
await agent.handle(reader: reader, writer: writer)
|
Task {
|
||||||
|
let inputParser = try await XPCAgentInputParser()
|
||||||
|
do {
|
||||||
|
for await message in session.messages {
|
||||||
|
let request = try await inputParser.parse(data: message)
|
||||||
|
let agentResponse = await agent.handle(request: request, provenance: session.provenance)
|
||||||
|
try session.write(agentResponse)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
@@ -48,6 +61,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 }
|
||||||
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,22 +9,7 @@
|
|||||||
<key>Website</key>
|
<key>Website</key>
|
||||||
<string>https://github.com/maxgoedjen/secretive</string>
|
<string>https://github.com/maxgoedjen/secretive</string>
|
||||||
<key>Connections</key>
|
<key>Connections</key>
|
||||||
<array>
|
<array/>
|
||||||
<dict>
|
|
||||||
<key>IsIncoming</key>
|
|
||||||
<false/>
|
|
||||||
<key>Host</key>
|
|
||||||
<string>api.github.com</string>
|
|
||||||
<key>NetworkProtocol</key>
|
|
||||||
<string>TCP</string>
|
|
||||||
<key>Port</key>
|
|
||||||
<string>443</string>
|
|
||||||
<key>Purpose</key>
|
|
||||||
<string>Secretive checks GitHub for new versions and security updates.</string>
|
|
||||||
<key>DenyConsequences</key>
|
|
||||||
<string>If you deny these connections, you will not be notified about new versions and critical security updates.</string>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>Services</key>
|
<key>Services</key>
|
||||||
<array/>
|
<array/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ final class Notifier: Sendable {
|
|||||||
private let notificationDelegate = NotificationDelegate()
|
private let notificationDelegate = NotificationDelegate()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: "update_notification_update_button"), options: [])
|
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: .updateNotificationUpdateButton), options: [])
|
||||||
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: "update_notification_ignore_button"), options: [])
|
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: .updateNotificationIgnoreButton), options: [])
|
||||||
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
|
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
|
||||||
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
|
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ final class Notifier: Sendable {
|
|||||||
Measurement(value: 24, unit: UnitDuration.hours)
|
Measurement(value: 24, unit: UnitDuration.hours)
|
||||||
]
|
]
|
||||||
|
|
||||||
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: "persist_authentication_decline_button"), options: [])
|
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
|
||||||
var allPersistenceActions = [doNotPersistAction]
|
var allPersistenceActions = [doNotPersistAction]
|
||||||
|
|
||||||
let formatter = DateComponentsFormatter()
|
let formatter = DateComponentsFormatter()
|
||||||
@@ -41,7 +41,7 @@ final class Notifier: Sendable {
|
|||||||
|
|
||||||
let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: [])
|
let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: [])
|
||||||
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
|
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
|
||||||
persistAuthenticationCategory.setValue(String(localized: "persist_authentication_accept_button"), forKey: "_actionsMenuTitle")
|
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
|
||||||
}
|
}
|
||||||
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
||||||
UNUserNotificationCenter.current().delegate = notificationDelegate
|
UNUserNotificationCenter.current().delegate = notificationDelegate
|
||||||
@@ -64,12 +64,12 @@ final class Notifier: Sendable {
|
|||||||
await notificationDelegate.state.setPending(secret: secret, store: store)
|
await notificationDelegate.state.setPending(secret: secret, store: store)
|
||||||
let notificationCenter = UNUserNotificationCenter.current()
|
let notificationCenter = UNUserNotificationCenter.current()
|
||||||
let notificationContent = UNMutableNotificationContent()
|
let notificationContent = UNMutableNotificationContent()
|
||||||
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)")
|
notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
|
||||||
notificationContent.subtitle = String(localized: "signed_notification_description_\(secret.name)")
|
notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name))
|
||||||
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
||||||
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
||||||
notificationContent.interruptionLevel = .timeSensitive
|
notificationContent.interruptionLevel = .timeSensitive
|
||||||
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.requiresAuthentication {
|
if await store.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) == nil && secret.authenticationRequirement.required {
|
||||||
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
||||||
}
|
}
|
||||||
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
||||||
@@ -79,17 +79,36 @@ final class Notifier: Sendable {
|
|||||||
try? await notificationCenter.add(request)
|
try? await notificationCenter.add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notify(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
|
||||||
|
await notificationDelegate.state.setPending(secret: secret, store: store)
|
||||||
|
let notificationCenter = UNUserNotificationCenter.current()
|
||||||
|
let notificationContent = UNMutableNotificationContent()
|
||||||
|
notificationContent.title = "pending" //String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
|
||||||
|
notificationContent.subtitle = "pending" //String(localized: .signedNotificationDescription(secretName: secret.name))
|
||||||
|
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
||||||
|
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
||||||
|
notificationContent.interruptionLevel = .timeSensitive
|
||||||
|
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
||||||
|
notificationContent.threadIdentifier = "\(secret.id)_\(provenance.hashValue)"
|
||||||
|
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
||||||
|
notificationContent.attachments = [attachment]
|
||||||
|
}
|
||||||
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
||||||
|
try? await notificationCenter.add(request)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
|
func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
|
||||||
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
|
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
|
||||||
let notificationCenter = UNUserNotificationCenter.current()
|
let notificationCenter = UNUserNotificationCenter.current()
|
||||||
let notificationContent = UNMutableNotificationContent()
|
let notificationContent = UNMutableNotificationContent()
|
||||||
if update.critical {
|
if update.critical {
|
||||||
notificationContent.interruptionLevel = .critical
|
notificationContent.interruptionLevel = .critical
|
||||||
notificationContent.title = String(localized: "update_notification_update_critical_title_\(update.name)")
|
notificationContent.title = String(localized: .updateNotificationUpdateCriticalTitle(updateName: update.name))
|
||||||
} else {
|
} else {
|
||||||
notificationContent.title = String(localized: "update_notification_update_normal_title_\(update.name)")
|
notificationContent.title = String(localized: .updateNotificationUpdateNormalTitle(updateName: update.name))
|
||||||
}
|
}
|
||||||
notificationContent.subtitle = String(localized: "update_notification_update_description")
|
notificationContent.subtitle = String(localized: .updateNotificationUpdateDescription)
|
||||||
notificationContent.body = update.body
|
notificationContent.body = update.body
|
||||||
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
|
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
||||||
@@ -103,6 +122,10 @@ extension Notifier: SigningWitness {
|
|||||||
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func witness(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
||||||
|
await notify(pendingAccessTo: secret, from: store, by: provenance)
|
||||||
|
}
|
||||||
|
|
||||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
||||||
await notify(accessTo: secret, from: store, by: provenance)
|
await notify(accessTo: secret, from: store, by: provenance)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,24 @@
|
|||||||
<!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>
|
||||||
|
|||||||
30
Sources/SecretAgent/XPCInputParser.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
11
Sources/SecretAgentInputParser/Info.plist
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>XPCService</key>
|
||||||
|
<dict>
|
||||||
|
<key>ServiceType</key>
|
||||||
|
<string>Application</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?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>
|
||||||
18
Sources/SecretAgentInputParser/SecretAgentInputParser.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
7
Sources/SecretAgentInputParser/main.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
import XPCWrappers
|
||||||
|
|
||||||
|
let delegate = XPCServiceDelegate(exportedObject: SecretAgentInputParser())
|
||||||
|
let listener = NSXPCListener.service()
|
||||||
|
listener.delegate = delegate
|
||||||
|
listener.resume()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>iOSPackagesShouldBuildARM64e</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?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 = "2600"
|
LastUpgradeVersion = "2640"
|
||||||
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 = "2600"
|
LastUpgradeVersion = "2640"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,77 +1,78 @@
|
|||||||
import Cocoa
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import SecureEnclaveSecretKit
|
import SecureEnclaveSecretKit
|
||||||
import SmartCardSecretKit
|
import SmartCardSecretKit
|
||||||
import Brief
|
import Brief
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
|
|
||||||
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
|
||||||
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
|
||||||
let list = SecretStoreList()
|
|
||||||
list.add(store: SecureEnclave.Store())
|
|
||||||
list.add(store: SmartCard.Store())
|
|
||||||
return list
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let _agentStatusChecker = AgentStatusChecker()
|
|
||||||
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
|
|
||||||
private static let _updater: any UpdaterProtocol = {
|
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
|
||||||
return Updater(checkOnLaunch: hasRunSetup)
|
|
||||||
}()
|
|
||||||
@Entry var updater: any UpdaterProtocol = _updater
|
|
||||||
|
|
||||||
@MainActor var secretStoreList: SecretStoreList {
|
|
||||||
EnvironmentValues._secretStoreList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Secretive: App {
|
struct Secretive: App {
|
||||||
|
|
||||||
private let justUpdatedChecker = JustUpdatedChecker()
|
@Environment(\.agentLaunchController) var agentLaunchController
|
||||||
@Environment(\.agentStatusChecker) var agentStatusChecker
|
@Environment(\.justUpdatedChecker) var justUpdatedChecker
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
|
||||||
@State private var showingSetup = false
|
|
||||||
@State private var showingCreation = false
|
|
||||||
|
|
||||||
@SceneBuilder var body: some Scene {
|
@SceneBuilder var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
|
ContentView()
|
||||||
.environment(EnvironmentValues._secretStoreList)
|
.environment(EnvironmentValues._secretStoreList)
|
||||||
.onAppear {
|
|
||||||
if !hasRunSetup {
|
|
||||||
showingSetup = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
guard hasRunSetup else { return }
|
Task {
|
||||||
agentStatusChecker.check()
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
if agentStatusChecker.running && justUpdatedChecker.justUpdated {
|
@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
|
// Relaunch the agent, since it'll be running from earlier update still
|
||||||
reinstallAgent()
|
try await agentLaunchController.forceLaunch()
|
||||||
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
|
}
|
||||||
forceLaunchAgent()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.commands {
|
.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) {
|
CommandGroup(after: CommandGroupPlacement.newItem) {
|
||||||
Button("app_menu_new_secret_button") {
|
Button(.appMenuNewSecretButton, systemImage: "plus") {
|
||||||
showingCreation = true
|
showCreateSecret?()
|
||||||
}
|
}
|
||||||
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
||||||
|
.disabled(showCreateSecret?.isEnabled == false)
|
||||||
}
|
}
|
||||||
CommandGroup(replacing: .help) {
|
CommandGroup(replacing: .help) {
|
||||||
Button("app_menu_help_button") {
|
Button(.appMenuHelpButton) {
|
||||||
NSWorkspace.shared.open(Constants.helpURL)
|
openURL(Constants.helpURL)
|
||||||
}
|
|
||||||
}
|
|
||||||
CommandGroup(after: .help) {
|
|
||||||
Button("app_menu_setup_button") {
|
|
||||||
showingSetup = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SidebarCommands()
|
SidebarCommands()
|
||||||
@@ -80,33 +81,56 @@ struct Secretive: App {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private enum Constants {
|
||||||
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
|
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
|
||||||
|
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
||||||
|
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
||||||
|
let list = SecretStoreList()
|
||||||
|
let cryptoKit = SecureEnclave.Store()
|
||||||
|
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||||
|
try? migrator.migrate(to: cryptoKit)
|
||||||
|
list.add(store: cryptoKit)
|
||||||
|
list.add(store: SmartCard.Store())
|
||||||
|
return list
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let _agentLaunchController = AgentLaunchController()
|
||||||
|
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
|
||||||
|
private static let _updater: any UpdaterProtocol = {
|
||||||
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
|
return Updater(checkOnLaunch: hasRunSetup)
|
||||||
|
}()
|
||||||
|
@Entry var updater: any UpdaterProtocol = _updater
|
||||||
|
|
||||||
|
private static let _justUpdatedChecker = JustUpdatedChecker()
|
||||||
|
@Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker
|
||||||
|
|
||||||
|
@MainActor var secretStoreList: SecretStoreList {
|
||||||
|
EnvironmentValues._secretStoreList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FocusedValues {
|
||||||
|
@Entry var showCreateSecret: OpenSheet?
|
||||||
|
}
|
||||||
|
|
||||||
|
final class OpenSheet {
|
||||||
|
|
||||||
|
let closure: () -> Void
|
||||||
|
let isEnabled: Bool
|
||||||
|
|
||||||
|
init(isEnabled: Bool = true, closure: @escaping () -> Void) {
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
self.closure = closure
|
||||||
|
}
|
||||||
|
|
||||||
|
func callAsFunction() {
|
||||||
|
closure()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
|
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 |