mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-10-02 12:49:55 +02:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e0d00d87 | ||
|
|
43ee687232 | ||
|
|
2382cd0153 | ||
|
|
19e1c66697 | ||
|
|
0a4f15e789 | ||
|
|
59aca9327f | ||
|
|
6122662b23 | ||
|
|
56afd30db5 | ||
|
|
70e32dcf99 | ||
|
|
e9a0ff31d3 | ||
|
|
8577e7cbc3 | ||
|
|
85ba5a1a37 | ||
|
|
039dddd107 | ||
|
|
b003286745 | ||
|
|
a727d110c8 | ||
|
|
fbc4133f39 | ||
|
|
437386b87e | ||
|
|
9bdf9775d2 | ||
|
|
03a31fb474 | ||
|
|
b337b24641 | ||
|
|
2f4d10d70d | ||
|
|
4033a5b947 | ||
|
|
2cc0157290 | ||
|
|
faa622e379 | ||
|
|
9f2c6d9e84 | ||
|
|
afb48529c7 | ||
|
|
6c56039ece | ||
|
|
2b712864d6 | ||
|
|
845b1ec313 | ||
|
|
595de41f03 | ||
|
|
d82f404166 | ||
|
|
a3bfcb316c | ||
|
|
bba4fb9e7c | ||
|
|
32a1a0bca9 | ||
|
|
bb0b6d8dc3 |
@@ -11,7 +11,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze (${{ matrix.language }})
|
name: Analyze (${{ matrix.language }})
|
||||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }}
|
runs-on: ${{ (matrix.language == 'swift' && 'xcode-27') || 'ubuntu-latest' }}
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -24,20 +24,15 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- language: actions
|
- language: actions
|
||||||
build-mode: none
|
build-mode: none
|
||||||
# Disable this until CodeQL supports Xcode 26 builds.
|
|
||||||
# - language: swift
|
|
||||||
# build-mode: manual
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
- if: matrix.build-mode == 'manual'
|
- if: matrix.build-mode == 'manual'
|
||||||
name: "Select Xcode"
|
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
|
||||||
- if: matrix.build-mode == 'manual'
|
- if: matrix.build-mode == 'manual'
|
||||||
name: "Build"
|
name: "Build"
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ name: Nightly
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 8 * * *"
|
- cron: "0 8 * * *"
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-26
|
runs-on: xcode-27
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
attestations: write
|
attestations: write
|
||||||
|
artifact-metadata: write
|
||||||
actions: read
|
actions: read
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -25,8 +25,6 @@ jobs:
|
|||||||
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
||||||
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
|
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
@@ -36,12 +34,12 @@ jobs:
|
|||||||
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
- name: Build
|
- name: Build
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
run: IDEEnableNewPackagePIFBuilder=NO xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||||
- name: Move to Artifact Folder
|
- name: Move to Artifact Folder
|
||||||
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
||||||
- name: Upload App to Artifacts
|
- name: Upload App to Artifacts
|
||||||
id: upload
|
id: upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Secretive
|
name: Secretive
|
||||||
path: Artifact
|
path: Artifact
|
||||||
@@ -60,7 +58,7 @@ jobs:
|
|||||||
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
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
|
- name: Attest
|
||||||
id: attest
|
id: attest
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest@v4
|
||||||
with:
|
with:
|
||||||
subject-name: "Secretive.zip"
|
subject-name: "Secretive.zip"
|
||||||
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: One-Off Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: xcode-27
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
attestations: write
|
||||||
|
artifact-metadata: write
|
||||||
|
actions: read
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- name: Setup Signing
|
||||||
|
env:
|
||||||
|
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||||
|
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
|
||||||
|
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
|
||||||
|
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
|
||||||
|
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
run: ./.github/scripts/signing.sh
|
||||||
|
- name: Update Build Number
|
||||||
|
env:
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
DATE=$(date "+%Y-%m-%d")
|
||||||
|
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_oneoff-$DATE/g" Sources/Config/Config.xcconfig
|
||||||
|
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
|
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
|
- name: Build
|
||||||
|
run: IDEEnableNewPackagePIFBuilder=NO xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||||
|
- name: Move to Artifact Folder
|
||||||
|
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
||||||
|
- name: Upload App to Artifacts
|
||||||
|
id: upload
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: Secretive
|
||||||
|
path: Artifact
|
||||||
|
- name: Download Zipped Artifact
|
||||||
|
id: download
|
||||||
|
env:
|
||||||
|
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
|
||||||
|
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
|
||||||
|
- name: Notarize
|
||||||
|
env:
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
||||||
|
- name: Attest
|
||||||
|
id: attest
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: "Secretive.zip"
|
||||||
|
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
runs-on: macos-26
|
runs-on: xcode-27
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -21,8 +21,6 @@ jobs:
|
|||||||
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
||||||
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
|
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||||
# SPM doesn't seem to pick up on the tests currently?
|
# SPM doesn't seem to pick up on the tests currently?
|
||||||
@@ -32,8 +30,9 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
attestations: write
|
attestations: write
|
||||||
|
artifact-metadata: write
|
||||||
actions: read
|
actions: read
|
||||||
runs-on: macos-26
|
runs-on: xcode-27
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -46,8 +45,6 @@ jobs:
|
|||||||
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
|
||||||
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
|
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref }}
|
TAG_NAME: ${{ github.ref }}
|
||||||
@@ -58,12 +55,12 @@ jobs:
|
|||||||
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_URL/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_URL/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: IDEEnableNewPackagePIFBuilder=NO xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||||
- name: Move to Artifact Folder
|
- name: Move to Artifact Folder
|
||||||
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
|
||||||
- name: Upload App to Artifacts
|
- name: Upload App to Artifacts
|
||||||
id: upload
|
id: upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Secretive.zip
|
name: Secretive.zip
|
||||||
path: Artifact
|
path: Artifact
|
||||||
@@ -82,7 +79,7 @@ jobs:
|
|||||||
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
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
|
- name: Attest
|
||||||
id: attest
|
id: attest
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest@v4
|
||||||
with:
|
with:
|
||||||
subject-path: "Secretive.zip"
|
subject-path: "Secretive.zip"
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
runs-on: macos-26
|
runs-on: xcode-27
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- name: Set Environment
|
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
|
||||||
- name: Test Main Packages
|
- name: Test Main Packages
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||||
# SPM doesn't seem to pick up on the tests currently?
|
# SPM doesn't seem to pick up on the tests currently?
|
||||||
|
|||||||
@@ -93,3 +93,7 @@ iOSInjectionProject/
|
|||||||
Archive.xcarchive
|
Archive.xcarchive
|
||||||
.DS_Store
|
.DS_Store
|
||||||
contents.xcworkspacedata
|
contents.xcworkspacedata
|
||||||
|
|
||||||
|
# Per-User Configs
|
||||||
|
|
||||||
|
Sources/Config/OpenSource.xcconfig
|
||||||
@@ -10,6 +10,10 @@ Security is obviously paramount for a project like Secretive. As such, any contr
|
|||||||
|
|
||||||
Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected.
|
Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected.
|
||||||
|
|
||||||
|
### AI/LLM Policy
|
||||||
|
|
||||||
|
For security and auditing reasons similar to the policy Secretive has on dependencies, any code generated with AI or LLM tools will not be accepted.
|
||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
|
|
||||||
All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md)
|
All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ let package = Package(
|
|||||||
.library(
|
.library(
|
||||||
name: "SmartCardSecretKit",
|
name: "SmartCardSecretKit",
|
||||||
targets: ["SmartCardSecretKit"]),
|
targets: ["SmartCardSecretKit"]),
|
||||||
|
.library(
|
||||||
|
name: "CertificateKit",
|
||||||
|
targets: ["CertificateKit"]),
|
||||||
|
.library(
|
||||||
|
name: "SSHProtocolKit",
|
||||||
|
targets: ["SSHProtocolKit"]),
|
||||||
|
.library(
|
||||||
|
name: "Formatters",
|
||||||
|
targets: ["Formatters"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
@@ -53,6 +62,33 @@ let package = Package(
|
|||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "CertificateKit",
|
||||||
|
dependencies: ["SecretKit", "Formatters"],
|
||||||
|
path: "Sources/Packages/Sources/CertificateKit",
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "SSHProtocolKit",
|
||||||
|
dependencies: ["SecretKit", "CertificateKit"],
|
||||||
|
path: "Sources/Packages/Sources/SSHProtocolKit",
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "SSHProtocolKitTests",
|
||||||
|
dependencies: ["SSHProtocolKit"],
|
||||||
|
path: "Sources/Packages/Tests/SSHProtocolKitTests",
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "Formatters",
|
||||||
|
dependencies: [],
|
||||||
|
path: "Sources/Packages/Sources/Formatters",
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +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
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2700"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "SecretAgentKit"
|
||||||
|
BuildableName = "SecretAgentKit"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
queueDebuggingEnabled = "No">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "SecretAgentKit"
|
||||||
|
BuildableName = "SecretAgentKit"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version:6.2
|
// swift-tools-version:6.4
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
@@ -7,7 +7,7 @@ let package = Package(
|
|||||||
name: "SecretivePackages",
|
name: "SecretivePackages",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v14)
|
.macOS(.v15)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
@@ -19,15 +19,33 @@ let package = Package(
|
|||||||
.library(
|
.library(
|
||||||
name: "SmartCardSecretKit",
|
name: "SmartCardSecretKit",
|
||||||
targets: ["SmartCardSecretKit"]),
|
targets: ["SmartCardSecretKit"]),
|
||||||
|
.library(
|
||||||
|
name: "CertificateKit",
|
||||||
|
targets: ["CertificateKit"]),
|
||||||
|
.library(
|
||||||
|
name: "SettingsKit",
|
||||||
|
targets: ["SettingsKit"]),
|
||||||
.library(
|
.library(
|
||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
targets: ["SecretAgentKit", "XPCWrappers"]),
|
targets: ["SecretAgentKit"]),
|
||||||
|
.library(
|
||||||
|
name: "Formatters",
|
||||||
|
targets: ["Formatters"]),
|
||||||
|
.library(
|
||||||
|
name: "Common",
|
||||||
|
targets: ["Common"]),
|
||||||
|
.library(
|
||||||
|
name: "SharedXPCServices",
|
||||||
|
targets: ["SharedXPCServices"]),
|
||||||
.library(
|
.library(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
targets: ["Brief"]),
|
targets: ["Brief"]),
|
||||||
.library(
|
.library(
|
||||||
name: "XPCWrappers",
|
name: "XPCWrappers",
|
||||||
targets: ["XPCWrappers"]),
|
targets: ["XPCWrappers"]),
|
||||||
|
.library(
|
||||||
|
name: "SSHProtocolKit",
|
||||||
|
targets: ["SSHProtocolKit"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
@@ -40,7 +58,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SecretKitTests",
|
name: "SecretKitTests",
|
||||||
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
dependencies: ["SecretKit", "SecretAgentKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
@@ -55,9 +73,21 @@ let package = Package(
|
|||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "CertificateKit",
|
||||||
|
dependencies: ["SecretKit", "Formatters"],
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "SettingsKit",
|
||||||
|
dependencies: [],
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
dependencies: ["SecretKit"],
|
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],
|
||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
@@ -65,9 +95,38 @@ let package = Package(
|
|||||||
name: "SecretAgentKitTests",
|
name: "SecretAgentKitTests",
|
||||||
dependencies: ["SecretAgentKit"],
|
dependencies: ["SecretAgentKit"],
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "SSHProtocolKit",
|
||||||
|
dependencies: ["SecretKit", "CertificateKit"],
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "SSHProtocolKitTests",
|
||||||
|
dependencies: ["SSHProtocolKit"],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "Formatters",
|
||||||
|
dependencies: [],
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "Common",
|
||||||
|
dependencies: ["SSHProtocolKit", "SecretKit"],
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "SharedXPCServices",
|
||||||
|
dependencies: ["XPCWrappers", "CertificateKit", "SSHProtocolKit"],
|
||||||
|
resources: [localization],
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
dependencies: ["XPCWrappers"],
|
dependencies: ["XPCWrappers", "SSHProtocolKit"],
|
||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,12 +34,12 @@ import XPCWrappers
|
|||||||
) {
|
) {
|
||||||
self.osVersion = osVersion
|
self.osVersion = osVersion
|
||||||
self.currentVersion = currentVersion
|
self.currentVersion = currentVersion
|
||||||
Task {
|
_ = Task {
|
||||||
if checkOnLaunch {
|
if checkOnLaunch {
|
||||||
try await checkForUpdates()
|
try await checkForUpdates()
|
||||||
}
|
}
|
||||||
while !Task.isCancelled {
|
while true {
|
||||||
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
|
try await Task.sleep(for: .seconds(Int(checkFrequency)))
|
||||||
try await checkForUpdates()
|
try await checkForUpdates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import Formatters
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct Certificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible {
|
||||||
|
|
||||||
|
public var openSSHCertificate: OpenSSHCertificate
|
||||||
|
public let rawData: Data
|
||||||
|
|
||||||
|
public init(openSSHCertificate: OpenSSHCertificate, rawData: Data) {
|
||||||
|
self.openSSHCertificate = openSSHCertificate
|
||||||
|
self.rawData = rawData
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String { Insecure.MD5.hash(data: rawData).formatted(.hex(separator: "")) }
|
||||||
|
|
||||||
|
public var debugDescription: String { openSSHCertificate.debugDescription }
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<OpenSSHCertificate, T>) -> T {
|
||||||
|
openSSHCertificate[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import Security
|
||||||
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
@Observable @MainActor public final class CertificateStore: Sendable {
|
||||||
|
|
||||||
|
public private(set) var certificates: [Certificate] = []
|
||||||
|
|
||||||
|
/// Initializes a Store.
|
||||||
|
public init() {
|
||||||
|
loadCertificates()
|
||||||
|
Task {
|
||||||
|
for await note in DistributedNotificationCenter.default().notifications(named: .certificateStoreUpdated) {
|
||||||
|
guard Constants.notificationToken != (note.object as? String) else {
|
||||||
|
// Don't reload if we're the ones triggering this by reloading.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loadCertificates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reloadCertificates() {
|
||||||
|
let before = certificates
|
||||||
|
certificates.removeAll()
|
||||||
|
loadCertificates()
|
||||||
|
if certificates != before {
|
||||||
|
NotificationCenter.default.post(name: .certificateStoreReloaded, object: self)
|
||||||
|
DistributedNotificationCenter.default().postNotificationName(.certificateStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(certificate: Certificate) throws {
|
||||||
|
let attributes = try JSONEncoder().encode(certificate.openSSHCertificate)
|
||||||
|
let keychainAttributes = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecAttrAccount: certificate.id,
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
kSecValueData: certificate.rawData,
|
||||||
|
kSecAttrGeneric: attributes
|
||||||
|
])
|
||||||
|
let status = SecItemAdd(keychainAttributes, nil)
|
||||||
|
if status != errSecSuccess && status != errSecDuplicateItem {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
reloadCertificates()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delete(certificate: Certificate) throws {
|
||||||
|
let deleteAttributes = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecAttrAccount: certificate.id,
|
||||||
|
])
|
||||||
|
let status = SecItemDelete(deleteAttributes)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
reloadCertificates()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update(certificate: Certificate) throws {
|
||||||
|
let updateQuery = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecAttrAccount: certificate.id,
|
||||||
|
])
|
||||||
|
|
||||||
|
let cert = try JSONEncoder().encode(certificate.openSSHCertificate)
|
||||||
|
let updatedAttributes = KeychainDictionary([
|
||||||
|
kSecAttrGeneric: cert,
|
||||||
|
])
|
||||||
|
|
||||||
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
reloadCertificates()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func certificates(for secret: any Secret) -> [Certificate] {
|
||||||
|
certificates.filter { $0.openSSHCertificate.publicKey.data == secret.publicKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CertificateStore {
|
||||||
|
|
||||||
|
/// Loads all certificates from the store.
|
||||||
|
private func loadCertificates() {
|
||||||
|
let queryAttributes = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecReturnData: true,
|
||||||
|
kSecMatchLimit: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes: true
|
||||||
|
])
|
||||||
|
var untyped: CFTypeRef?
|
||||||
|
unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let wrapped: [Certificate] = typed.compactMap {
|
||||||
|
do {
|
||||||
|
guard let data = $0[kSecValueData] as? Data,
|
||||||
|
let attributesData = $0[kSecAttrGeneric] as? Data else {
|
||||||
|
throw MissingAttributesError()
|
||||||
|
}
|
||||||
|
return Certificate(openSSHCertificate: try decoder.decode(OpenSSHCertificate.self, from: attributesData), rawData: data)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
if let validityRange = $0.validityRange {
|
||||||
|
validityRange.contains(Date())
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
certificates.append(contentsOf: wrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CertificateStore {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
static let keyClass = kSecClassGenericPassword as String
|
||||||
|
static let keyTag = "com.maxgoedjen.certificatestore.opensshcertificate"
|
||||||
|
static let notificationToken = UUID().uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnsupportedAlgorithmError: Error {}
|
||||||
|
struct MissingAttributesError: Error {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSNotification.Name {
|
||||||
|
|
||||||
|
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed certificates)
|
||||||
|
public static let certificateStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.updated")
|
||||||
|
// Internal notification that certificates were reloaded from the backing store.
|
||||||
|
public static let certificateStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.reloaded")
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import Formatters
|
||||||
|
|
||||||
|
public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, CustomDebugStringConvertible {
|
||||||
|
|
||||||
|
public var type: CertificateType
|
||||||
|
public var name: String
|
||||||
|
public var data: Data
|
||||||
|
|
||||||
|
public var publicKey: PublicKey
|
||||||
|
public var principals: [String]
|
||||||
|
public var keyID: String
|
||||||
|
public var serial: UInt64
|
||||||
|
public var validityRange: Range<Date>?
|
||||||
|
public var criticalOptions: [String]
|
||||||
|
public var extensions: [String]
|
||||||
|
public var signingKey: PublicKey
|
||||||
|
|
||||||
|
public init(
|
||||||
|
type: OpenSSHCertificate.CertificateType,
|
||||||
|
name: String,
|
||||||
|
data: Data,
|
||||||
|
publicKey: PublicKey,
|
||||||
|
principals: [String],
|
||||||
|
keyID: String,
|
||||||
|
serial: UInt64,
|
||||||
|
validityRange: Range<Date>? = nil,
|
||||||
|
criticalOptions: [String],
|
||||||
|
extensions: [String],
|
||||||
|
signingKey: PublicKey,
|
||||||
|
) {
|
||||||
|
self.type = type
|
||||||
|
self.name = name
|
||||||
|
self.data = data
|
||||||
|
self.publicKey = publicKey
|
||||||
|
self.principals = principals
|
||||||
|
self.keyID = keyID
|
||||||
|
self.serial = serial
|
||||||
|
self.validityRange = validityRange
|
||||||
|
self.criticalOptions = criticalOptions
|
||||||
|
self.extensions = extensions
|
||||||
|
self.signingKey = signingKey
|
||||||
|
}
|
||||||
|
|
||||||
|
public var debugDescription: String {
|
||||||
|
"OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OpenSSHCertificate {
|
||||||
|
|
||||||
|
public enum CertificateType: String, Sendable, Codable {
|
||||||
|
case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
|
||||||
|
case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
|
||||||
|
case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
|
||||||
|
|
||||||
|
public var keyIdentifier: String {
|
||||||
|
rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OpenSSHCertificate {
|
||||||
|
|
||||||
|
public struct PublicKey: Hashable, Sendable, Codable {
|
||||||
|
|
||||||
|
public let keyType: String
|
||||||
|
public let curveName: String
|
||||||
|
public let data: Data
|
||||||
|
|
||||||
|
public init(keyType: String, curveName: String, data: Data) {
|
||||||
|
self.keyType = keyType
|
||||||
|
self.curveName = curveName
|
||||||
|
self.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+12
-12
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PrimaryButtonModifier: ViewModifier {
|
public struct PrimaryButtonModifier: ViewModifier {
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Environment(\.isEnabled) var isEnabled
|
@Environment(\.isEnabled) var isEnabled
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
public func body(content: Content) -> some View {
|
||||||
// Tinted glass prominent is really hard to read on 26.0.
|
// Tinted glass prominent is really hard to read on 26.0.
|
||||||
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
|
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
|
||||||
content.buttonStyle(.glassProminent)
|
content.buttonStyle(.glassProminent)
|
||||||
@@ -18,15 +18,15 @@ struct PrimaryButtonModifier: ViewModifier {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|
||||||
func primaryButton() -> some View {
|
public func primaryButton() -> some View {
|
||||||
modifier(PrimaryButtonModifier())
|
modifier(PrimaryButtonModifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ToolbarCircleButtonModifier: ViewModifier {
|
public struct ToolbarCircleButtonModifier: ViewModifier {
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
public func body(content: Content) -> some View {
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
content
|
content
|
||||||
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
|
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
|
||||||
@@ -40,15 +40,15 @@ struct ToolbarCircleButtonModifier: ViewModifier {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|
||||||
func toolbarCircleButton() -> some View {
|
public func toolbarCircleButton() -> some View {
|
||||||
modifier(ToolbarCircleButtonModifier())
|
modifier(ToolbarCircleButtonModifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NormalButtonModifier: ViewModifier {
|
public struct NormalButtonModifier: ViewModifier {
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
public func body(content: Content) -> some View {
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
content.buttonStyle(.glass)
|
content.buttonStyle(.glass)
|
||||||
} else {
|
} else {
|
||||||
@@ -60,17 +60,17 @@ struct NormalButtonModifier: ViewModifier {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|
||||||
func normalButton() -> some View {
|
public func normalButton() -> some View {
|
||||||
modifier(NormalButtonModifier())
|
modifier(NormalButtonModifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DangerButtonModifier: ViewModifier {
|
public struct DangerButtonModifier: ViewModifier {
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
public func body(content: Content) -> some View {
|
||||||
// Tinted glass prominent is really hard to read on 26.0.
|
// Tinted glass prominent is really hard to read on 26.0.
|
||||||
if #available(macOS 26.0, *), colorScheme == .dark {
|
if #available(macOS 26.0, *), colorScheme == .dark {
|
||||||
content.buttonStyle(.glassProminent)
|
content.buttonStyle(.glassProminent)
|
||||||
@@ -87,7 +87,7 @@ struct DangerButtonModifier: ViewModifier {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|
||||||
func danger() -> some View {
|
public func danger() -> some View {
|
||||||
modifier(DangerButtonModifier())
|
modifier(DangerButtonModifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
public struct MultilineInfoView<TitleView: View, ItemView: View>: View {
|
||||||
|
|
||||||
|
var titleView: TitleView
|
||||||
|
var items: ItemView
|
||||||
|
|
||||||
|
public init(@ViewBuilder titleView: () -> TitleView, @ContentBuilder items: () -> ItemView) {
|
||||||
|
self.titleView = titleView()
|
||||||
|
self.items = items()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource? = nil, image: Image, items: [String]) where TitleView == FixedTitleView, ItemView == FixedItemsView {
|
||||||
|
self.init {
|
||||||
|
FixedTitleView(title: title, subtitle: subtitle, image: image)
|
||||||
|
} items: {
|
||||||
|
FixedItemsView(items: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource? = nil, image: Image, @ContentBuilder items: () -> ItemView) where TitleView == FixedTitleView {
|
||||||
|
self.init {
|
||||||
|
FixedTitleView(title: title, subtitle: subtitle, image: image)
|
||||||
|
} items: {
|
||||||
|
items()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
titleView
|
||||||
|
.bold()
|
||||||
|
.safeAreaPadding(20)
|
||||||
|
Group(subviews: items) { subviews in
|
||||||
|
ForEach(subviews: subviews) { subview in
|
||||||
|
Divider()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.opacity(subview.id == subviews.first?.id ? 1 : 0.5)
|
||||||
|
HoveringSubview {
|
||||||
|
subview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
._background(interactionState: .normal)
|
||||||
|
.frame(minWidth: 150, maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HoveringSubview<Content: View>: View {
|
||||||
|
|
||||||
|
@State private var multilineAction: MultilineItemActionKey.Box?
|
||||||
|
private let content: Content
|
||||||
|
@State private var interactionState: InteractionState = .normal
|
||||||
|
|
||||||
|
init(@ViewBuilder _ content: () -> Content) {
|
||||||
|
self.multilineAction = nil
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
content
|
||||||
|
Spacer()
|
||||||
|
if let image = multilineAction?.image {
|
||||||
|
image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaPadding(20)
|
||||||
|
.onHover { hovering in
|
||||||
|
withAnimation {
|
||||||
|
guard multilineAction != nil else { return }
|
||||||
|
interactionState = hovering ? .hovering : .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
TapGesture()
|
||||||
|
.onEnded {
|
||||||
|
multilineAction?.closure()
|
||||||
|
withAnimation {
|
||||||
|
interactionState = .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.backgroundStyle(.primary.opacity(interactionState == .hovering ? 0.3 : 0))
|
||||||
|
.onPreferenceChange(MultilineItemActionKey.self) {
|
||||||
|
multilineAction = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct FixedTitleView: View {
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let subtitle: LocalizedStringResource?
|
||||||
|
let image: Image
|
||||||
|
|
||||||
|
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource?, image: Image) {
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack {
|
||||||
|
image
|
||||||
|
.renderingMode(.template)
|
||||||
|
.imageScale(.large)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FixedItemsView: View {
|
||||||
|
|
||||||
|
let items: [String]
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ForEach(Array(items.enumerated()), id: \.offset) {
|
||||||
|
Text($0.element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate enum InteractionState {
|
||||||
|
case normal, hovering
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
fileprivate func _background(interactionState: InteractionState, cornerRadius: Double = 15) -> some View {
|
||||||
|
modifier(BackgroundViewModifier(interactionState: interactionState, cornerRadius: cornerRadius))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.appearsActive) private var appearsActive
|
||||||
|
|
||||||
|
let interactionState: InteractionState
|
||||||
|
let cornerRadius: Double
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
content
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.mask(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 5)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.background(backgroundColor(interactionState: interactionState))
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func backgroundColor(interactionState: InteractionState) -> Color {
|
||||||
|
guard appearsActive else { return Color.clear }
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
let base: Color
|
||||||
|
if #available(macOS 27.0, *) {
|
||||||
|
base = .clear
|
||||||
|
} else {
|
||||||
|
base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1)
|
||||||
|
}
|
||||||
|
switch interactionState {
|
||||||
|
case .normal:
|
||||||
|
return base
|
||||||
|
case .hovering:
|
||||||
|
return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch interactionState {
|
||||||
|
case .normal:
|
||||||
|
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
||||||
|
case .hovering:
|
||||||
|
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MultilineItemAction: ViewModifier {
|
||||||
|
|
||||||
|
let image: Image?
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
public init(image: Image?, action: @escaping () -> Void) {
|
||||||
|
self.image = image
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
public func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.preference(key: MultilineItemActionKey.self, value: MultilineItemActionKey.Box(image: image, closure: action))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
public func multilineItemAction(image: Image? = nil, action: @escaping () -> Void) -> some View {
|
||||||
|
modifier(MultilineItemAction(image: image, action: action))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MultilineItemActionKey : @MainActor PreferenceKey, ~Sendable {
|
||||||
|
|
||||||
|
public struct Box: Equatable {
|
||||||
|
|
||||||
|
let id: UUID = UUID()
|
||||||
|
let image: Image?
|
||||||
|
let closure: () -> Void
|
||||||
|
|
||||||
|
public static func == (lhs: borrowing MultilineItemActionKey.Box, rhs: borrowing MultilineItemActionKey.Box) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public typealias Value = Box?
|
||||||
|
@MainActor public static let defaultValue: Box? = nil
|
||||||
|
|
||||||
|
public static func reduce(value: inout Box?, nextValue: () -> Box?) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
MultilineInfoView {
|
||||||
|
Text("Hello")
|
||||||
|
} items: {
|
||||||
|
Text("World")
|
||||||
|
.multilineItemAction(image: Image(systemName: "person.wave")) {
|
||||||
|
print("Hello")
|
||||||
|
}
|
||||||
|
Text("World")
|
||||||
|
Text("World")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
#Preview {
|
||||||
|
MultilineInfoView(title: "One", image: Image(systemName: "figure.wave"), items: ["Hello world.", "Hello world."])
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CertificateKit
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
|
||||||
|
public static var agentHomeURL: URL {
|
||||||
|
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var socketPath: String {
|
||||||
|
#if DEBUG
|
||||||
|
URL.agentHomeURL.appendingPathComponent("socket-debug.ssh").path()
|
||||||
|
#else
|
||||||
|
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var publicKeyDirectory: URL {
|
||||||
|
agentHomeURL.appending(component: "PublicKeys")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var certificatesDirectory: URL {
|
||||||
|
agentHomeURL.appending(component: "Certificates")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path for a Secret's public key.
|
||||||
|
/// - Parameter secret: The Secret to return the path for.
|
||||||
|
/// - Returns: The path to the Secret's public key.
|
||||||
|
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
||||||
|
public static func publicKeyPath<SecretType: Secret>(for secret: SecretType, in directory: URL) -> String {
|
||||||
|
let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
|
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||||
|
return directory.appending(component: "\(minimalHex).pub").path()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path for a certificate.
|
||||||
|
/// - Parameter certificate: The Certificate to return the path for.
|
||||||
|
/// - Returns: The path to the Certificate.
|
||||||
|
/// - Warning: This method returning a path does not imply that a certificate has been written to disk already. This method only describes where it will be written to.
|
||||||
|
public static func certificatePath(for certificateID: String, in directory: URL) -> String {
|
||||||
|
return directory.appending(component: "\(certificateID)-cert.pub").path()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
|
||||||
|
public var normalizedPathAndFolder: (String, String) {
|
||||||
|
// All foundation-based normalization methods replace this with the container directly.
|
||||||
|
let processedPath = replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
|
||||||
|
let url = URL(filePath: processedPath)
|
||||||
|
let folder = url.deletingLastPathComponent().path()
|
||||||
|
return (processedPath, folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
public struct HexDataStyle<SequenceType: Sequence>: Hashable, Codable {
|
||||||
|
|
||||||
|
let separator: String
|
||||||
|
|
||||||
|
public init(separator: String) {
|
||||||
|
self.separator = separator
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HexDataStyle: FormatStyle where SequenceType.Element == UInt8 {
|
||||||
|
|
||||||
|
public func format(_ value: SequenceType) -> String {
|
||||||
|
value
|
||||||
|
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
||||||
|
.joined(separator: separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FormatStyle where Self == HexDataStyle<Data> {
|
||||||
|
|
||||||
|
public static func hex(separator: String = "") -> HexDataStyle<Data> {
|
||||||
|
HexDataStyle(separator: separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
|
||||||
|
|
||||||
|
public static func hex(separator: String = ":") -> HexDataStyle<Insecure.MD5Digest> {
|
||||||
|
HexDataStyle(separator: separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Base64DataStyle<SequenceType: Sequence>: Hashable, Codable {
|
||||||
|
|
||||||
|
private let stripPadding: Bool
|
||||||
|
|
||||||
|
public init(stripPadding: Bool) {
|
||||||
|
self.stripPadding = stripPadding
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Base64DataStyle: FormatStyle where SequenceType.Element == UInt8 {
|
||||||
|
|
||||||
|
public func format(_ value: SequenceType) -> String {
|
||||||
|
let base64 = Data(value).base64EncodedString()
|
||||||
|
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
|
||||||
|
return base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FormatStyle where Self == Base64DataStyle<Data> {
|
||||||
|
|
||||||
|
public static func base64(stripPadding: Bool) -> Base64DataStyle<Data> {
|
||||||
|
Base64DataStyle(stripPadding: stripPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FormatStyle where Self == Base64DataStyle<SHA256.Digest> {
|
||||||
|
|
||||||
|
public static func base64(stripPadding: Bool) -> Base64DataStyle<SHA256.Digest> {
|
||||||
|
Base64DataStyle(stripPadding: stripPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import CertificateKit
|
||||||
|
import Formatters
|
||||||
|
|
||||||
|
/// Generates OpenSSH representations of Certificates.
|
||||||
|
public struct OpenSSHCertificateWriter: Sendable {
|
||||||
|
|
||||||
|
/// Initializes the writer.
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH data payload identifying the certificate.
|
||||||
|
/// - Returns: OpenSSH data payload identifying the certificate.
|
||||||
|
public func data(publicKey: OpenSSHCertificate.PublicKey) -> Data {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||||
|
publicKey.keyType.lengthAndData +
|
||||||
|
publicKey.curveName.lengthAndData +
|
||||||
|
publicKey.data.lengthAndData
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH SHA256 fingerprint string.
|
||||||
|
/// - Returns: OpenSSH SHA256 fingerprint string.
|
||||||
|
public func openSSHSHA256KeyFingerprint(publicKey: OpenSSHCertificate.PublicKey) -> String {
|
||||||
|
// OpenSSL format seems to strip the padding at the end.
|
||||||
|
let cleaned = SHA256.hash(data: data(publicKey: publicKey)).formatted(.base64(stripPadding: true))
|
||||||
|
return "SHA256:\(cleaned)"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
import CertificateKit
|
||||||
|
|
||||||
|
public protocol OpenSSHCertificateParserProtocol {
|
||||||
|
func parse(data: Data) async throws -> OpenSSHCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable {
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parse(data: Data) throws(OpenSSHCertificateError) -> OpenSSHCertificate {
|
||||||
|
let string = String(decoding: data, as: UTF8.self)
|
||||||
|
var elements = string
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.components(separatedBy: " ")
|
||||||
|
guard elements.count >= 2 else {
|
||||||
|
throw OpenSSHCertificateError.parsingFailed
|
||||||
|
}
|
||||||
|
let typeString = elements.removeFirst()
|
||||||
|
guard let type = OpenSSHCertificate.CertificateType(rawValue: typeString) else { throw .unsupportedType }
|
||||||
|
let encodedKey = elements.removeFirst()
|
||||||
|
guard let decoded = Data(base64Encoded: encodedKey) else {
|
||||||
|
throw OpenSSHCertificateError.parsingFailed
|
||||||
|
}
|
||||||
|
let comment = elements.first
|
||||||
|
do {
|
||||||
|
let dataParser = OpenSSHReader(data: decoded)
|
||||||
|
let publicKeyType = try dataParser.readNextChunkAsString() // Theoretically the same as typeString, but
|
||||||
|
.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||||
|
_ = try dataParser.readNextChunk() // Nonce
|
||||||
|
let publicKeyCurveName = try dataParser.readNextChunkAsString()
|
||||||
|
let publicKeyData = try dataParser.readNextChunk()
|
||||||
|
let publicKey = OpenSSHCertificate.PublicKey(keyType: publicKeyType, curveName: publicKeyCurveName, data: publicKeyData)
|
||||||
|
let serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
|
||||||
|
let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true)
|
||||||
|
_ = role
|
||||||
|
let keyIdentifier = try dataParser.readNextChunkAsString()
|
||||||
|
let principalsReader = try dataParser.readNextChunkAsSubReader()
|
||||||
|
var principals: [String] = []
|
||||||
|
while !principalsReader.done {
|
||||||
|
try principals.append(principalsReader.readNextChunkAsString())
|
||||||
|
}
|
||||||
|
let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
|
||||||
|
let validBefore = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
|
||||||
|
let validityRange = Date(timeIntervalSince1970: TimeInterval(validAfter))..<Date(timeIntervalSince1970: TimeInterval(validBefore))
|
||||||
|
let criticalOptionsReader = try dataParser.readNextChunkAsSubReader()
|
||||||
|
var criticalOptions: [String] = []
|
||||||
|
while !criticalOptionsReader.done {
|
||||||
|
let next = try criticalOptionsReader.readNextChunkAsString()
|
||||||
|
if !next.isEmpty {
|
||||||
|
criticalOptions.append(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let extensionsReader = try dataParser.readNextChunkAsSubReader()
|
||||||
|
var extensions: [String] = []
|
||||||
|
while !extensionsReader.done {
|
||||||
|
let next = try extensionsReader.readNextChunkAsString()
|
||||||
|
if !next.isEmpty {
|
||||||
|
extensions.append(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = try dataParser.readNextChunk() // reserved
|
||||||
|
let signingKeyReader = try dataParser.readNextChunkAsSubReader()
|
||||||
|
let signingKeyType = try signingKeyReader.readNextChunkAsString()
|
||||||
|
let signingKeyCurveName = try signingKeyReader.readNextChunkAsString()
|
||||||
|
let signingKeyData = try signingKeyReader.readNextChunk()
|
||||||
|
let signingKey = OpenSSHCertificate.PublicKey(keyType: signingKeyType, curveName: signingKeyCurveName, data: signingKeyData)
|
||||||
|
|
||||||
|
return OpenSSHCertificate(
|
||||||
|
type: type,
|
||||||
|
name: comment ?? keyIdentifier,
|
||||||
|
data: decoded,
|
||||||
|
publicKey: publicKey,
|
||||||
|
principals: principals,
|
||||||
|
keyID: keyIdentifier,
|
||||||
|
serial: serialNumber,
|
||||||
|
validityRange: validityRange,
|
||||||
|
criticalOptions: criticalOptions,
|
||||||
|
extensions: extensions,
|
||||||
|
signingKey: signingKey,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw .parsingFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenSSHCertificateError: Error, Codable {
|
||||||
|
case unsupportedType
|
||||||
|
case parsingFailed
|
||||||
|
}
|
||||||
+4
-7
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
/// Generates OpenSSH representations of the public key sof secrets.
|
/// Generates OpenSSH representations of the public key sof secrets.
|
||||||
public struct OpenSSHPublicKeyWriter: Sendable {
|
public struct OpenSSHPublicKeyWriter: Sendable {
|
||||||
@@ -18,7 +19,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
|
|||||||
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
|
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
|
||||||
secret.publicKey.lengthAndData
|
secret.publicKey.lengthAndData
|
||||||
case .mldsa:
|
case .mldsa:
|
||||||
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
|
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
|
||||||
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
||||||
secret.publicKey.lengthAndData
|
secret.publicKey.lengthAndData
|
||||||
case .rsa:
|
case .rsa:
|
||||||
@@ -40,18 +41,14 @@ public struct OpenSSHPublicKeyWriter: Sendable {
|
|||||||
/// - Returns: OpenSSH SHA256 fingerprint string.
|
/// - Returns: OpenSSH SHA256 fingerprint string.
|
||||||
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
// OpenSSL format seems to strip the padding at the end.
|
// OpenSSL format seems to strip the padding at the end.
|
||||||
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
|
let cleaned = SHA256.hash(data: data(secret: secret)).formatted(.base64(stripPadding: true))
|
||||||
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
|
|
||||||
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
|
||||||
return "SHA256:\(cleaned)"
|
return "SHA256:\(cleaned)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an OpenSSH MD5 fingerprint string.
|
/// Generates an OpenSSH MD5 fingerprint string.
|
||||||
/// - Returns: OpenSSH MD5 fingerprint string.
|
/// - Returns: OpenSSH MD5 fingerprint string.
|
||||||
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
Insecure.MD5.hash(data: data(secret: secret))
|
Insecure.MD5.hash(data: data(secret: secret)).formatted(.hex(separator: ":"))
|
||||||
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
|
||||||
.joined(separator: ":")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func comment<SecretType: Secret>(secret: SecretType) -> String {
|
public func comment<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Reads OpenSSH protocol data.
|
||||||
|
public final class OpenSSHReader {
|
||||||
|
|
||||||
|
var remaining: Data
|
||||||
|
var done = false
|
||||||
|
|
||||||
|
/// Initialize the reader with an OpenSSH data payload.
|
||||||
|
/// - Parameter data: The data to read.
|
||||||
|
public init(data: Data) {
|
||||||
|
remaining = Data(data)
|
||||||
|
if remaining.count == 0 {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the next chunk of data from the playload.
|
||||||
|
/// - Returns: The next chunk of data.
|
||||||
|
public func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
|
||||||
|
let length = try readNextBytes(as: UInt32.self, convertEndianness: convertEndianness)
|
||||||
|
guard remaining.count >= length else { throw .beyondBounds }
|
||||||
|
let dataRange = 0..<Int(length)
|
||||||
|
let ret = Data(remaining[dataRange])
|
||||||
|
remaining.removeSubrange(dataRange)
|
||||||
|
if remaining.isEmpty {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextBytes<T: FixedWidthInteger>(as: T.Type, convertEndianness: Bool = true) throws(OpenSSHReaderError) -> T {
|
||||||
|
let size = MemoryLayout<T>.size
|
||||||
|
guard remaining.count >= size else { throw .beyondBounds }
|
||||||
|
let lengthRange = 0..<size
|
||||||
|
let lengthChunk = remaining[lengthRange]
|
||||||
|
remaining.removeSubrange(lengthRange)
|
||||||
|
if remaining.isEmpty {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
let value = unsafe lengthChunk.bytes.unsafeLoad(as: T.self)
|
||||||
|
return convertEndianness ? T(value.bigEndian) : T(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextByteAsBool() throws(OpenSSHReaderError) -> Bool {
|
||||||
|
let size = MemoryLayout<Bool>.size
|
||||||
|
guard remaining.count >= size else { throw .beyondBounds }
|
||||||
|
let lengthRange = 0..<size
|
||||||
|
let lengthChunk = remaining[lengthRange]
|
||||||
|
remaining.removeSubrange(lengthRange)
|
||||||
|
if remaining.isEmpty {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
return unsafe lengthChunk.bytes.unsafeLoad(as: Bool.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
|
||||||
|
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
|
||||||
|
OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenSSHReaderError: Error, Codable {
|
||||||
|
case incorrectFormat
|
||||||
|
case beyondBounds
|
||||||
|
}
|
||||||
+22
-12
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
/// Generates OpenSSH representations of Secrets.
|
/// Generates OpenSSH representations of Secrets.
|
||||||
public struct OpenSSHSignatureWriter: Sendable {
|
public struct OpenSSHSignatureWriter: Sendable {
|
||||||
@@ -16,7 +17,7 @@ public struct OpenSSHSignatureWriter: Sendable {
|
|||||||
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||||
ecdsaSignature(signature, keyType: secret.keyType)
|
ecdsaSignature(signature, keyType: secret.keyType)
|
||||||
case .mldsa:
|
case .mldsa:
|
||||||
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms
|
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
|
||||||
mldsaSignature(signature, keyType: secret.keyType)
|
mldsaSignature(signature, keyType: secret.keyType)
|
||||||
case .rsa:
|
case .rsa:
|
||||||
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
||||||
@@ -29,19 +30,28 @@ public struct OpenSSHSignatureWriter: Sendable {
|
|||||||
|
|
||||||
extension OpenSSHSignatureWriter {
|
extension OpenSSHSignatureWriter {
|
||||||
|
|
||||||
|
/// Converts a fixed-width big-endian integer (e.g. r/s from CryptoKit rawRepresentation) into an SSH mpint.
|
||||||
|
/// Strips unnecessary leading zeros and prefixes `0x00` if needed to keep the value positive.
|
||||||
|
private func mpint(fromFixedWidthPositiveBytes bytes: Data) -> Data {
|
||||||
|
// mpint zero is encoded as a string with zero bytes of data.
|
||||||
|
guard let firstNonZeroIndex = bytes.firstIndex(where: { $0 != 0x00 }) else {
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = Data(bytes[firstNonZeroIndex...])
|
||||||
|
|
||||||
|
if let first = trimmed.first, first >= 0x80 {
|
||||||
|
var prefixed = Data([0x00])
|
||||||
|
prefixed.append(trimmed)
|
||||||
|
return prefixed
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
||||||
let rawLength = rawRepresentation.count/2
|
let rawLength = rawRepresentation.count/2
|
||||||
// Check if we need to pad with 0x00 to prevent certain
|
let r = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[0..<rawLength]))
|
||||||
// ssh servers from thinking r or s is negative
|
let s = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[rawLength...]))
|
||||||
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
|
|
||||||
var r = Data(rawRepresentation[0..<rawLength])
|
|
||||||
if paddingRange ~= r.first! {
|
|
||||||
r.insert(0x00, at: 0)
|
|
||||||
}
|
|
||||||
var s = Data(rawRepresentation[rawLength...])
|
|
||||||
if paddingRange ~= s.first! {
|
|
||||||
s.insert(0x00, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var signatureChunk = Data()
|
var signatureChunk = Data()
|
||||||
signatureChunk.append(r.lengthAndData)
|
signatureChunk.append(r.lengthAndData)
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
import CertificateKit
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
public protocol SSHAgentInputParserProtocol {
|
||||||
|
|
||||||
|
func parse(data: Data) async throws -> SSHAgent.Request
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
|
||||||
|
logger.debug("Parsing new data")
|
||||||
|
guard data.count > 4 else {
|
||||||
|
throw .invalidData
|
||||||
|
}
|
||||||
|
let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4
|
||||||
|
let rawRequestInt = data[4]
|
||||||
|
let remainingDataRange = 5..<min(Int(specifiedLength), data.count)
|
||||||
|
lazy var body: Data = { Data(data[remainingDataRange]) }()
|
||||||
|
switch rawRequestInt {
|
||||||
|
case SSHAgent.Request.requestIdentities.protocolID:
|
||||||
|
return .requestIdentities
|
||||||
|
case SSHAgent.Request.signRequest(.empty).protocolID:
|
||||||
|
do {
|
||||||
|
return .signRequest(try signatureRequestContext(from: body))
|
||||||
|
} catch {
|
||||||
|
throw .openSSHReader(error)
|
||||||
|
}
|
||||||
|
case SSHAgent.Request.addIdentity.protocolID:
|
||||||
|
return .addIdentity
|
||||||
|
case SSHAgent.Request.removeIdentity.protocolID:
|
||||||
|
return .removeIdentity
|
||||||
|
case SSHAgent.Request.removeAllIdentities.protocolID:
|
||||||
|
return .removeAllIdentities
|
||||||
|
case SSHAgent.Request.addIDConstrained.protocolID:
|
||||||
|
return .addIDConstrained
|
||||||
|
case SSHAgent.Request.addSmartcardKey.protocolID:
|
||||||
|
return .addSmartcardKey
|
||||||
|
case SSHAgent.Request.removeSmartcardKey.protocolID:
|
||||||
|
return .removeSmartcardKey
|
||||||
|
case SSHAgent.Request.lock.protocolID:
|
||||||
|
return .lock
|
||||||
|
case SSHAgent.Request.unlock.protocolID:
|
||||||
|
return .unlock
|
||||||
|
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
|
||||||
|
return .addSmartcardKeyConstrained
|
||||||
|
case SSHAgent.Request.protocolExtension(.empty).protocolID:
|
||||||
|
return .protocolExtension(try protocolExtension(from: body))
|
||||||
|
default:
|
||||||
|
return .unknown(rawRequestInt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SSHAgentInputParser {
|
||||||
|
|
||||||
|
private enum Constants {
|
||||||
|
static let userAuthMagic: UInt8 = 50 // SSH2_MSG_USERAUTH_REQUEST
|
||||||
|
static let sshSigMagic = Data("SSHSIG".utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
|
||||||
|
let reader = OpenSSHReader(data: data)
|
||||||
|
let rawKeyBlob = try reader.readNextChunk()
|
||||||
|
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
|
||||||
|
let rawPayload = try reader.readNextChunk()
|
||||||
|
let payload: SSHAgent.Request.SignatureRequestContext.SignaturePayload
|
||||||
|
do {
|
||||||
|
if rawPayload.count > 6 && rawPayload[0..<6] == Constants.sshSigMagic {
|
||||||
|
payload = .init(raw: rawPayload, decoded: .sshSig(try sshSigPayload(from: rawPayload[6...])))
|
||||||
|
} else {
|
||||||
|
payload = .init(raw: rawPayload, decoded: .sshConnection(try sshConnectionPayload(from: rawPayload)))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
payload = .init(raw: rawPayload, decoded: nil)
|
||||||
|
}
|
||||||
|
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshSigPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHSigPayload {
|
||||||
|
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L79
|
||||||
|
let payloadReader = OpenSSHReader(data: data)
|
||||||
|
let namespace = try payloadReader.readNextChunkAsString()
|
||||||
|
_ = try payloadReader.readNextChunk() // reserved
|
||||||
|
let hashAlgorithm = try payloadReader.readNextChunkAsString()
|
||||||
|
let hash = try payloadReader.readNextChunk()
|
||||||
|
return .init(
|
||||||
|
namespace: namespace,
|
||||||
|
hashAlgorithm: hashAlgorithm,
|
||||||
|
hash: hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshConnectionPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHConnectionPayload {
|
||||||
|
let payloadReader = OpenSSHReader(data: data)
|
||||||
|
_ = try payloadReader.readNextChunk()
|
||||||
|
let magic = try payloadReader.readNextBytes(as: UInt8.self, convertEndianness: false)
|
||||||
|
guard magic == Constants.userAuthMagic else { throw .incorrectFormat }
|
||||||
|
let username = try payloadReader.readNextChunkAsString()
|
||||||
|
_ = try payloadReader.readNextChunkAsString() // "ssh-connection"
|
||||||
|
_ = try payloadReader.readNextChunkAsString() // "publickey-hostbound-v00@openssh.com"
|
||||||
|
let hasSignature = try payloadReader.readNextByteAsBool()
|
||||||
|
let algorithm = try payloadReader.readNextChunkAsString()
|
||||||
|
let publicKeyReader = try payloadReader.readNextChunkAsSubReader()
|
||||||
|
_ = try publicKeyReader.readNextChunk()
|
||||||
|
_ = try publicKeyReader.readNextChunk()
|
||||||
|
let publicKey = try publicKeyReader.readNextChunk()
|
||||||
|
let hostKeyReader = try payloadReader.readNextChunkAsSubReader()
|
||||||
|
_ = try hostKeyReader.readNextChunk()
|
||||||
|
let hostKey = try hostKeyReader.readNextChunk()
|
||||||
|
return .init(
|
||||||
|
username: username,
|
||||||
|
hasSignature: hasSignature,
|
||||||
|
publicKeyAlgorithm: algorithm,
|
||||||
|
publicKey: publicKey,
|
||||||
|
hostKey: hostKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func protocolExtension(from data: Data) throws(AgentParsingError) -> SSHAgent.ProtocolExtension {
|
||||||
|
do {
|
||||||
|
let reader = OpenSSHReader(data: data)
|
||||||
|
let nameRaw = try reader.readNextChunkAsString()
|
||||||
|
let nameSplit = nameRaw.split(separator: "@")
|
||||||
|
guard nameSplit.count == 2 else {
|
||||||
|
throw AgentParsingError.invalidData
|
||||||
|
}
|
||||||
|
let (name, domain) = (nameSplit[0], nameSplit[1])
|
||||||
|
switch domain {
|
||||||
|
case SSHAgent.ProtocolExtension.OpenSSHExtension.domain:
|
||||||
|
switch name {
|
||||||
|
case SSHAgent.ProtocolExtension.OpenSSHExtension.sessionBind(.empty).name:
|
||||||
|
do {
|
||||||
|
let hostkeyBlob = try reader.readNextChunkAsSubReader()
|
||||||
|
let hostKeyType = try hostkeyBlob.readNextChunkAsString()
|
||||||
|
let hostKeyData = try hostkeyBlob.readNextChunk()
|
||||||
|
let sessionID = try reader.readNextChunk()
|
||||||
|
let signatureBlob = try reader.readNextChunkAsSubReader()
|
||||||
|
_ = try signatureBlob.readNextChunk() // key type again
|
||||||
|
let signature = try signatureBlob.readNextChunk()
|
||||||
|
let forwarding = try reader.readNextByteAsBool()
|
||||||
|
switch hostKeyType {
|
||||||
|
case "ssh-ed25519":
|
||||||
|
let hostKey = try CryptoKit.Curve25519.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||||
|
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||||
|
throw AgentParsingError.incorrectSignature
|
||||||
|
}
|
||||||
|
case "ecdsa-sha2-nistp256":
|
||||||
|
let hostKey = try CryptoKit.P256.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||||
|
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
||||||
|
throw AgentParsingError.incorrectSignature
|
||||||
|
}
|
||||||
|
case "ecdsa-sha2-nistp384":
|
||||||
|
let hostKey = try CryptoKit.P384.Signing.PublicKey(rawRepresentation: hostKeyData)
|
||||||
|
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
|
||||||
|
throw AgentParsingError.incorrectSignature
|
||||||
|
}
|
||||||
|
case "ssh-mldsa-65":
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
||||||
|
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||||
|
throw AgentParsingError.incorrectSignature
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw AgentParsingError.unhandledRequest
|
||||||
|
}
|
||||||
|
case "ssh-mldsa-87":
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
|
||||||
|
guard hostKey.isValidSignature(signature, for: sessionID) else {
|
||||||
|
throw AgentParsingError.incorrectSignature
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw AgentParsingError.unhandledRequest
|
||||||
|
}
|
||||||
|
case "ssh-rsa":
|
||||||
|
throw AgentParsingError.unhandledRequest
|
||||||
|
default:
|
||||||
|
throw AgentParsingError.unhandledRequest
|
||||||
|
}
|
||||||
|
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
|
||||||
|
hostKey: hostKeyData,
|
||||||
|
sessionID: sessionID,
|
||||||
|
signature: signature,
|
||||||
|
forwarding: forwarding
|
||||||
|
)
|
||||||
|
return .openSSH(.sessionBind(context))
|
||||||
|
} catch {
|
||||||
|
return .openSSH(.sessionBind(.empty))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return .openSSH(.unknown(String(name)))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return .unknown(nameRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch let error as OpenSSHReaderError {
|
||||||
|
throw .openSSHReader(error)
|
||||||
|
} catch let error as AgentParsingError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw .unknownRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificatePublicKeyBlob(from hash: Data) -> Data? {
|
||||||
|
let reader = OpenSSHReader(data: hash)
|
||||||
|
do {
|
||||||
|
let certType = try reader.readNextChunkAsString()
|
||||||
|
guard let certType = OpenSSHCertificate.CertificateType(rawValue: certType) else { return nil }
|
||||||
|
_ = try reader.readNextChunk() // nonce
|
||||||
|
let curveIdentifier = try reader.readNextChunk()
|
||||||
|
let publicKey = try reader.readNextChunk()
|
||||||
|
let openSSHIdentifier = certType.keyIdentifier
|
||||||
|
return openSSHIdentifier.lengthAndData +
|
||||||
|
curveIdentifier.lengthAndData +
|
||||||
|
publicKey.lengthAndData
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension SSHAgentInputParser {
|
||||||
|
|
||||||
|
public enum AgentParsingError: Error, Codable {
|
||||||
|
case unknownRequest
|
||||||
|
case unhandledRequest
|
||||||
|
case invalidData
|
||||||
|
case incorrectSignature
|
||||||
|
case openSSHReader(OpenSSHReaderError)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+70
-6
@@ -19,7 +19,7 @@ extension SSHAgent {
|
|||||||
case lock
|
case lock
|
||||||
case unlock
|
case unlock
|
||||||
case addSmartcardKeyConstrained
|
case addSmartcardKeyConstrained
|
||||||
case protocolExtension
|
case protocolExtension(ProtocolExtension)
|
||||||
case unknown(UInt8)
|
case unknown(UInt8)
|
||||||
|
|
||||||
public var protocolID: UInt8 {
|
public var protocolID: UInt8 {
|
||||||
@@ -60,18 +60,82 @@ extension SSHAgent {
|
|||||||
|
|
||||||
public struct SignatureRequestContext: Sendable, Codable {
|
public struct SignatureRequestContext: Sendable, Codable {
|
||||||
public let keyBlob: Data
|
public let keyBlob: Data
|
||||||
public let dataToSign: Data
|
public let dataToSign: SignaturePayload
|
||||||
|
|
||||||
public init(keyBlob: Data, dataToSign: Data) {
|
public init(keyBlob: Data, dataToSign: SignaturePayload) {
|
||||||
self.keyBlob = keyBlob
|
self.keyBlob = keyBlob
|
||||||
self.dataToSign = dataToSign
|
self.dataToSign = dataToSign
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var empty: SignatureRequestContext {
|
public static var empty: SignatureRequestContext {
|
||||||
SignatureRequestContext(keyBlob: Data(), dataToSign: Data())
|
SignatureRequestContext(keyBlob: Data(), dataToSign: SignaturePayload(raw: Data(), decoded: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SignaturePayload: Sendable, Codable {
|
||||||
|
|
||||||
|
public let raw: Data
|
||||||
|
public let decoded: DecodedPayload?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
raw: Data,
|
||||||
|
decoded: DecodedPayload?
|
||||||
|
) {
|
||||||
|
self.raw = raw
|
||||||
|
self.decoded = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DecodedPayload: Sendable, Codable {
|
||||||
|
case sshConnection(SSHConnectionPayload)
|
||||||
|
case sshSig(SSHSigPayload)
|
||||||
|
|
||||||
|
public struct SSHConnectionPayload: Sendable, Codable {
|
||||||
|
|
||||||
|
public let username: String
|
||||||
|
public let hasSignature: Bool
|
||||||
|
public let publicKeyAlgorithm: String
|
||||||
|
public let publicKey: Data
|
||||||
|
public let hostKey: Data
|
||||||
|
|
||||||
|
public init(
|
||||||
|
username: String,
|
||||||
|
hasSignature: Bool,
|
||||||
|
publicKeyAlgorithm: String,
|
||||||
|
publicKey: Data,
|
||||||
|
hostKey: Data
|
||||||
|
) {
|
||||||
|
self.username = username
|
||||||
|
self.hasSignature = hasSignature
|
||||||
|
self.publicKeyAlgorithm = publicKeyAlgorithm
|
||||||
|
self.publicKey = publicKey
|
||||||
|
self.hostKey = hostKey
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SSHSigPayload: Sendable, Codable {
|
||||||
|
|
||||||
|
public let namespace: String
|
||||||
|
public let hashAlgorithm: String
|
||||||
|
public let hash: Data
|
||||||
|
|
||||||
|
public init(
|
||||||
|
namespace: String,
|
||||||
|
hashAlgorithm: String,
|
||||||
|
hash: Data,
|
||||||
|
) {
|
||||||
|
self.namespace = namespace
|
||||||
|
self.hashAlgorithm = hashAlgorithm
|
||||||
|
self.hash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
@@ -88,8 +152,8 @@ extension SSHAgent {
|
|||||||
switch self {
|
switch self {
|
||||||
case .agentFailure: "SSH_AGENT_FAILURE"
|
case .agentFailure: "SSH_AGENT_FAILURE"
|
||||||
case .agentSuccess: "SSH_AGENT_SUCCESS"
|
case .agentSuccess: "SSH_AGENT_SUCCESS"
|
||||||
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
|
case .agentIdentitiesAnswer: "SSH2_AGENT_IDENTITIES_ANSWER"
|
||||||
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
|
case .agentSignResponse: "SSH2_AGENT_SIGN_RESPONSE"
|
||||||
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
|
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
|
||||||
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
|
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Extensions, as defined in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.agent
|
||||||
|
|
||||||
|
extension SSHAgent {
|
||||||
|
|
||||||
|
public enum ProtocolExtension: CustomDebugStringConvertible, Codable, Sendable {
|
||||||
|
case openSSH(OpenSSHExtension)
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
public var debugDescription: String {
|
||||||
|
switch self {
|
||||||
|
case let .openSSH(protocolExtension):
|
||||||
|
protocolExtension.debugDescription
|
||||||
|
case .unknown(let string):
|
||||||
|
"Unknown (\(string))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var empty: ProtocolExtension {
|
||||||
|
.unknown("empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ProtocolExtensionParsingError: Error {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SSHAgent.ProtocolExtension {
|
||||||
|
|
||||||
|
public enum OpenSSHExtension: CustomDebugStringConvertible, Codable, Sendable {
|
||||||
|
case sessionBind(SessionBindContext)
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
public static var domain: String {
|
||||||
|
"openssh.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
public var name: String {
|
||||||
|
switch self {
|
||||||
|
case .sessionBind:
|
||||||
|
"session-bind"
|
||||||
|
case .unknown(let name):
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var debugDescription: String {
|
||||||
|
"\(name)@\(OpenSSHExtension.domain)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SSHAgent.ProtocolExtension.OpenSSHExtension {
|
||||||
|
|
||||||
|
public struct SessionBindContext: Codable, Sendable {
|
||||||
|
|
||||||
|
public let hostKey: Data
|
||||||
|
public let sessionID: Data
|
||||||
|
public let signature: Data
|
||||||
|
public let forwarding: Bool
|
||||||
|
|
||||||
|
public init(hostKey: Data, sessionID: Data, signature: Data, forwarding: Bool) {
|
||||||
|
self.hostKey = hostKey
|
||||||
|
self.sessionID = sessionID
|
||||||
|
self.signature = signature
|
||||||
|
self.forwarding = forwarding
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let empty = SessionBindContext(hostKey: Data(), sessionID: Data(), signature: Data(), forwarding: false)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,36 +2,50 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import OSLog
|
import OSLog
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import CertificateKit
|
||||||
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 authenticationHandler: AuthenticationHandler
|
||||||
|
private let certificateStore: CertificateStore
|
||||||
private let witness: SigningWitness?
|
private let witness: SigningWitness?
|
||||||
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
||||||
private let signatureWriter = OpenSSHSignatureWriter()
|
private let signatureWriter = OpenSSHSignatureWriter()
|
||||||
private let certificateHandler = OpenSSHCertificateHandler()
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
||||||
|
|
||||||
|
@MainActor private var sessionID: SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext?
|
||||||
|
|
||||||
/// Initializes an agent with a store list and a witness.
|
/// Initializes an agent with a store list and a witness.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - storeList: The `SecretStoreList` to make available.
|
/// - storeList: The `SecretStoreList` to make available.
|
||||||
/// - witness: A witness to notify of requests.
|
/// - witness: A witness to notify of requests.
|
||||||
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
|
public init(
|
||||||
|
storeList: SecretStoreList,
|
||||||
|
certificateStore: CertificateStore,
|
||||||
|
authenticationHandler: AuthenticationHandler,
|
||||||
|
witness: SigningWitness? = nil
|
||||||
|
) {
|
||||||
logger.debug("Agent is running")
|
logger.debug("Agent is running")
|
||||||
self.storeList = storeList
|
self.storeList = storeList
|
||||||
|
self.certificateStore = certificateStore
|
||||||
|
self.authenticationHandler = authenticationHandler
|
||||||
self.witness = witness
|
self.witness = witness
|
||||||
Task { @MainActor in
|
|
||||||
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
|
public func handle(
|
||||||
|
request: SSHAgent.Request,
|
||||||
|
provenance: SigningRequestProvenance,
|
||||||
|
hosts: [Data: String]?
|
||||||
|
) async -> Data {
|
||||||
|
logger.debug("Agent received request of type \(request.debugDescription)")
|
||||||
// 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()
|
||||||
@@ -42,9 +56,55 @@ extension Agent {
|
|||||||
response.append(await identities())
|
response.append(await identities())
|
||||||
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
|
||||||
case .signRequest(let context):
|
case .signRequest(let context):
|
||||||
|
let target: SigningRequestTarget?
|
||||||
|
switch context.dataToSign.decoded {
|
||||||
|
case .sshConnection(let payload):
|
||||||
|
target = .connection(
|
||||||
|
.init(
|
||||||
|
username: payload.username,
|
||||||
|
hasSignature: payload.hasSignature,
|
||||||
|
publicKeyAlgorithm: payload.publicKeyAlgorithm,
|
||||||
|
publicKey: payload.publicKey,
|
||||||
|
hostKey: payload.hostKey,
|
||||||
|
host: hosts?[payload.hostKey]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if let boundSession = await sessionID {
|
||||||
|
guard payload.hostKey == boundSession.hostKey else {
|
||||||
|
logger.error("Agent received bind request, but host key does not match signature request host key.")
|
||||||
|
throw BindingFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .sshSig(let payload):
|
||||||
|
target = .signature(
|
||||||
|
.init(
|
||||||
|
namespace: payload.namespace,
|
||||||
|
hashAlgorithm: payload.hashAlgorithm,
|
||||||
|
hash: payload.hash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
target = nil
|
||||||
|
}
|
||||||
|
_ = target
|
||||||
response.append(SSHAgent.Response.agentSignResponse.data)
|
response.append(SSHAgent.Response.agentSignResponse.data)
|
||||||
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
|
response.append(try await sign(data: context.dataToSign.raw, keyBlob: context.keyBlob, provenance: provenance, target: target))
|
||||||
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
|
||||||
|
case .protocolExtension(.openSSH(.sessionBind(let bind))):
|
||||||
|
// This is disabled until forward enforcement is handled.
|
||||||
|
_ = bind
|
||||||
|
response = try await MainActor.run {
|
||||||
|
logger.debug("Agent received bind request but not currently supported.")
|
||||||
|
throw UnhandledRequestError()
|
||||||
|
// guard sessionID == nil else {
|
||||||
|
// logger.error("Agent received bind request, but already bound.")
|
||||||
|
// throw BindingFailure()
|
||||||
|
// }
|
||||||
|
// logger.debug("Agent bound")
|
||||||
|
// sessionID = bind
|
||||||
|
// return SSHAgent.Response.agentSuccess.data
|
||||||
|
}
|
||||||
|
logger.debug("Agent returned \(SSHAgent.Response.agentSuccess.debugDescription)")
|
||||||
case .unknown(let value):
|
case .unknown(let value):
|
||||||
logger.error("Agent received unknown request of type \(value).")
|
logger.error("Agent received unknown request of type \(value).")
|
||||||
throw UnhandledRequestError()
|
throw UnhandledRequestError()
|
||||||
@@ -67,7 +127,6 @@ extension Agent {
|
|||||||
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
||||||
func identities() async -> Data {
|
func identities() async -> Data {
|
||||||
let secrets = await storeList.allSecrets
|
let secrets = await storeList.allSecrets
|
||||||
await certificateHandler.reloadCertificates(for: secrets)
|
|
||||||
var count = 0
|
var count = 0
|
||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
|
|
||||||
@@ -76,10 +135,9 @@ extension Agent {
|
|||||||
keyData.append(keyBlob.lengthAndData)
|
keyData.append(keyBlob.lengthAndData)
|
||||||
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
|
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
|
||||||
count += 1
|
count += 1
|
||||||
|
for certificate in await certificateStore.certificates(for: secret) {
|
||||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
keyData.append(certificate.data.lengthAndData)
|
||||||
keyData.append(certificateData.lengthAndData)
|
keyData.append(certificate.name.lengthAndData)
|
||||||
keyData.append(name.lengthAndData)
|
|
||||||
count += 1
|
count += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,22 +152,28 @@ extension Agent {
|
|||||||
/// - data: The data to sign.
|
/// - data: The data to sign.
|
||||||
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
|
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
|
||||||
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
||||||
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
|
||||||
guard let (secret, store) = await secret(matching: keyBlob) else {
|
guard let (secret, store) = await secret(matching: keyBlob) else {
|
||||||
let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined()
|
let keyBlobHex = keyBlob.formatted(.hex())
|
||||||
logger.debug("Agent did not have a key matching \(keyBlobHex)")
|
logger.debug("Agent did not have a key matching \(keyBlobHex)")
|
||||||
throw NoMatchingKeyError()
|
throw NoMatchingKeyError()
|
||||||
}
|
}
|
||||||
|
logger.debug("Agent offering witness chance to object")
|
||||||
|
do {
|
||||||
|
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance, target: target)
|
||||||
|
} catch {
|
||||||
|
logger.debug("Witness objected")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
logger.debug("Witness did not object")
|
||||||
|
|
||||||
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
let request = SignatureRequest(secret: secret, provenance: provenance, target: target)
|
||||||
|
let newContext = AuthenticationContext(secret: secret, requests: [request])
|
||||||
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
|
let context = try await authenticationHandler.authenticatedContext(for: request, context: newContext)
|
||||||
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
let result = try await store.sign(data: data, with: secret, for: provenance, target: target, context: context?.laContext)
|
||||||
|
let signedData = signatureWriter.data(secret: secret, signature: result)
|
||||||
try await witness?.witness(accessTo: secret, from: store, by: provenance)
|
try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: secret.authenticationRequirement.required)
|
||||||
|
|
||||||
logger.debug("Agent signed request")
|
logger.debug("Agent signed request")
|
||||||
|
|
||||||
return signedData
|
return signedData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +208,7 @@ extension Agent {
|
|||||||
|
|
||||||
struct NoMatchingKeyError: Error {}
|
struct NoMatchingKeyError: Error {}
|
||||||
struct UnhandledRequestError: Error {}
|
struct UnhandledRequestError: Error {}
|
||||||
|
struct BindingFailure: Error {}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
@unsafe @preconcurrency import LocalAuthentication
|
||||||
|
import SecretKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// A context describing a persisted authentication.
|
||||||
|
public final class AuthenticationContext: AuthenticationContextProtocol {
|
||||||
|
|
||||||
|
/// The Secret to persist authentication for.
|
||||||
|
public let secret: AnySecret
|
||||||
|
/// The LAContext used to authorize the persistent context.
|
||||||
|
public let laContext: LAContext?
|
||||||
|
|
||||||
|
enum Validity {
|
||||||
|
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||||
|
case time(monotonicExpiration: UInt64)
|
||||||
|
case requestIDs(Set<UUID>)
|
||||||
|
case exclusive(UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
let validity: Validity
|
||||||
|
|
||||||
|
/// Initializes a context.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - secret: The Secret to persist authentication for.
|
||||||
|
/// - duration: The duration of the authorization context, in seconds.
|
||||||
|
init<SecretType: Secret>(secret: SecretType, duration: TimeInterval) {
|
||||||
|
self.secret = AnySecret(secret)
|
||||||
|
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||||
|
self.validity = .time(monotonicExpiration: clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds))
|
||||||
|
let newContext = LAContext()
|
||||||
|
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||||
|
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
|
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.unitsStyle = .spellOut
|
||||||
|
formatter.allowedUnits = [.hour, .minute, .day]
|
||||||
|
let durationString = formatter.string(from: duration)!
|
||||||
|
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
||||||
|
laContext = newContext
|
||||||
|
}
|
||||||
|
|
||||||
|
init<SecretType: Secret>(secret: SecretType, requests: Set<SignatureRequest>) {
|
||||||
|
self.secret = AnySecret(secret)
|
||||||
|
if requests.count == 1 {
|
||||||
|
self.validity = .exclusive(requests.first!.id)
|
||||||
|
} else {
|
||||||
|
self.validity = .requestIDs(Set(requests.map(\.id)))
|
||||||
|
}
|
||||||
|
if secret.authenticationRequirement.required {
|
||||||
|
let newContext = LAContext()
|
||||||
|
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||||
|
let appName = requests.first!.provenance.origin.displayName
|
||||||
|
if requests.count > 1 {
|
||||||
|
newContext.localizedReason = String(localized: .authContextRequestMultiple(appName: appName, secretName: secret.name))
|
||||||
|
} else {
|
||||||
|
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: appName, secretName: secret.name))
|
||||||
|
}
|
||||||
|
laContext = newContext
|
||||||
|
} else {
|
||||||
|
laContext = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A boolean describing whether or not the context is still valid.
|
||||||
|
public func valid(for request: SignatureRequest) -> Bool {
|
||||||
|
switch validity {
|
||||||
|
case .time(let monotonicExpiration):
|
||||||
|
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||||
|
case .requestIDs(let set):
|
||||||
|
set.contains(request.id)
|
||||||
|
case .exclusive(let id):
|
||||||
|
id == request.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func evaluate() async throws -> Bool {
|
||||||
|
guard let laContext else { return false }
|
||||||
|
return try await laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: laContext.localizedReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() async {
|
||||||
|
laContext?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor public protocol AuthenticationHandlerProtocol: Observable {
|
||||||
|
var batchableRequests: [[SignatureRequest]] { get }
|
||||||
|
func setPendingRequestHandler(_ handler: @escaping () async throws -> Void)
|
||||||
|
func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)?
|
||||||
|
func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws
|
||||||
|
func requestAuthentication(for requests: Set<SignatureRequest>) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable @MainActor public class AuthenticationHandler: AuthenticationHandlerProtocol {
|
||||||
|
|
||||||
|
private var authenticatedContexts: [AnySecret: AuthenticationContext] = [:]
|
||||||
|
private var waitingRequests: Set<SignatureRequest> = []
|
||||||
|
private var activeTask: Task<Bool, any Error>?
|
||||||
|
private var activeContext: (any AuthenticationContextProtocol)?
|
||||||
|
|
||||||
|
private var lastBatchAuthPresentation: Set<SignatureRequest>?
|
||||||
|
private var presentPendingAuth: (() async throws -> Void)?
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AuthenticationHandler")
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setPendingRequestHandler(_ handler: @escaping () async throws -> Void) {
|
||||||
|
self.presentPendingAuth = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
public func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)? {
|
||||||
|
if request.secret.authenticationRequirement.required {
|
||||||
|
// Slow path, will block caller until authenticated (either directly or via a pending requests view).
|
||||||
|
return try await waitForAuthentication(for: request, context: context)
|
||||||
|
} else {
|
||||||
|
// Fast path, no blocking/enqueing required
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForAuthentication(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> any AuthenticationContextProtocol {
|
||||||
|
logger.log("Entering waitForAuthentication for \(request.id)")
|
||||||
|
if let existing = existingAuthenticationContext(for: request) {
|
||||||
|
logger.log("Short circuiting wait, existing valid context already exists.")
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
waitingRequests.insert(request)
|
||||||
|
logger.log("Waiting for authentication for \(request.id)")
|
||||||
|
defer {
|
||||||
|
logger.log("Removed hold for \(request.id)")
|
||||||
|
waitingRequests.remove(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if waitingRequests.count > 1 {
|
||||||
|
return try await waitUntilRequestActedOn(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold onto the task and context so we can cancel them when prsenting pending.
|
||||||
|
activeContext = context
|
||||||
|
let currentTask = Task<Bool, any Error> {
|
||||||
|
logger.log("Beginning individual auth prompt")
|
||||||
|
let result = (try? await context.evaluate()) ?? false
|
||||||
|
logger.log("Ended individual auth prompt")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
activeTask = currentTask
|
||||||
|
let result = try? await activeTask?.value
|
||||||
|
if result == false && activeTask?.isCancelled == false {
|
||||||
|
waitingRequests.remove(request)
|
||||||
|
throw CancellationError()
|
||||||
|
} else if currentTask.isCancelled {
|
||||||
|
return try await waitUntilRequestActedOn(request)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilRequestActedOn(_ request: SignatureRequest) async throws -> any AuthenticationContextProtocol {
|
||||||
|
logger.log("Auth prompt was cancelled, waiting for explicit auth")
|
||||||
|
// At this point, we essentially just block the task until either the request has been authenticated "externally" via the pending view.
|
||||||
|
while waitingRequests.contains(request) {
|
||||||
|
if waitingRequests != lastBatchAuthPresentation {
|
||||||
|
// If we're about to present a batch, we cancel the individual auth prompt, and show the batch one.
|
||||||
|
logger.log("Multiple pending requests exist, cancelling existing auth prompt")
|
||||||
|
activeTask?.cancel()
|
||||||
|
lastBatchAuthPresentation = waitingRequests
|
||||||
|
logger.log("Requesting pending requests presentation")
|
||||||
|
try await presentPendingAuth?()
|
||||||
|
await activeContext?.cancel()
|
||||||
|
logger.log("Requested pending requests presentation")
|
||||||
|
}
|
||||||
|
if let preauthenticated = existingAuthenticationContext(for: request) {
|
||||||
|
logger.log("Explicit auth context found")
|
||||||
|
return preauthenticated
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
throw CancellationError()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public var batchableRequests: [[SignatureRequest]] {
|
||||||
|
waitingRequests.reduce(into: [:]) { partialResult, next in
|
||||||
|
partialResult[next.batchID, default: []].append(next)
|
||||||
|
}
|
||||||
|
.values
|
||||||
|
.map { $0.sorted() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func existingAuthenticationContext(for request: SignatureRequest) -> (any AuthenticationContextProtocol)? {
|
||||||
|
guard let authenticated = authenticatedContexts[request.secret], authenticated.valid(for: request) else { return nil }
|
||||||
|
return authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
public func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
||||||
|
let context = AuthenticationContext(secret: secret, duration: duration)
|
||||||
|
let success = try await context.evaluate()
|
||||||
|
guard success else { return }
|
||||||
|
authenticatedContexts[AnySecret(secret)] = context
|
||||||
|
}
|
||||||
|
|
||||||
|
public func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
|
||||||
|
activeTask?.cancel()
|
||||||
|
guard let first = requests.first else { return }
|
||||||
|
let context = AuthenticationContext(secret: first.secret, requests: requests)
|
||||||
|
let success = (try? await context.evaluate()) ?? false
|
||||||
|
guard success else {
|
||||||
|
waitingRequests.subtract(requests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Even single-use ones get stuffed into authenticatedContexts, so that it can unblock the response path.
|
||||||
|
authenticatedContexts[AnySecret(first.secret)] = context
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
/// Manages storage and lookup for OpenSSH certificates.
|
|
||||||
public actor OpenSSHCertificateHandler: Sendable {
|
|
||||||
|
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
|
||||||
private let writer = OpenSSHPublicKeyWriter()
|
|
||||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
|
||||||
|
|
||||||
/// Initializes an OpenSSHCertificateHandler.
|
|
||||||
public init() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reloads any certificates in the PublicKeys folder.
|
|
||||||
/// - Parameter secrets: the secrets to look up corresponding certificates for.
|
|
||||||
public func reloadCertificates(for secrets: [AnySecret]) {
|
|
||||||
guard publicKeyFileStoreController.hasAnyCertificates else {
|
|
||||||
logger.log("No certificates, short circuiting")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
|
|
||||||
partialResult[next] = try? loadKeyblobAndName(for: next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
|
||||||
/// - Parameter secret: The secret to search for a certificate with
|
|
||||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
|
||||||
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
|
|
||||||
keyBlobsAndNames[AnySecret(secret)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
|
||||||
/// - Parameter secret: The secret to search for a certificate with
|
|
||||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
|
||||||
private func loadKeyblobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
|
|
||||||
let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret)
|
|
||||||
guard FileManager.default.fileExists(atPath: certificatePath) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Found certificate for \(secret.name)")
|
|
||||||
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
|
|
||||||
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
|
|
||||||
|
|
||||||
guard certElements.count >= 2 else {
|
|
||||||
logger.warning("Certificate found for \(secret.name) but failed to load")
|
|
||||||
throw OpenSSHCertificateError.parsingFailed
|
|
||||||
}
|
|
||||||
guard let certDecoded = Data(base64Encoded: certElements[1] as String) else {
|
|
||||||
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
|
|
||||||
throw OpenSSHCertificateError.parsingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
if certElements.count >= 3 {
|
|
||||||
let certName = Data(certElements[2].utf8)
|
|
||||||
return (certDecoded, certName)
|
|
||||||
}
|
|
||||||
let certName = Data(secret.name.utf8)
|
|
||||||
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
|
||||||
return (certDecoded, certName)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension OpenSSHCertificateHandler {
|
|
||||||
|
|
||||||
enum OpenSSHCertificateError: LocalizedError {
|
|
||||||
case unsupportedType
|
|
||||||
case parsingFailed
|
|
||||||
case doesNotExist
|
|
||||||
|
|
||||||
public var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .unsupportedType:
|
|
||||||
return "The key type was unsupported"
|
|
||||||
case .parsingFailed:
|
|
||||||
return "Failed to properly parse the SSH certificate"
|
|
||||||
case .doesNotExist:
|
|
||||||
return "Certificate does not exist"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Reads OpenSSH protocol data.
|
|
||||||
final class OpenSSHReader {
|
|
||||||
|
|
||||||
var remaining: Data
|
|
||||||
|
|
||||||
/// Initialize the reader with an OpenSSH data payload.
|
|
||||||
/// - Parameter data: The data to read.
|
|
||||||
init(data: Data) {
|
|
||||||
remaining = Data(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the next chunk of data from the playload.
|
|
||||||
/// - Returns: The next chunk of data.
|
|
||||||
func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
|
|
||||||
let littleEndianLength = try readNextBytes(as: UInt32.self)
|
|
||||||
let length = convertEndianness ? Int(littleEndianLength.bigEndian) : Int(littleEndianLength)
|
|
||||||
guard remaining.count >= length else { throw .beyondBounds }
|
|
||||||
let dataRange = 0..<length
|
|
||||||
let ret = Data(remaining[dataRange])
|
|
||||||
remaining.removeSubrange(dataRange)
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func readNextBytes<T>(as: T.Type) throws(OpenSSHReaderError) -> T {
|
|
||||||
let size = MemoryLayout<T>.size
|
|
||||||
guard remaining.count >= size else { throw .beyondBounds }
|
|
||||||
let lengthRange = 0..<size
|
|
||||||
let lengthChunk = remaining[lengthRange]
|
|
||||||
remaining.removeSubrange(lengthRange)
|
|
||||||
return unsafe lengthChunk.bytes.unsafeLoad(as: T.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
|
|
||||||
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
|
|
||||||
OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum OpenSSHReaderError: Error, Codable {
|
|
||||||
case beyondBounds
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CertificateKit
|
||||||
|
import Common
|
||||||
|
|
||||||
|
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
|
||||||
|
public final class PublicKeyFileStoreController: Sendable {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
||||||
|
private let publicKeysURL: URL
|
||||||
|
private let certificatesURL: URL
|
||||||
|
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
|
|
||||||
|
/// Initializes a PublicKeyFileStoreController.
|
||||||
|
public init(publicKeysURL: URL, certificatesURL: URL) {
|
||||||
|
self.publicKeysURL = publicKeysURL
|
||||||
|
self.certificatesURL = certificatesURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes out the keys specified to disk.
|
||||||
|
/// - Parameter secrets: The Secrets to generate keys for.
|
||||||
|
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
||||||
|
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
||||||
|
logger.log("Writing public keys to disk")
|
||||||
|
if clear {
|
||||||
|
let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: publicKeysURL) })
|
||||||
|
.union(Set(secrets.map { legacySSHCertificatePath(for: $0) }))
|
||||||
|
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: publicKeysURL.path())) ?? []
|
||||||
|
let fullPathContents = contentsOfDirectory.map { publicKeysURL.appending(path: $0).path() }
|
||||||
|
|
||||||
|
let untracked = Set(fullPathContents)
|
||||||
|
.subtracting(validPaths)
|
||||||
|
for path in untracked {
|
||||||
|
// string instead of fileURLWithPath since we're already using fileURL format.
|
||||||
|
try? FileManager.default.removeItem(at: URL(string: path)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? FileManager.default.createDirectory(at: publicKeysURL, withIntermediateDirectories: false, attributes: nil)
|
||||||
|
for secret in secrets {
|
||||||
|
let path = URL.publicKeyPath(for: secret, in: publicKeysURL)
|
||||||
|
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
||||||
|
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
||||||
|
}
|
||||||
|
logger.log("Finished writing public keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes out the certificates specified to disk.
|
||||||
|
/// - Parameter certificates: The Secrets to generate keys for.
|
||||||
|
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
||||||
|
public func generateCertificates(for certificates: [Certificate], clear: Bool = false) throws {
|
||||||
|
logger.log("Writing certificates to disk")
|
||||||
|
if clear {
|
||||||
|
let validPaths = Set(certificates.map { URL.certificatePath(for: $0.id, in: certificatesURL) })
|
||||||
|
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: certificatesURL.path())) ?? []
|
||||||
|
let fullPathContents = contentsOfDirectory.map { certificatesURL.appending(path: $0).path() }
|
||||||
|
|
||||||
|
let untracked = Set(fullPathContents)
|
||||||
|
.subtracting(validPaths)
|
||||||
|
for path in untracked {
|
||||||
|
// string instead of fileURLWithPath since we're already using fileURL format.
|
||||||
|
try? FileManager.default.removeItem(at: URL(string: path)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? FileManager.default.createDirectory(at: certificatesURL, withIntermediateDirectories: false, attributes: nil)
|
||||||
|
for certificate in certificates {
|
||||||
|
let path = URL.certificatePath(for: certificate.id, in: certificatesURL)
|
||||||
|
FileManager.default.createFile(atPath: path, contents: certificate.rawData, attributes: nil)
|
||||||
|
}
|
||||||
|
logger.log("Finished writing certificates")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path for a Secret's SSH Certificate public key.
|
||||||
|
/// - Parameter secret: The Secret to return the path for.
|
||||||
|
/// - Returns: The path to the SSH Certificate public key.
|
||||||
|
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
|
||||||
|
private func legacySSHCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||||
|
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||||
|
return publicKeysURL.appending(component: "\(minimalHex).pub").path()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
public protocol SSHAgentInputParserProtocol {
|
|
||||||
|
|
||||||
func parse(data: Data) async throws -> SSHAgent.Request
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
|
|
||||||
logger.debug("Parsing new data")
|
|
||||||
guard data.count > 4 else {
|
|
||||||
throw .invalidData
|
|
||||||
}
|
|
||||||
let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4
|
|
||||||
let rawRequestInt = data[4]
|
|
||||||
let remainingDataRange = 5..<min(Int(specifiedLength), data.count)
|
|
||||||
lazy var body: Data = { Data(data[remainingDataRange]) }()
|
|
||||||
switch rawRequestInt {
|
|
||||||
case SSHAgent.Request.requestIdentities.protocolID:
|
|
||||||
return .requestIdentities
|
|
||||||
case SSHAgent.Request.signRequest(.empty).protocolID:
|
|
||||||
do {
|
|
||||||
return .signRequest(try signatureRequestContext(from: body))
|
|
||||||
} catch {
|
|
||||||
throw .openSSHReader(error)
|
|
||||||
}
|
|
||||||
case SSHAgent.Request.addIdentity.protocolID:
|
|
||||||
return .addIdentity
|
|
||||||
case SSHAgent.Request.removeIdentity.protocolID:
|
|
||||||
return .removeIdentity
|
|
||||||
case SSHAgent.Request.removeAllIdentities.protocolID:
|
|
||||||
return .removeAllIdentities
|
|
||||||
case SSHAgent.Request.addIDConstrained.protocolID:
|
|
||||||
return .addIDConstrained
|
|
||||||
case SSHAgent.Request.addSmartcardKey.protocolID:
|
|
||||||
return .addSmartcardKey
|
|
||||||
case SSHAgent.Request.removeSmartcardKey.protocolID:
|
|
||||||
return .removeSmartcardKey
|
|
||||||
case SSHAgent.Request.lock.protocolID:
|
|
||||||
return .lock
|
|
||||||
case SSHAgent.Request.unlock.protocolID:
|
|
||||||
return .unlock
|
|
||||||
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
|
|
||||||
return .addSmartcardKeyConstrained
|
|
||||||
case SSHAgent.Request.protocolExtension.protocolID:
|
|
||||||
return .protocolExtension
|
|
||||||
default:
|
|
||||||
return .unknown(rawRequestInt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SSHAgentInputParser {
|
|
||||||
|
|
||||||
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
|
|
||||||
let reader = OpenSSHReader(data: data)
|
|
||||||
let rawKeyBlob = try reader.readNextChunk()
|
|
||||||
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
|
|
||||||
let dataToSign = try reader.readNextChunk()
|
|
||||||
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign)
|
|
||||||
}
|
|
||||||
|
|
||||||
func certificatePublicKeyBlob(from hash: Data) -> Data? {
|
|
||||||
let reader = OpenSSHReader(data: hash)
|
|
||||||
do {
|
|
||||||
let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self)
|
|
||||||
switch certType {
|
|
||||||
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
||||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
|
||||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
|
|
||||||
_ = try reader.readNextChunk() // nonce
|
|
||||||
let curveIdentifier = try reader.readNextChunk()
|
|
||||||
let publicKey = try reader.readNextChunk()
|
|
||||||
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
|
||||||
return openSSHIdentifier.lengthAndData +
|
|
||||||
curveIdentifier.lengthAndData +
|
|
||||||
publicKey.lengthAndData
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension SSHAgentInputParser {
|
|
||||||
|
|
||||||
public enum AgentParsingError: Error, Codable {
|
|
||||||
case unknownRequest
|
|
||||||
case unhandledRequest
|
|
||||||
case invalidData
|
|
||||||
case openSSHReader(OpenSSHReaderError)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,7 @@ extension SigningRequestTracer {
|
|||||||
/// - 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 = unsafe 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 = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
||||||
unsafe String(cString: pointer)
|
unsafe String(cString: pointer)
|
||||||
@@ -44,6 +44,7 @@ extension SigningRequestTracer {
|
|||||||
|
|
||||||
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
||||||
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
||||||
|
|
||||||
let path = unsafe 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]
|
||||||
@@ -80,10 +81,6 @@ extension SigningRequestTracer {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// from libproc.h
|
|
||||||
@_silgen_name("proc_pidpath")
|
|
||||||
@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32
|
|
||||||
|
|
||||||
//// from SecTask.h
|
//// from SecTask.h
|
||||||
@_silgen_name("SecCodeCreateWithPID")
|
@_silgen_name("SecCodeCreateWithPID")
|
||||||
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus
|
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
/// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access.
|
/// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access.
|
||||||
public protocol SigningWitness: Sendable {
|
public protocol SigningWitness: Sendable {
|
||||||
@@ -10,13 +11,13 @@ public protocol SigningWitness: Sendable {
|
|||||||
/// - store: The `Store` being asked to sign the request..
|
/// - store: The `Store` being asked to sign the request..
|
||||||
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
|
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
|
||||||
/// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail.
|
/// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail.
|
||||||
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws
|
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws
|
||||||
|
|
||||||
/// Notifies the callee that a signing operation has been performed for a given secret.
|
/// Notifies the callee that a signing operation has been performed for a given secret.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - secret: The `Secret` that will was used to sign the request.
|
/// - secret: The `Secret` that will was used to sign the request.
|
||||||
/// - store: The `Store` that signed the request..
|
/// - store: The `Store` that signed the request..
|
||||||
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
|
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
|
||||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws
|
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,16 +36,21 @@ public struct SocketController {
|
|||||||
logger.debug("Socket controller path is clear")
|
logger.debug("Socket controller path is clear")
|
||||||
port = SocketPort(path: path)
|
port = SocketPort(path: path)
|
||||||
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
||||||
Task { [fileHandle, sessionsContinuation, logger] in
|
Task { @MainActor [fileHandle, sessionsContinuation, logger] in
|
||||||
for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) {
|
// Create the sequence before triggering the notification to
|
||||||
|
// ensure it will not be missed.
|
||||||
|
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted, object: fileHandle)
|
||||||
|
|
||||||
|
fileHandle.acceptConnectionInBackgroundAndNotify()
|
||||||
|
|
||||||
|
for await notification in connectionAcceptedNotifications {
|
||||||
logger.debug("Socket controller accepted connection")
|
logger.debug("Socket controller accepted connection")
|
||||||
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
|
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
|
||||||
let session = Session(fileHandle: new)
|
let session = Session(fileHandle: new)
|
||||||
sessionsContinuation.yield(session)
|
sessionsContinuation.yield(session)
|
||||||
await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor()
|
fileHandle.acceptConnectionInBackgroundAndNotify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
|
|
||||||
logger.debug("Socket listening at \(path)")
|
logger.debug("Socket listening at \(path)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,29 +82,32 @@ extension SocketController {
|
|||||||
self.fileHandle = fileHandle
|
self.fileHandle = fileHandle
|
||||||
provenance = SigningRequestTracer().provenance(from: fileHandle)
|
provenance = SigningRequestTracer().provenance(from: fileHandle)
|
||||||
(messages, messagesContinuation) = AsyncStream.makeStream()
|
(messages, messagesContinuation) = AsyncStream.makeStream()
|
||||||
Task { [messagesContinuation, logger] in
|
Task { @MainActor [messagesContinuation, logger] in
|
||||||
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) {
|
// 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
|
let data = fileHandle.availableData
|
||||||
guard !data.isEmpty else {
|
guard !data.isEmpty else {
|
||||||
logger.debug("Socket controller received empty data, ending continuation.")
|
logger.debug("Socket controller received empty data, ending continuation.")
|
||||||
messagesContinuation.finish()
|
messagesContinuation.finish()
|
||||||
try fileHandle.close()
|
try? fileHandle.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
messagesContinuation.yield(data)
|
messagesContinuation.yield(data)
|
||||||
logger.debug("Socket controller yielded data.")
|
logger.debug("Socket controller yielded data.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Task {
|
|
||||||
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes new data to the socket.
|
/// Writes new data to the socket.
|
||||||
/// - Parameter data: The data to write.
|
/// - Parameter data: The data to write.
|
||||||
public func write(_ data: Data) async throws {
|
@MainActor public func write(_ data: Data) throws {
|
||||||
try fileHandle.write(contentsOf: data)
|
try fileHandle.write(contentsOf: data)
|
||||||
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
fileHandle.waitForDataInBackgroundAndNotify()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Closes the socket and cleans up resources.
|
/// Closes the socket and cleans up resources.
|
||||||
@@ -113,29 +121,13 @@ extension SocketController {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension FileHandle {
|
|
||||||
|
|
||||||
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
|
|
||||||
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
|
|
||||||
waitForDataInBackgroundAndNotify()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
|
|
||||||
/// - Parameter modes: the runloop modes to use.
|
|
||||||
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
|
|
||||||
acceptConnectionInBackgroundAndNotify(forModes: modes)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension SocketPort {
|
private extension SocketPort {
|
||||||
|
|
||||||
convenience init(path: String) {
|
convenience init(path: String) {
|
||||||
var addr = sockaddr_un()
|
var addr = sockaddr_un()
|
||||||
|
|
||||||
let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
let length = withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
||||||
unsafe path.withCString { cstring in
|
path.withCString { cstring in
|
||||||
let len = unsafe strlen(cstring)
|
let len = unsafe strlen(cstring)
|
||||||
unsafe strncpy(pointer, cstring, len)
|
unsafe strncpy(pointer, cstring, len)
|
||||||
return len
|
return len
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ SecretKit is a collection of protocols describing secrets and stores.
|
|||||||
|
|
||||||
### Authentication Persistence
|
### Authentication Persistence
|
||||||
|
|
||||||
- ``PersistedAuthenticationContext``
|
- ``AuthenticationContextProtocol``
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
/// Type eraser for SecretStore.
|
/// Type eraser for SecretStore.
|
||||||
open class AnySecretStore: SecretStore, @unchecked Sendable {
|
open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||||
@@ -8,9 +9,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
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, SigningRequestTarget?, LAContext?) async throws -> Data
|
||||||
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
|
|
||||||
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
|
||||||
private let _reloadSecrets: @Sendable () async -> Void
|
private let _reloadSecrets: @Sendable () async -> Void
|
||||||
|
|
||||||
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
|
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
|
||||||
@@ -19,9 +18,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
_name = { secretStore.name }
|
_name = { secretStore.name }
|
||||||
_id = { secretStore.id }
|
_id = { secretStore.id }
|
||||||
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
||||||
_sign = { try 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, target: $3, context: $4) }
|
||||||
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
|
|
||||||
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
|
||||||
_reloadSecrets = { await secretStore.reloadSecrets() }
|
_reloadSecrets = { await secretStore.reloadSecrets() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,16 +38,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
return _secrets()
|
return _secrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||||
try await _sign(data, secret, provenance)
|
try await _sign(data, secret, provenance, target, context)
|
||||||
}
|
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
|
|
||||||
await _existingPersistedAuthenticationContext(secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
|
|
||||||
try await _persistAuthentication(secret, duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reloadSecrets() async {
|
public func reloadSecrets() async {
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
|
|
||||||
public final class PublicKeyFileStoreController: Sendable {
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
|
||||||
private let directory: URL
|
|
||||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
|
||||||
|
|
||||||
/// Initializes a PublicKeyFileStoreController.
|
|
||||||
public init(homeDirectory: URL) {
|
|
||||||
directory = homeDirectory.appending(component: "PublicKeys")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes out the keys specified to disk.
|
|
||||||
/// - Parameter secrets: The Secrets to generate keys for.
|
|
||||||
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
|
||||||
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
|
||||||
logger.log("Writing public keys to disk")
|
|
||||||
if clear {
|
|
||||||
let validPaths = Set(secrets.map { publicKeyPath(for: $0) })
|
|
||||||
.union(Set(secrets.map { sshCertificatePath(for: $0) }))
|
|
||||||
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
|
|
||||||
let fullPathContents = contentsOfDirectory.map { directory.appending(path: $0).path() }
|
|
||||||
|
|
||||||
let untracked = Set(fullPathContents)
|
|
||||||
.subtracting(validPaths)
|
|
||||||
for path in untracked {
|
|
||||||
// string instead of fileURLWithPath since we're already using fileURL format.
|
|
||||||
try? FileManager.default.removeItem(at: URL(string: path)!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
|
|
||||||
for secret in secrets {
|
|
||||||
let path = publicKeyPath(for: secret)
|
|
||||||
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
|
||||||
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
|
||||||
}
|
|
||||||
logger.log("Finished writing public keys")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path for a Secret's public key.
|
|
||||||
/// - Parameter secret: The Secret to return the path for.
|
|
||||||
/// - Returns: The path to the Secret's public key.
|
|
||||||
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
|
||||||
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
|
|
||||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
|
||||||
return directory.appending(component: "\(minimalHex).pub").path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
|
|
||||||
public var hasAnyCertificates: Bool {
|
|
||||||
do {
|
|
||||||
return try FileManager.default
|
|
||||||
.contentsOfDirectory(atPath: directory.path())
|
|
||||||
.filter { $0.hasSuffix("-cert.pub") }
|
|
||||||
.isEmpty == false
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path for a Secret's SSH Certificate public key.
|
|
||||||
/// - Parameter secret: The Secret to return the path for.
|
|
||||||
/// - Returns: The path to the SSH Certificate public key.
|
|
||||||
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
|
|
||||||
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
|
|
||||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
|
||||||
return directory.appending(component: "\(minimalHex)-cert.pub").path()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
/// Protocol describing an authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
|
||||||
|
public protocol AuthenticationContextProtocol: Sendable, Identifiable {
|
||||||
|
var secret: AnySecret { get }
|
||||||
|
func valid(for request: SignatureRequest) -> Bool
|
||||||
|
var laContext: LAContext? { get }
|
||||||
|
func evaluate() async throws -> Bool
|
||||||
|
func cancel() async
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SignatureRequest: Identifiable, Hashable, Sendable, Comparable {
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let date: Date
|
||||||
|
public let secret: AnySecret
|
||||||
|
public let provenance: SigningRequestProvenance
|
||||||
|
public let target: SigningRequestTarget?
|
||||||
|
|
||||||
|
public init(secret: AnySecret, provenance: SigningRequestProvenance, target: SigningRequestTarget?) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.date = Date()
|
||||||
|
self.secret = secret
|
||||||
|
self.provenance = provenance
|
||||||
|
self.target = target
|
||||||
|
}
|
||||||
|
|
||||||
|
public var batchID: Int {
|
||||||
|
var hasher = Hasher()
|
||||||
|
guard let target else {
|
||||||
|
// Requests without target are not permitted to be batched.
|
||||||
|
id.hash(into: &hasher)
|
||||||
|
return hasher.finalize()
|
||||||
|
}
|
||||||
|
provenance.batchID.hash(into: &hasher)
|
||||||
|
target.batchID.hash(into: &hasher)
|
||||||
|
secret.id.hash(into: &hasher)
|
||||||
|
return hasher.finalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func < (lhs: SignatureRequest, rhs: SignatureRequest) -> Bool {
|
||||||
|
lhs.date < rhs.date
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Protocol describing a persisted authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
|
|
||||||
public protocol PersistedAuthenticationContext: Sendable {
|
|
||||||
/// Whether the context remains valid.
|
|
||||||
var valid: Bool { get }
|
|
||||||
/// The date at which the authorization expires and the context becomes invalid.
|
|
||||||
var expiration: Date { get }
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
/// 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<SecretType>: Identifiable, Sendable {
|
public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||||
@@ -20,20 +21,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
|||||||
/// - secret: The ``Secret`` to sign with.
|
/// - secret: The ``Secret`` to sign with.
|
||||||
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
|
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
|
||||||
/// - Returns: The signed data.
|
/// - Returns: The signed data.
|
||||||
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
|
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data
|
||||||
|
|
||||||
/// Checks to see if there is currently a valid persisted authentication for a given secret.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
|
|
||||||
/// - Returns: A persisted authentication context, if a valid one exists.
|
|
||||||
func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
|
|
||||||
|
|
||||||
/// Persists user authorization for access to a secret.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - secret: The ``Secret`` to persist the authorization for.
|
|
||||||
/// - duration: The duration that the authorization should persist for.
|
|
||||||
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
|
|
||||||
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
|
|
||||||
|
|
||||||
/// Requests that the store reload secrets from any backing store, if neccessary.
|
/// Requests that the store reload secrets from any backing store, if neccessary.
|
||||||
func reloadSecrets() async
|
func reloadSecrets() async
|
||||||
|
|||||||
@@ -2,13 +2,23 @@ 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: Hashable, Sendable {
|
||||||
|
|
||||||
/// A list of processes involved in the request.
|
/// A list of processes involved in the request.
|
||||||
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
|
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
|
||||||
public var chain: [Process]
|
public var chain: [Process]
|
||||||
public init(root: Process) {
|
|
||||||
|
public var date: Date
|
||||||
|
|
||||||
|
public init(root: Process, date: Date = .now) {
|
||||||
self.chain = [root]
|
self.chain = [root]
|
||||||
|
self.date = date
|
||||||
|
}
|
||||||
|
|
||||||
|
public var batchID: Int {
|
||||||
|
var hasher = Hasher()
|
||||||
|
chain.map(\.path).hash(into: &hasher)
|
||||||
|
return hasher.finalize()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -30,7 +40,7 @@ extension SigningRequestProvenance {
|
|||||||
extension SigningRequestProvenance {
|
extension SigningRequestProvenance {
|
||||||
|
|
||||||
/// Describes a process in a `SigningRequestProvenance` chain.
|
/// Describes a process in a `SigningRequestProvenance` chain.
|
||||||
public struct Process: Equatable, Sendable {
|
public struct Process: Hashable, Sendable {
|
||||||
|
|
||||||
/// The pid of the process.
|
/// The pid of the process.
|
||||||
public let pid: Int32
|
public let pid: Int32
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Describes the target of the signature operation.
|
||||||
|
public enum SigningRequestTarget: Sendable, Hashable {
|
||||||
|
|
||||||
|
case connection(ConnectionPayload)
|
||||||
|
case signature(SignaturePayload)
|
||||||
|
|
||||||
|
public var batchID: Int {
|
||||||
|
hashValue
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct ConnectionPayload: Sendable, Codable, Hashable {
|
||||||
|
|
||||||
|
public let username: String
|
||||||
|
public let hasSignature: Bool
|
||||||
|
public let publicKeyAlgorithm: String
|
||||||
|
public let publicKey: Data
|
||||||
|
public let hostKey: Data
|
||||||
|
public let host: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
username: String,
|
||||||
|
hasSignature: Bool,
|
||||||
|
publicKeyAlgorithm: String,
|
||||||
|
publicKey: Data,
|
||||||
|
hostKey: Data,
|
||||||
|
host: String?
|
||||||
|
) {
|
||||||
|
self.username = username
|
||||||
|
self.hasSignature = hasSignature
|
||||||
|
self.publicKeyAlgorithm = publicKeyAlgorithm
|
||||||
|
self.publicKey = publicKey
|
||||||
|
self.hostKey = hostKey
|
||||||
|
self.host = host
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SignaturePayload: Sendable, Codable, Hashable {
|
||||||
|
|
||||||
|
public let namespace: String
|
||||||
|
public let hashAlgorithm: String
|
||||||
|
public let hash: Data
|
||||||
|
|
||||||
|
public init(
|
||||||
|
namespace: String,
|
||||||
|
hashAlgorithm: String,
|
||||||
|
hash: Data,
|
||||||
|
) {
|
||||||
|
self.namespace = namespace
|
||||||
|
self.hashAlgorithm = hashAlgorithm
|
||||||
|
self.hash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import Security
|
|||||||
import CryptoTokenKit
|
import CryptoTokenKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import os
|
import OSLog
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave {
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +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
|
|
||||||
unsafe self.context = context
|
|
||||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
|
||||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A boolean describing whether or not the context is still valid.
|
|
||||||
var valid: Bool {
|
|
||||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiration: Date {
|
|
||||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
|
||||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
|
||||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor PersistentAuthenticationHandler: Sendable {
|
|
||||||
|
|
||||||
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
|
||||||
|
|
||||||
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
|
|
||||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
|
||||||
return persisted
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
|
||||||
let newContext = LAContext()
|
|
||||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
|
||||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
|
||||||
|
|
||||||
let formatter = DateComponentsFormatter()
|
|
||||||
formatter.unitsStyle = .spellOut
|
|
||||||
formatter.allowedUnits = [.hour, .minute, .day]
|
|
||||||
|
|
||||||
|
|
||||||
let durationString = formatter.string(from: duration)!
|
|
||||||
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
|
||||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
|
||||||
guard success else { return }
|
|
||||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
|
||||||
persistedAuthenticationContexts[secret] = context
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import Security
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import os
|
import OSLog
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave {
|
||||||
|
|
||||||
@@ -17,7 +17,6 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
public let name = String(localized: .secureEnclave)
|
public let name = String(localized: .secureEnclave)
|
||||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
|
|
||||||
|
|
||||||
/// Initializes a Store.
|
/// Initializes a Store.
|
||||||
@MainActor public init() {
|
@MainActor public init() {
|
||||||
@@ -37,17 +36,7 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
// MARK: SecretStore
|
// MARK: SecretStore
|
||||||
|
|
||||||
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, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||||
var context: LAContext
|
|
||||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
|
||||||
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 queryAttributes = KeychainDictionary([
|
let queryAttributes = KeychainDictionary([
|
||||||
kSecClass: Constants.keyClass,
|
kSecClass: Constants.keyClass,
|
||||||
kSecAttrService: Constants.keyTag,
|
kSecAttrService: Constants.keyTag,
|
||||||
@@ -88,14 +77,6 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
|
||||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
|
||||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor public func reloadSecrets() {
|
@MainActor public func reloadSecrets() {
|
||||||
let before = secrets
|
let before = secrets
|
||||||
secrets.removeAll()
|
secrets.removeAll()
|
||||||
@@ -176,6 +157,7 @@ extension SecureEnclave {
|
|||||||
let attributes = try JSONEncoder().encode(attributes)
|
let attributes = try JSONEncoder().encode(attributes)
|
||||||
let updatedAttributes = KeychainDictionary([
|
let updatedAttributes = KeychainDictionary([
|
||||||
kSecAttrLabel: name,
|
kSecAttrLabel: name,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
kSecAttrGeneric: attributes,
|
kSecAttrGeneric: attributes,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -287,7 +269,7 @@ extension SecureEnclave.Store {
|
|||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let keyClass = kSecClassGenericPassword as String
|
static let keyClass = kSecClassGenericPassword as String
|
||||||
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
static let keyTag = "com.maxgoedjen.secretive.secureenclave.key"
|
||||||
static let notificationToken = UUID().uuidString
|
static let notificationToken = UUID().uuidString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct RequireDestinationInformationSettingsKey: SettingsStore.SettingsKey {
|
||||||
|
static let defaultValue: Bool = true
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SettingsStore {
|
||||||
|
|
||||||
|
public var requireDestinationInformation: Bool {
|
||||||
|
get { self[RequireDestinationInformationSettingsKey.self] }
|
||||||
|
set { self[RequireDestinationInformationSettingsKey.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import Security
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
// Setting store backed by macOS keychain for stronger guarantees around ownership/other-process-modification than UserDefaults offers.
|
||||||
|
@Observable @MainActor public final class SettingsStore: Sendable {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.settings", category: "SettingsStore")
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private var state: [ObjectIdentifier: UUID] = [:]
|
||||||
|
|
||||||
|
subscript<SettingsKeyType: SettingsKey>(_ key: SettingsKeyType.Type) -> SettingsKeyType.Value {
|
||||||
|
get {
|
||||||
|
_ = state[ObjectIdentifier(key)]
|
||||||
|
let queryAttributes = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecAttrAccount: String(describing: SettingsKeyType.self),
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecReturnData: true,
|
||||||
|
kSecReturnAttributes: true,
|
||||||
|
kSecMatchLimit: kSecMatchLimitOne,
|
||||||
|
])
|
||||||
|
var untyped: CFTypeRef?
|
||||||
|
unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
|
guard let typed = untyped as? [CFString: Any] else { return SettingsKeyType.defaultValue }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
guard let data = typed[kSecValueData] as? Data else { return SettingsKeyType.defaultValue }
|
||||||
|
return (try? decoder.decode(SettingValue<SettingsKeyType.Value>.self, from: data).value) ?? SettingsKeyType.defaultValue
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(SettingValue(value: newValue))
|
||||||
|
let keychainAttributes = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecAttrAccount: String(describing: SettingsKeyType.self),
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
kSecValueData: data,
|
||||||
|
])
|
||||||
|
let status = SecItemAdd(keychainAttributes, nil)
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
break
|
||||||
|
case errSecDuplicateItem:
|
||||||
|
let updateQuery = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecAttrAccount: String(describing: SettingsKeyType.self),
|
||||||
|
])
|
||||||
|
let updatedAttributes = KeychainDictionary([
|
||||||
|
kSecValueData: data,
|
||||||
|
])
|
||||||
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
state[ObjectIdentifier(key)] = UUID()
|
||||||
|
} catch {
|
||||||
|
logger.error("Error updating key: \(String(describing: SettingsKeyType.self), privacy: .public): \(error.localizedDescription.debugDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SettingsStore {
|
||||||
|
|
||||||
|
public protocol SettingsKey<Value>: Equatable, Codable, Sendable {
|
||||||
|
associatedtype Value: Codable
|
||||||
|
static var defaultValue: Value { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SettingsStore {
|
||||||
|
|
||||||
|
struct SettingValue<Value: Codable>: Codable {
|
||||||
|
let value: Value
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension SettingsStore {
|
||||||
|
|
||||||
|
fileprivate struct KeychainError: Error {
|
||||||
|
let statusCode: OSStatus?
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func KeychainDictionary(_ dictionary: [CFString: Any]) -> CFDictionary {
|
||||||
|
dictionary as CFDictionary
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension SettingsStore {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
static let keyClass = kSecClassGenericPassword as String
|
||||||
|
static let keyTag = "com.maxgoedjen.settingsStore"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import CryptoTokenKit
|
||||||
|
import CryptoKit
|
||||||
|
import OSLog
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CertificateKit
|
||||||
|
|
||||||
|
public struct CertificateMigrator {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
|
||||||
|
private let publicKeysDirectory: URL
|
||||||
|
private let certificatesDirectory: URL
|
||||||
|
private let certificateStore: CertificateStore
|
||||||
|
|
||||||
|
/// Initializes a PublicKeyFileStoreController.
|
||||||
|
public init(homeDirectory: URL, certificateStore: CertificateStore) {
|
||||||
|
publicKeysDirectory = homeDirectory.appending(component: "PublicKeys")
|
||||||
|
certificatesDirectory = homeDirectory.appending(component: "Certificates")
|
||||||
|
self.certificateStore = certificateStore
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor public func migrate() throws {
|
||||||
|
try migrate(directory: publicKeysDirectory)
|
||||||
|
try migrate(directory: certificatesDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor public func migrate(directory: URL) throws {
|
||||||
|
let fileCerts = try FileManager.default
|
||||||
|
.contentsOfDirectory(atPath: directory.path())
|
||||||
|
.filter { $0.hasSuffix("-cert.pub") }
|
||||||
|
Task {
|
||||||
|
for path in fileCerts {
|
||||||
|
do {
|
||||||
|
let url = directory.appending(component: path)
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let parser = try await XPCCertificateParser()
|
||||||
|
let cert = try await parser.parse(data: data)
|
||||||
|
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to delete successfully migrated cert: \(path)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to migrate cert: \(path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CertificateKit
|
||||||
|
import XPCWrappers
|
||||||
|
|
||||||
|
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
|
||||||
|
public final class XPCCertificateParser: OpenSSHCertificateParserProtocol {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "XPCCertificateParser")
|
||||||
|
private let session: XPCTypedSession<OpenSSHCertificate, OpenSSHCertificateError>
|
||||||
|
|
||||||
|
public init() async throws {
|
||||||
|
logger.debug("Creating XPCCertificateParser")
|
||||||
|
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretiveCertificateParser", warmup: true)
|
||||||
|
logger.debug("XPCCertificateParser is warmed up.")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parse(data: Data) async throws -> OpenSSHCertificate {
|
||||||
|
logger.debug("Parsing input")
|
||||||
|
defer { logger.debug("Parsed input") }
|
||||||
|
return try await session.send(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
session.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -56,19 +56,19 @@ extension SmartCard {
|
|||||||
|
|
||||||
// MARK: Public API
|
// MARK: Public API
|
||||||
|
|
||||||
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, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||||
guard let tokenID = await state.tokenID else { fatalError() }
|
guard let tokenID = await state.tokenID else { fatalError() }
|
||||||
let context = LAContext()
|
var rawAttributes: [CFString: Any] = [
|
||||||
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
|
||||||
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
|
||||||
let attributes = KeychainDictionary([
|
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
kSecAttrApplicationLabel: secret.id as CFData,
|
kSecAttrApplicationLabel: secret.id as CFData,
|
||||||
kSecAttrTokenID: tokenID,
|
kSecAttrTokenID: tokenID,
|
||||||
kSecUseAuthenticationContext: context,
|
|
||||||
kSecReturnRef: true
|
kSecReturnRef: true
|
||||||
])
|
]
|
||||||
|
if let context {
|
||||||
|
rawAttributes[kSecUseAuthenticationContext] = context
|
||||||
|
}
|
||||||
|
let attributes = KeychainDictionary(rawAttributes)
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
let status = unsafe SecItemCopyMatching(attributes, &untyped)
|
let status = unsafe SecItemCopyMatching(attributes, &untyped)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
@@ -86,13 +86,6 @@ extension SmartCard {
|
|||||||
return signature as Data
|
return signature as Data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reloads all secrets from the store.
|
/// Reloads all secrets from the store.
|
||||||
@MainActor public func reloadSecrets() {
|
@MainActor public func reloadSecrets() {
|
||||||
reloadSecretsInternal()
|
reloadSecretsInternal()
|
||||||
@@ -163,7 +156,7 @@ extension SmartCard.Store {
|
|||||||
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
|
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
|
||||||
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
|
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
|
||||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
||||||
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
|
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .presenceRequired)
|
||||||
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
|
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
|
||||||
guard signatureAlgorithm(for: secret) != nil else { return nil }
|
guard signatureAlgorithm(for: secret) != nil else { return nil }
|
||||||
return secret
|
return secret
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
|
|||||||
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
||||||
let exportedObject = exportedObject
|
let exportedObject = exportedObject
|
||||||
newConnection.exportedObject = exportedObject
|
newConnection.exportedObject = exportedObject
|
||||||
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
|
#if !DEBUG
|
||||||
|
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
|
||||||
|
#endif
|
||||||
newConnection.resume()
|
newConnection.resume()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error
|
|||||||
public init(serviceName: String, warmup: Bool = false) async throws {
|
public init(serviceName: String, warmup: Bool = false) async throws {
|
||||||
let connection = NSXPCConnection(serviceName: serviceName)
|
let connection = NSXPCConnection(serviceName: serviceName)
|
||||||
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
||||||
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
|
#if !DEBUG
|
||||||
|
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
|
||||||
|
#endif
|
||||||
connection.resume()
|
connection.resume()
|
||||||
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
|
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|||||||
+3
-4
@@ -1,8 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import SecretKit
|
@testable import SecretKit
|
||||||
@testable import SecureEnclaveSecretKit
|
import SSHProtocolKit
|
||||||
@testable import SmartCardSecretKit
|
|
||||||
|
|
||||||
@Suite struct OpenSSHPublicKeyWriterTests {
|
@Suite struct OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
@@ -47,8 +46,8 @@ import Testing
|
|||||||
extension OpenSSHPublicKeyWriterTests {
|
extension OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
static let 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)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
static let ecdsa384Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-3
@@ -1,8 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import SecretAgentKit
|
import SSHProtocolKit
|
||||||
@testable import SecureEnclaveSecretKit
|
|
||||||
@testable import SmartCardSecretKit
|
|
||||||
|
|
||||||
@Suite struct OpenSSHReaderTests {
|
@Suite struct OpenSSHReaderTests {
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,25 +1,27 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import CertificateKit
|
||||||
|
@testable import SSHProtocolKit
|
||||||
@testable import SecretKit
|
@testable import SecretKit
|
||||||
@testable import SecretAgentKit
|
@testable import SecretAgentKit
|
||||||
|
|
||||||
@Suite struct AgentTests {
|
@Suite @MainActor struct AgentTests {
|
||||||
|
|
||||||
// MARK: Identity Listing
|
// MARK: Identity Listing
|
||||||
|
|
||||||
@Test func emptyStores() async throws {
|
@Test func emptyStores() async throws {
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||||
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func identitiesList() async throws {
|
@Test func identitiesList() async throws {
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||||
|
|
||||||
let actual = OpenSSHReader(data: response)
|
let actual = OpenSSHReader(data: response)
|
||||||
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
|
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
|
||||||
@@ -31,9 +33,9 @@ import CryptoKit
|
|||||||
|
|
||||||
@Test func noMatchingIdentities() async throws {
|
@Test func noMatchingIdentities() async throws {
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,11 +43,11 @@ import CryptoKit
|
|||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
guard case SSHAgent.Request.signRequest(let context) = request else { return }
|
guard case SSHAgent.Request.signRequest(let context) = request else { return }
|
||||||
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, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||||
let responseReader = OpenSSHReader(data: response)
|
let responseReader = OpenSSHReader(data: response)
|
||||||
let length = try responseReader.readNextBytes(as: UInt32.self).bigEndian
|
let length = try responseReader.readNextBytes(as: UInt32.self)
|
||||||
let type = try responseReader.readNextBytes(as: UInt8.self).bigEndian
|
let type = try responseReader.readNextBytes(as: UInt8.self)
|
||||||
#expect(length == response.count - MemoryLayout<UInt32>.size)
|
#expect(length == response.count - MemoryLayout<UInt32>.size)
|
||||||
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
|
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
|
||||||
let outer = OpenSSHReader(data: responseReader.remaining)
|
let outer = OpenSSHReader(data: responseReader.remaining)
|
||||||
@@ -66,7 +68,7 @@ import CryptoKit
|
|||||||
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||||
// Correct signature
|
// Correct signature
|
||||||
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||||
.isValidSignature(signature, for: context.dataToSign))
|
.isValidSignature(signature, for: context.dataToSign.raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Witness protocol
|
// MARK: Witness protocol
|
||||||
@@ -76,8 +78,8 @@ import CryptoKit
|
|||||||
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, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
|
||||||
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
|
let response = await agent.handle(request: .signRequest(.empty), provenance: .test, hosts: nil)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +91,9 @@ import CryptoKit
|
|||||||
}, witness: { _, trace in
|
}, witness: { _, trace in
|
||||||
witnessed = true
|
witnessed = true
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
_ = await agent.handle(request: request, provenance: .test)
|
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||||
#expect(witnessed)
|
#expect(witnessed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +107,9 @@ import CryptoKit
|
|||||||
}, witness: { _, trace in
|
}, witness: { _, trace in
|
||||||
witnessTrace = trace
|
witnessTrace = trace
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler(), witness: witness)
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
_ = await agent.handle(request: request, provenance: .test)
|
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||||
#expect(witnessTrace == speakNowTrace)
|
#expect(witnessTrace == speakNowTrace)
|
||||||
#expect(witnessTrace == .test)
|
#expect(witnessTrace == .test)
|
||||||
}
|
}
|
||||||
@@ -116,59 +118,20 @@ import CryptoKit
|
|||||||
|
|
||||||
@Test func signatureException() async throws {
|
@Test func signatureException() async throws {
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let store = await list.stores.first?.base as! Stub.Store
|
let store = list.stores.first?.base as! Stub.Store
|
||||||
store.shouldThrow = true
|
store.shouldThrow = true
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list, certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
let response = await agent.handle(request: request, provenance: .test)
|
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Unsupported
|
// MARK: Unsupported
|
||||||
|
|
||||||
@Test func unhandledAdd() async throws {
|
@Test func unhandledAdd() async throws {
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore(), authenticationHandler: AuthenticationHandler())
|
||||||
let response = await agent.handle(request: .addIdentity, provenance: .test)
|
let response = await agent.handle(request: .addIdentity, provenance: .test, hosts: nil)
|
||||||
#expect(response == 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 {
|
|
||||||
|
|
||||||
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
|
||||||
let store = Stub.Store()
|
|
||||||
store.secrets.append(contentsOf: secrets)
|
|
||||||
let storeList = SecretStoreList()
|
|
||||||
storeList.add(store: store)
|
|
||||||
return storeList
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Constants {
|
|
||||||
|
|
||||||
enum Requests {
|
|
||||||
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
|
||||||
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
|
||||||
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Responses {
|
|
||||||
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
|
||||||
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
|
|
||||||
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Secrets {
|
|
||||||
static let ecdsa256Secret = Stub.Secret(keySize: 256, publicKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08=")!, privateKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy09nw780wy/TSfUmzj15iJkV234AaCLNl+H8qFL6qK8VIg==")!)
|
|
||||||
static let ecdsa384Secret = Stub.Secret(keySize: 384, publicKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDA==")!, privateKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDHNapAOzrt9E+9QC4/KYoXS7Uw4pmdAz53uIj02tttiq3c0ZyIQ7XoscWWRqRrz8Kw==")!)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import LocalAuthentication
|
||||||
|
import SecretKit
|
||||||
|
import SecretAgentKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
|
public struct TestSecret: Secret {
|
||||||
|
|
||||||
|
public let id = UUID()
|
||||||
|
public let name = "Test"
|
||||||
|
public let publicKey = Data()
|
||||||
|
public var attributes: Attributes
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor final class TestContext: @preconcurrency AuthenticationContextProtocol, Sendable {
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
private var testEvaluationResult: Bool
|
||||||
|
|
||||||
|
private(set) var canceled: Bool
|
||||||
|
private(set) var evaluationResult: Bool? = nil
|
||||||
|
|
||||||
|
let approval: ApprovalMode
|
||||||
|
let secret: AnySecret
|
||||||
|
let laContext: LAContext? = nil
|
||||||
|
|
||||||
|
enum ApprovalMode {
|
||||||
|
case manual
|
||||||
|
case duration(Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(authenticationRequirement: AuthenticationRequirement, testEvaluationResult: Bool, approval: ApprovalMode = .manual) {
|
||||||
|
self.canceled = false
|
||||||
|
self.testEvaluationResult = testEvaluationResult
|
||||||
|
self.secret = AnySecret(TestSecret(attributes: .init(keyType: .ecdsa256, authentication: authenticationRequirement)))
|
||||||
|
self.approval = approval
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func valid(for request: SecretKit.SignatureRequest) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func evaluate() async throws -> Bool {
|
||||||
|
if case let .duration(duration) = approval {
|
||||||
|
try await Task.sleep(for: duration)
|
||||||
|
} else {
|
||||||
|
while true {
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
guard !canceled else { return false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !canceled else { return false }
|
||||||
|
evaluationResult = testEvaluationResult
|
||||||
|
return testEvaluationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() async {
|
||||||
|
assert(!canceled)
|
||||||
|
assert(evaluationResult == nil)
|
||||||
|
canceled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor @Suite struct AuthenticationHandlerTests {
|
||||||
|
|
||||||
|
let handler = AuthenticationHandler()
|
||||||
|
|
||||||
|
@Test func singleImmediatelyRequests() async throws {
|
||||||
|
var calledPresentPending = false
|
||||||
|
handler.setPendingRequestHandler {
|
||||||
|
calledPresentPending = true
|
||||||
|
}
|
||||||
|
let context = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.zero))
|
||||||
|
_ = try await handler.authenticatedContext(for: .init(secret: .init(context.secret), provenance: .test, target: nil), context: context)
|
||||||
|
#expect(context.evaluationResult == true)
|
||||||
|
#expect(!calledPresentPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sequentialRequestsDoNotBlock() async throws {
|
||||||
|
var calledPresentPending = false
|
||||||
|
handler.setPendingRequestHandler {
|
||||||
|
calledPresentPending = true
|
||||||
|
}
|
||||||
|
let context = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.milliseconds(1)))
|
||||||
|
_ = try await handler.authenticatedContext(for: .init(secret: .init(context.secret), provenance: .test, target: nil), context: context)
|
||||||
|
#expect(context.evaluationResult == true)
|
||||||
|
let second = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .duration(.milliseconds(1)))
|
||||||
|
_ = try await handler.authenticatedContext(for: .init(secret: .init(second.secret), provenance: .test, target: nil), context: second)
|
||||||
|
#expect(context.evaluationResult == true)
|
||||||
|
#expect(second.evaluationResult == true)
|
||||||
|
#expect(!calledPresentPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func secondRetractsAndPresentsBatch() async throws {
|
||||||
|
var calledPresentPending = false
|
||||||
|
handler.setPendingRequestHandler {
|
||||||
|
calledPresentPending = true
|
||||||
|
}
|
||||||
|
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||||
|
let contextB = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||||
|
Task {
|
||||||
|
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
|
||||||
|
}
|
||||||
|
#expect(contextA.evaluationResult == nil)
|
||||||
|
#expect(contextB.evaluationResult == nil)
|
||||||
|
await Task.yield()
|
||||||
|
#expect(contextA.canceled)
|
||||||
|
#expect(calledPresentPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func authRequiredDoesNotBlockNoAuthRequired() async throws {
|
||||||
|
var calledPresentPending = false
|
||||||
|
handler.setPendingRequestHandler {
|
||||||
|
calledPresentPending = true
|
||||||
|
}
|
||||||
|
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true, approval: .manual)
|
||||||
|
let contextB = TestContext(authenticationRequirement: .notRequired, testEvaluationResult: true)
|
||||||
|
var unauthenticatedResult = false
|
||||||
|
Task {
|
||||||
|
Task {
|
||||||
|
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
|
||||||
|
unauthenticatedResult = true
|
||||||
|
}
|
||||||
|
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
#expect(contextA.evaluationResult == nil)
|
||||||
|
#expect(unauthenticatedResult)
|
||||||
|
#expect(!contextA.canceled)
|
||||||
|
#expect(!contextB.canceled)
|
||||||
|
#expect(!calledPresentPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func batchDoesNotBlockNoAuthRequired() async throws {
|
||||||
|
var calledPresentPending = false
|
||||||
|
handler.setPendingRequestHandler {
|
||||||
|
calledPresentPending = true
|
||||||
|
}
|
||||||
|
let contextA = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||||
|
let contextB = TestContext(authenticationRequirement: .presenceRequired, testEvaluationResult: true)
|
||||||
|
let contextC = TestContext(authenticationRequirement: .notRequired, testEvaluationResult: true)
|
||||||
|
Task {
|
||||||
|
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextA.secret), provenance: .test, target: nil), context: contextA)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextB.secret), provenance: .test, target: nil), context: contextB)
|
||||||
|
}
|
||||||
|
#expect(contextA.evaluationResult == nil)
|
||||||
|
#expect(contextB.evaluationResult == nil)
|
||||||
|
await Task.yield()
|
||||||
|
#expect(contextA.canceled)
|
||||||
|
#expect(calledPresentPending)
|
||||||
|
_ = try? await handler.authenticatedContext(for: .init(secret: .init(contextC.secret), provenance: .test, target: nil), context: contextA)
|
||||||
|
#expect(contextA.evaluationResult == nil)
|
||||||
|
#expect(contextB.evaluationResult == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func batching() async throws {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import SecretKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension SigningRequestProvenance {
|
||||||
|
|
||||||
|
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AgentTests {
|
||||||
|
|
||||||
|
func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||||
|
let store = Stub.Store()
|
||||||
|
store.secrets.append(contentsOf: secrets)
|
||||||
|
let storeList = SecretStoreList()
|
||||||
|
storeList.add(store: store)
|
||||||
|
return storeList
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
|
||||||
|
enum Requests {
|
||||||
|
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
||||||
|
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
||||||
|
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Responses {
|
||||||
|
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
||||||
|
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
|
||||||
|
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Secrets {
|
||||||
|
static let ecdsa256Secret = Stub.Secret(keySize: 256, publicKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08=")!, privateKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy09nw780wy/TSfUmzj15iJkV234AaCLNl+H8qFL6qK8VIg==")!)
|
||||||
|
static let ecdsa384Secret = Stub.Secret(keySize: 384, publicKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDA==")!, privateKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDHNapAOzrt9E+9QC4/KYoXS7Uw4pmdAz53uIj02tttiq3c0ZyIQ7XoscWWRqRrz8Kw==")!)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
struct Stub {}
|
struct Stub {}
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ extension Stub {
|
|||||||
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().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, target: SigningRequestTarget?, context: LAContext?) throws -> Data {
|
||||||
guard !shouldThrow else {
|
guard !shouldThrow else {
|
||||||
throw NSError(domain: "test", code: 0, userInfo: nil)
|
throw NSError(domain: "test", code: 0, userInfo: nil)
|
||||||
}
|
}
|
||||||
@@ -56,13 +58,6 @@ extension Stub {
|
|||||||
return try privateKey.signature(for: data).rawRepresentation
|
return try privateKey.signature(for: data).rawRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
|
|
||||||
}
|
|
||||||
|
|
||||||
public func reloadSecrets() {
|
public func reloadSecrets() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ struct StubWitness {
|
|||||||
|
|
||||||
extension StubWitness: SigningWitness {
|
extension StubWitness: SigningWitness {
|
||||||
|
|
||||||
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
|
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws {
|
||||||
let objection = speakNow(secret, provenance)
|
let objection = speakNow(secret, provenance)
|
||||||
if objection {
|
if objection {
|
||||||
throw TheresMyChance()
|
throw TheresMyChance()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
|
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
|
||||||
witness(secret, provenance)
|
witness(secret, provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Cocoa
|
||||||
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
import SecureEnclaveSecretKit
|
||||||
|
import SmartCardSecretKit
|
||||||
|
import SecretAgentKit
|
||||||
|
import Brief
|
||||||
|
import Observation
|
||||||
|
import Common
|
||||||
|
import SwiftUI
|
||||||
|
import CertificateKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SecretAgent: App {
|
||||||
|
|
||||||
|
@MainActor private let storeList: SecretStoreList = {
|
||||||
|
let list = SecretStoreList()
|
||||||
|
let cryptoKit = SecureEnclave.Store()
|
||||||
|
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||||
|
try? migrator.migrate(to: cryptoKit)
|
||||||
|
list.add(store: cryptoKit)
|
||||||
|
list.add(store: SmartCard.Store())
|
||||||
|
return list
|
||||||
|
}()
|
||||||
|
@MainActor private let certificateStore: CertificateStore = CertificateStore()
|
||||||
|
|
||||||
|
private let updater = Updater(checkOnLaunch: true)
|
||||||
|
private let notifier = Notifier()
|
||||||
|
private let authenticationHandler = AuthenticationHandler()
|
||||||
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
|
||||||
|
|
||||||
|
@Environment(\.openWindow) var openWindow
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "App")
|
||||||
|
@SceneBuilder var body: some Scene {
|
||||||
|
MenuBarExtra(isInserted: .constant(false)) {
|
||||||
|
EmptyView()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "lock")
|
||||||
|
.task {
|
||||||
|
await notifier.registerPersistenceHandler {
|
||||||
|
try await authenticationHandler.persistAuthentication(secret: $0, forDuration: $1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
let socketController = SocketController(path: URL.socketPath)
|
||||||
|
let agent = Agent(
|
||||||
|
storeList: storeList,
|
||||||
|
certificateStore: certificateStore,
|
||||||
|
authenticationHandler: authenticationHandler,
|
||||||
|
witness: notifier
|
||||||
|
)
|
||||||
|
for await session in socketController.sessions {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let inputParser = try await XPCAgentInputParser()
|
||||||
|
let hosts = try? await XPCHostsfileReader().read()
|
||||||
|
for await message in session.messages {
|
||||||
|
let request = try await inputParser.parse(data: message)
|
||||||
|
let agentResponse = await agent.handle(request: request, provenance: session.provenance, hosts: hosts)
|
||||||
|
try session.write(agentResponse)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||||
|
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
|
||||||
|
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: certificateStore)
|
||||||
|
try? certsMigrator.migrate()
|
||||||
|
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
|
||||||
|
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
|
||||||
|
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
authenticationHandler.setPendingRequestHandler { @MainActor in
|
||||||
|
openWindow(value: PendingRequestsViewIdentifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
notifier.prompt()
|
||||||
|
_ = withObservationTracking {
|
||||||
|
updater.update
|
||||||
|
} onChange: { [updater, notifier] in
|
||||||
|
Task {
|
||||||
|
guard !updater.currentVersion.isTestBuild else { return }
|
||||||
|
await notifier.notify(update: updater.update!) { release in
|
||||||
|
await updater.ignore(release: release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowGroup(for: PendingRequestsViewIdentifier.self) { _ in
|
||||||
|
pendingView
|
||||||
|
}
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var pendingView: some View {
|
||||||
|
if !authenticationHandler.batchableRequests.isEmpty {
|
||||||
|
PendingRequestsView(authenticationHandler: authenticationHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingRequestsViewIdentifier: Codable, Hashable {}
|
||||||
@@ -6,7 +6,21 @@ import SmartCardSecretKit
|
|||||||
import SecretAgentKit
|
import SecretAgentKit
|
||||||
import Brief
|
import Brief
|
||||||
import Observation
|
import Observation
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CertificateKit
|
||||||
|
import Common
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
|
||||||
|
@MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore()
|
||||||
|
|
||||||
|
@MainActor var certificateStore: CertificateStore {
|
||||||
|
EnvironmentValues._certificateStore
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@main
|
@main
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
@@ -17,16 +31,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
try? migrator.migrate(to: cryptoKit)
|
try? migrator.migrate(to: cryptoKit)
|
||||||
list.add(store: cryptoKit)
|
list.add(store: cryptoKit)
|
||||||
list.add(store: SmartCard.Store())
|
list.add(store: SmartCard.Store())
|
||||||
|
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: EnvironmentValues._certificateStore)
|
||||||
|
try? certsMigrator.migrate()
|
||||||
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: URL.homeDirectory)
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
|
||||||
private lazy var agent: Agent = {
|
@MainActor private lazy var agent: Agent = {
|
||||||
Agent(storeList: storeList, witness: notifier)
|
Agent(storeList: storeList, certificateStore: EnvironmentValues._certificateStore, 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 let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
|
||||||
@@ -34,17 +50,19 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
logger.debug("SecretAgent finished launching")
|
logger.debug("SecretAgent finished launching")
|
||||||
Task {
|
Task {
|
||||||
let inputParser = try await XPCAgentInputParser()
|
|
||||||
for await session in socketController.sessions {
|
for await session in socketController.sessions {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
let inputParser = try await XPCAgentInputParser()
|
||||||
|
let hostsReader = try? await XPCHostsfileReader()
|
||||||
|
let hosts = try? await hostsReader?.read()
|
||||||
for await message in session.messages {
|
for await message in session.messages {
|
||||||
let request = try await inputParser.parse(data: message)
|
let request = try await inputParser.parse(data: message)
|
||||||
let agentResponse = await agent.handle(request: request, provenance: session.provenance)
|
let agentResponse = await agent.handle(request: request, provenance: session.provenance, hosts: hosts)
|
||||||
try await session.write(agentResponse)
|
try session.write(agentResponse)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
try session.close()
|
try? session.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +72,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Task {
|
||||||
|
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
|
||||||
|
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||||
|
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
|
||||||
notifier.prompt()
|
notifier.prompt()
|
||||||
_ = withObservationTracking {
|
_ = withObservationTracking {
|
||||||
updater.update
|
updater.update
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import CryptoTokenKit
|
||||||
|
import CryptoKit
|
||||||
|
import OSLog
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CertificateKit
|
||||||
|
import SharedXPCServices
|
||||||
|
|
||||||
|
public struct CertificateMigrator {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
|
||||||
|
private let directory: URL
|
||||||
|
private let certificateStore: CertificateStore
|
||||||
|
|
||||||
|
/// Initializes a PublicKeyFileStoreController.
|
||||||
|
public init(homeDirectory: URL, certificateStore: CertificateStore) {
|
||||||
|
directory = homeDirectory.appending(component: "PublicKeys")
|
||||||
|
self.certificateStore = certificateStore
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor public func migrate() throws {
|
||||||
|
let fileCerts = try FileManager.default
|
||||||
|
.contentsOfDirectory(atPath: directory.path())
|
||||||
|
.filter { $0.hasSuffix("-cert.pub") }
|
||||||
|
Task {
|
||||||
|
for path in fileCerts {
|
||||||
|
do {
|
||||||
|
let url = directory.appending(component: path)
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let parser = try await XPCCertificateParser()
|
||||||
|
let cert = try await parser.parse(data: data)
|
||||||
|
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to delete successfully migrated cert: \(path)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to migrate cert: \(path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import SecretKit
|
|||||||
import SecretAgentKit
|
import SecretAgentKit
|
||||||
import Brief
|
import Brief
|
||||||
|
|
||||||
|
typealias PersistAction = (@Sendable (AnySecret, TimeInterval) async throws -> Void)
|
||||||
|
|
||||||
final class Notifier: Sendable {
|
final class Notifier: Sendable {
|
||||||
|
|
||||||
private let notificationDelegate = NotificationDelegate()
|
private let notificationDelegate = NotificationDelegate()
|
||||||
@@ -15,6 +17,12 @@ final class Notifier: Sendable {
|
|||||||
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: [])
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory])
|
||||||
|
UNUserNotificationCenter.current().delegate = notificationDelegate
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerPersistenceHandler(action: @escaping PersistAction) async {
|
||||||
let rawDurations = [
|
let rawDurations = [
|
||||||
Measurement(value: 1, unit: UnitDuration.minutes),
|
Measurement(value: 1, unit: UnitDuration.minutes),
|
||||||
Measurement(value: 5, unit: UnitDuration.minutes),
|
Measurement(value: 5, unit: UnitDuration.minutes),
|
||||||
@@ -24,11 +32,9 @@ final class Notifier: Sendable {
|
|||||||
|
|
||||||
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
|
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
|
||||||
var allPersistenceActions = [doNotPersistAction]
|
var allPersistenceActions = [doNotPersistAction]
|
||||||
|
|
||||||
let formatter = DateComponentsFormatter()
|
let formatter = DateComponentsFormatter()
|
||||||
formatter.unitsStyle = .spellOut
|
formatter.unitsStyle = .spellOut
|
||||||
formatter.allowedUnits = [.hour, .minute, .day]
|
formatter.allowedUnits = [.hour, .minute, .day]
|
||||||
|
|
||||||
var identifiers: [String: TimeInterval] = [:]
|
var identifiers: [String: TimeInterval] = [:]
|
||||||
for duration in rawDurations {
|
for duration in rawDurations {
|
||||||
let seconds = duration.converted(to: .seconds).value
|
let seconds = duration.converted(to: .seconds).value
|
||||||
@@ -43,16 +49,11 @@ final class Notifier: Sendable {
|
|||||||
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
|
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
|
||||||
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
|
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
|
||||||
}
|
}
|
||||||
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
var categories = await UNUserNotificationCenter.current().notificationCategories()
|
||||||
UNUserNotificationCenter.current().delegate = notificationDelegate
|
categories.insert(persistAuthenticationCategory)
|
||||||
|
UNUserNotificationCenter.current().setNotificationCategories(categories)
|
||||||
Task {
|
|
||||||
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
|
|
||||||
guard let duration = duration else { return }
|
|
||||||
try? await store.persistAuthentication(secret: secret, forDuration: duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
await notificationDelegate.state.setPersistenceState(options: identifiers, action: action)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prompt() {
|
func prompt() {
|
||||||
@@ -60,16 +61,23 @@ final class Notifier: Sendable {
|
|||||||
notificationCenter.requestAuthorization(options: .alert) { _, _ in }
|
notificationCenter.requestAuthorization(options: .alert) { _, _ in }
|
||||||
}
|
}
|
||||||
|
|
||||||
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
|
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async {
|
||||||
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: .signedNotificationTitle(appName: provenance.origin.displayName))
|
notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
|
||||||
|
switch target {
|
||||||
|
case .connection(let payload) where payload.host != nil:
|
||||||
|
notificationContent.subtitle = String(localized: .signedConnectionNotificationDescription(secretName: secret.name, username: payload.username, host: payload.host!))
|
||||||
|
case .signature(let payload):
|
||||||
|
notificationContent.subtitle = String(localized: .signedSignatureNotificationDescription(secretName: secret.name, namespace: payload.namespace))
|
||||||
|
default:
|
||||||
notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name))
|
notificationContent.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.authenticationRequirement.required {
|
if offerPersistence {
|
||||||
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) {
|
||||||
@@ -100,11 +108,16 @@ final class Notifier: Sendable {
|
|||||||
|
|
||||||
extension Notifier: SigningWitness {
|
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,
|
||||||
|
target: SigningRequestTarget?
|
||||||
|
) async throws {
|
||||||
}
|
}
|
||||||
|
|
||||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
|
||||||
await notify(accessTo: secret, from: store, by: provenance)
|
await notify(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: offerPersistence)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -133,28 +146,24 @@ extension Notifier {
|
|||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
|
||||||
|
|
||||||
fileprivate actor State {
|
fileprivate actor State {
|
||||||
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
|
||||||
typealias IgnoreAction = (@Sendable (Release) async -> Void)
|
typealias IgnoreAction = (@Sendable (Release) async -> Void)
|
||||||
fileprivate var release: Release?
|
fileprivate var release: Release?
|
||||||
fileprivate var ignoreAction: IgnoreAction?
|
fileprivate var ignoreAction: IgnoreAction?
|
||||||
fileprivate var persistAction: PersistAction?
|
fileprivate var persistAction: PersistAction?
|
||||||
fileprivate var persistOptions: [String: TimeInterval] = [:]
|
fileprivate var persistOptions: [String: TimeInterval] = [:]
|
||||||
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
|
|
||||||
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
|
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
|
||||||
|
|
||||||
func setPending(secret: AnySecret, store: AnySecretStore) {
|
func setPending(secret: AnySecret, store: AnySecretStore) {
|
||||||
pendingPersistableSecrets[secret.id.description] = secret
|
pendingPersistableSecrets[secret.id.description] = secret
|
||||||
pendingPersistableStores[store.id.description] = store
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
|
func retrievePending(secretID: String, optionID: String) -> (AnySecret, TimeInterval)? {
|
||||||
guard let secret = pendingPersistableSecrets[secretID],
|
guard let secret = pendingPersistableSecrets[secretID],
|
||||||
let store = pendingPersistableStores[storeID],
|
|
||||||
let options = persistOptions[optionID] else {
|
let options = persistOptions[optionID] else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
pendingPersistableSecrets.removeValue(forKey: secretID)
|
pendingPersistableSecrets.removeValue(forKey: secretID)
|
||||||
return (secret, store, options)
|
return (secret, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
|
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
|
||||||
@@ -202,13 +211,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
|
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
|
||||||
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String,
|
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String else {
|
||||||
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let optionID = response.actionIdentifier
|
let optionID = response.actionIdentifier
|
||||||
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
|
guard let (secret, persistOptions) = await state.retrievePending(secretID: secretID, optionID: optionID) else { return }
|
||||||
await state.persistAction?(secret, store, persistOptions)
|
try? await state.persistAction?(secret, persistOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SecretKit
|
||||||
|
import SecretAgentKit
|
||||||
|
import SmartCardSecretKit
|
||||||
|
import Common
|
||||||
|
|
||||||
|
struct PendingRequestsView: View {
|
||||||
|
|
||||||
|
private let authenticationHandler: any AuthenticationHandlerProtocol
|
||||||
|
@Environment(\.dismissWindow) var dismiss
|
||||||
|
|
||||||
|
init(authenticationHandler: some AuthenticationHandlerProtocol) {
|
||||||
|
self.authenticationHandler = authenticationHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
Text(.pendingRequestDescription)
|
||||||
|
ForEach(Array(authenticationHandler.batchableRequests.enumerated()), id: \.offset) { group in
|
||||||
|
MultilineInfoView {
|
||||||
|
if let first = group.element.first {
|
||||||
|
HStack {
|
||||||
|
HStack {
|
||||||
|
Image(nsImage: .init(byReferencing: first.provenance.origin.iconURL!))
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 50)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(first.provenance.origin.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(first.secret.name)
|
||||||
|
.font(.headline)
|
||||||
|
switch first.target {
|
||||||
|
case .connection(let payload):
|
||||||
|
if let host = payload.host {
|
||||||
|
Text(.authContextConnectingToUsernameAndHost(username: payload.username, host: host))
|
||||||
|
.font(.caption2)
|
||||||
|
} else {
|
||||||
|
Text(.authContextConnectingToUnknownHost)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
case .signature(let payload):
|
||||||
|
Text(.authContextSigningForNamespace(namespace: payload.namespace))
|
||||||
|
.font(.caption2)
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack {
|
||||||
|
Button(.pendingRequestsReviewBatchButton) {
|
||||||
|
Task {
|
||||||
|
try? await authenticationHandler.requestAuthentication(for: Set(group.element))
|
||||||
|
if authenticationHandler.batchableRequests.isEmpty {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonBorderShape(.capsule)
|
||||||
|
.primaryButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} items: {
|
||||||
|
ForEach(Array(group.element.enumerated()), id: \.offset) { pending in
|
||||||
|
HStack {
|
||||||
|
Text(pending.element.provenance.date.formatted())
|
||||||
|
Spacer()
|
||||||
|
Button(.pendingRequestsReviewSingleButton) {
|
||||||
|
Task {
|
||||||
|
try? await authenticationHandler.requestAuthentication(for: [pending.element])
|
||||||
|
if authenticationHandler.batchableRequests.isEmpty {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonBorderShape(.capsule)
|
||||||
|
.normalButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaPadding(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TestHandler: AuthenticationHandlerProtocol {
|
||||||
|
|
||||||
|
var batchableRequests: [[SignatureRequest]] = []
|
||||||
|
|
||||||
|
func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistAuthentication<SecretType>(secret: SecretType, forDuration duration: TimeInterval) async throws where SecretType : Secret {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPendingRequestHandler(_ handler: @escaping () async throws -> Void) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
ScrollView {
|
||||||
|
MultilineInfoView {
|
||||||
|
HStack {
|
||||||
|
HStack {
|
||||||
|
Image("ghostty")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 50)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Ghostty")
|
||||||
|
.font(.subheadline)
|
||||||
|
Text("GitHub")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Authenticating git@github.com")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack {
|
||||||
|
Button("Review as Batch") {
|
||||||
|
|
||||||
|
}
|
||||||
|
.buttonBorderShape(.capsule)
|
||||||
|
.buttonStyle(.glassProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} items: {
|
||||||
|
ForEach(0..<2) { _ in
|
||||||
|
HStack {
|
||||||
|
Text("4:05 PM")
|
||||||
|
Spacer()
|
||||||
|
Button("Review") {
|
||||||
|
|
||||||
|
}
|
||||||
|
.buttonBorderShape(.capsule)
|
||||||
|
.buttonStyle(.glass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
MultilineInfoView {
|
||||||
|
HStack {
|
||||||
|
HStack {
|
||||||
|
Image("ghostty")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 50)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Ghostty")
|
||||||
|
.font(.subheadline)
|
||||||
|
Text("Git Signing")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Git Signature")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack {
|
||||||
|
Button("Review as Batch") {
|
||||||
|
|
||||||
|
}
|
||||||
|
.buttonBorderShape(.capsule)
|
||||||
|
.buttonStyle(.glassProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} items: {
|
||||||
|
ForEach(0..<2) { _ in
|
||||||
|
HStack {
|
||||||
|
Text("4:05 PM")
|
||||||
|
Spacer()
|
||||||
|
Button("Review") {
|
||||||
|
|
||||||
|
}
|
||||||
|
.buttonBorderShape(.capsule)
|
||||||
|
.buttonStyle(.glass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.frame(minHeight: 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,22 @@
|
|||||||
<!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>2</string>
|
||||||
|
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
||||||
|
<string>2</string>
|
||||||
<key>com.apple.security.smartcard</key>
|
<key>com.apple.security.smartcard</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import XPCWrappers
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
public final class XPCHostsfileReader {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "XPCHostsfileReader")
|
||||||
|
private let session: XPCTypedSession<[Data: String], HostsfileReaderError>
|
||||||
|
|
||||||
|
public init() async throws {
|
||||||
|
logger.debug("Creating XPCHostsfileReader")
|
||||||
|
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentHostsfileReader", warmup: true)
|
||||||
|
logger.debug("XPCHostsfileReader is warmed up.")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func read() async throws -> [Data: String] {
|
||||||
|
logger.debug("Reading hosts file")
|
||||||
|
defer { logger.debug("Read hosts file") }
|
||||||
|
return try await session.send(Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
session.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XPCHostsfileReader {
|
||||||
|
|
||||||
|
public enum HostsfileReaderError: Error, Codable {
|
||||||
|
case fileDoesNotExist
|
||||||
|
case parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SecretAgentKit
|
import OSLog
|
||||||
|
import SSHProtocolKit
|
||||||
import Brief
|
import Brief
|
||||||
import XPCWrappers
|
import XPCWrappers
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
|
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
|
||||||
public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
|
public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
|
||||||
|
|||||||
@@ -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.enhanced-security-version-string</key>
|
||||||
|
<string>2</string>
|
||||||
|
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
||||||
|
<string>2</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import XPCWrappers
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
|
final class SecretAgentHostsfileReader: NSObject, XPCProtocol {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretAgentHostsfileReader", category: "SecretAgentHostsfileReader")
|
||||||
|
|
||||||
|
func process(_ data: Data) async throws -> [Data: String] {
|
||||||
|
logger.log("Parsing hostsfile")
|
||||||
|
var result: [Data: String] = [:]
|
||||||
|
for try await line in URL(filePath: NSHomeDirectory().appending("/.ssh/known_hosts")).lines {
|
||||||
|
let split = line.split(separator: " ").map(String.init)
|
||||||
|
guard split.count == 3 else { continue }
|
||||||
|
guard let decoded = Data(base64Encoded: split[2]) else { continue }
|
||||||
|
let reader = OpenSSHReader(data: decoded)
|
||||||
|
_ = try reader.readNextChunk()
|
||||||
|
let key = try reader.readNextChunk()
|
||||||
|
result[key] = split[0]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
import XPCWrappers
|
||||||
|
|
||||||
|
let delegate = XPCServiceDelegate(exportedObject: SecretAgentHostsfileReader())
|
||||||
|
let listener = NSXPCListener.service()
|
||||||
|
listener.delegate = delegate
|
||||||
|
listener.resume()
|
||||||
@@ -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>2</string>
|
||||||
|
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
||||||
|
<string>2</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
import OSLog
|
import OSLog
|
||||||
import XPCWrappers
|
import XPCWrappers
|
||||||
import SecretAgentKit
|
import SecretAgentKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
final class SecretAgentInputParser: NSObject, XPCProtocol {
|
final class SecretAgentInputParser: NSObject, XPCProtocol {
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,770 @@
|
|||||||
|
{
|
||||||
|
"organization": "Max Goedjen",
|
||||||
|
"default-configuration": "Release",
|
||||||
|
"configurations": [
|
||||||
|
{ "name": "Debug", "file": "Config/Config.xcconfig" },
|
||||||
|
{ "name": "Release", "file": "Config/Config.xcconfig" },
|
||||||
|
{ "name": "Test", "file": "Config/Config.xcconfig" },
|
||||||
|
],
|
||||||
|
"localizations": {
|
||||||
|
"development": "en",
|
||||||
|
"supported": [
|
||||||
|
"ca",
|
||||||
|
"de",
|
||||||
|
"fi",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"ko",
|
||||||
|
"pl",
|
||||||
|
"pt-BR",
|
||||||
|
"ru",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{ "path": "Packages" },
|
||||||
|
{
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Secretive",
|
||||||
|
"children": [
|
||||||
|
{ "path": "App.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Views",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Configuration",
|
||||||
|
"children": [
|
||||||
|
{ "path": "ConfigurationItemView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "IntegrationsView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "Instructions.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "ToolConfigurationView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "SetupView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "GettingStartedView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Secrets",
|
||||||
|
"children": [
|
||||||
|
{ "path": "CreateSecretView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "DeleteSecretView.swift", "encoding": "utf8", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "EditSecretView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "DeleteCertificateView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "EditCertificateView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "EmptyStoreView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "NoStoresView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "SecretDetailView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "CertificateDetailView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "SecretListItemView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "CertificateListItemView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "StoreListView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "SettingsView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Modifiers",
|
||||||
|
"children": [
|
||||||
|
{ "path": "ErrorStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "BoxBackgroundStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "ToolbarButtonStyle.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Views",
|
||||||
|
"children": [
|
||||||
|
{ "path": "AboutView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "AgentStatusView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "ContentView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "CopyableView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "UpdateView.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Controllers",
|
||||||
|
"children": [
|
||||||
|
{ "path": "AgentStatusChecker.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "ApplicationDirectoryController.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "JustUpdatedChecker.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ "path": "Info.plist" },
|
||||||
|
{ "path": "InternetAccessPolicy.plist", "encoding": "utf8", "target-membership": [ "Secretive/resources" ] },
|
||||||
|
{
|
||||||
|
"path": "AppIcon.icon",
|
||||||
|
"target-membership": [
|
||||||
|
"Secretive/resources",
|
||||||
|
"SecretAgent/resources",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ "path": "Secretive.entitlements" },
|
||||||
|
{
|
||||||
|
"path": "<PROJECT>/Packages/Resources/Localizable.xcstrings",
|
||||||
|
"target-membership": [
|
||||||
|
"Secretive/resources",
|
||||||
|
"SecretAgent/resources",
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Preview Content",
|
||||||
|
"children": [
|
||||||
|
{ "path": "Preview Assets.xcassets", "target-membership": [ "Secretive/resources" ] },
|
||||||
|
{ "path": "PreviewStore.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "PreviewUpdater.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
{ "path": "PreviewAgentStatusChecker.swift", "target-membership": [ "Secretive/compile-sources" ] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "SecretAgent",
|
||||||
|
"children": [
|
||||||
|
{ "path": "App.swift", "encoding": "utf8", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||||
|
{ "path": "Notifier.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||||
|
{ "path": "XPCInputParser.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||||
|
{ "path": "PendingRequestsView.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||||
|
{ "path": "XPCHostsfileReader.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||||
|
{ "path": "CertificateMigrator.swift", "target-membership": [ "SecretAgent/compile-sources" ] },
|
||||||
|
{
|
||||||
|
"kind": "variant-group",
|
||||||
|
"name": "Main.storyboard",
|
||||||
|
"target-membership": [
|
||||||
|
"SecretAgent/resources",
|
||||||
|
],
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"path": "en.lproj/Main.storyboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ "path": "Info.plist" },
|
||||||
|
{ "path": "<PROJECT>/SecretAgent/InternetAccessPolicy.plist", "target-membership": [ "SecretAgent/resources" ] },
|
||||||
|
{ "path": "SecretAgent.entitlements" },
|
||||||
|
{
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Preview Content",
|
||||||
|
"children": [
|
||||||
|
{ "path": "Preview Assets.xcassets", "target-membership": [ "SecretAgent/resources" ] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "Config",
|
||||||
|
"children": [
|
||||||
|
{ "path": "Secretive.xctestplan" },
|
||||||
|
{ "path": "Config.xcconfig" },
|
||||||
|
{ "path": "OpenSource.xcconfig" },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "SecretiveUpdater",
|
||||||
|
"children": [
|
||||||
|
{ "path": "SecretiveUpdater.entitlements" },
|
||||||
|
{ "path": "Info.plist" },
|
||||||
|
{ "path": "InternetAccessPolicy.plist", "target-membership": [ "SecretiveUpdater/resources" ] },
|
||||||
|
{ "path": "main.swift", "target-membership": [ "SecretiveUpdater/compile-sources" ] },
|
||||||
|
{ "path": "SecretiveUpdater.swift", "target-membership": [ "SecretiveUpdater/compile-sources" ] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "SecretAgentInputParser",
|
||||||
|
"children": [
|
||||||
|
{ "path": "SecretAgentInputParser.entitlements" },
|
||||||
|
{ "path": "Info.plist" },
|
||||||
|
{ "path": "main.swift", "target-membership": [ "SecretAgentInputParser/compile-sources" ] },
|
||||||
|
{ "path": "SecretAgentInputParser.swift", "target-membership": [ "SecretAgentInputParser/compile-sources" ] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "SecretiveCertificateParser",
|
||||||
|
"children": [
|
||||||
|
{ "path": "Info.plist" },
|
||||||
|
{ "path": "main.swift", "target-membership": [ "SecretiveCertificateParser/compile-sources" ] },
|
||||||
|
{ "path": "SecretiveCertificateParser.entitlements" },
|
||||||
|
{ "path": "SecretiveCertificateParser.swift", "target-membership": [ "SecretiveCertificateParser/compile-sources" ] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"path": "SecretAgentHostsfileReader",
|
||||||
|
"children": [
|
||||||
|
{ "path": "SecretAgentHostsfileReader.entitlements" },
|
||||||
|
{ "path": "Info.plist" },
|
||||||
|
{ "path": "main.swift", "target-membership": [ "SecretAgentHostsfileReader/compile-sources" ] },
|
||||||
|
{ "path": "SecretAgentHostsfileReader.swift", "target-membership": [ "SecretAgentHostsfileReader/compile-sources" ] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"name": "Products",
|
||||||
|
"children": [
|
||||||
|
{ "path": "<PRODUCTS>/Secretive.app", "id": "50617D7F23FCE48E0099B055", "type": "wrapper.application", "index": false },
|
||||||
|
{ "path": "<PRODUCTS>/SecretAgent.app", "id": "50A3B78A24026B7500D209EA", "type": "wrapper.application", "index": false, "target-membership": [ { "build-phase": "Secretive/copy/Copy Files", "header-preservation": "remove-on-copy" } ] },
|
||||||
|
{
|
||||||
|
"path": "<PRODUCTS>/SecretiveUpdater.xpc",
|
||||||
|
"id": "50692D122E6FDB880043C7BB",
|
||||||
|
"type": "wrapper.xpc-service",
|
||||||
|
"index": false,
|
||||||
|
"target-membership": [
|
||||||
|
{ "build-phase": "Secretive/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
|
||||||
|
{ "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"path": "<PRODUCTS>/SecretAgentInputParser.xpc",
|
||||||
|
"id": "50692E502E6FF9D20043C7BB",
|
||||||
|
"type": "wrapper.xpc-service",
|
||||||
|
"index": false,
|
||||||
|
"target-membership": [
|
||||||
|
{ "build-phase": "Secretive/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
|
||||||
|
{ "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"path": "<PRODUCTS>/SecretiveCertificateParser.xpc",
|
||||||
|
"id": "50E205142FAAB81C00402380",
|
||||||
|
"type": "wrapper.xpc-service",
|
||||||
|
"index": false,
|
||||||
|
"target-membership": [
|
||||||
|
{ "build-phase": "Secretive/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
|
||||||
|
{ "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ "path": "<PRODUCTS>/SecretAgentHostsfileReader.xpc", "id": "5054028D3034B5E3000C3356", "type": "wrapper.xpc-service", "index": false, "target-membership": [ { "build-phase": "SecretAgent/copy/Embed XPC Services", "header-preservation": "remove-on-copy" } ] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ "kind": "group", "name": "Frameworks" },
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "Secretive",
|
||||||
|
"id": "50617D7E23FCE48D0099B055",
|
||||||
|
"product": "Products/Secretive.app",
|
||||||
|
"product-type": "application",
|
||||||
|
"dependencies": [
|
||||||
|
"SecretAgent",
|
||||||
|
"SecretiveUpdater",
|
||||||
|
"SecretAgentInputParser",
|
||||||
|
"SecretiveCertificateParser",
|
||||||
|
],
|
||||||
|
"build-phases": [
|
||||||
|
"compile-sources",
|
||||||
|
"frameworks",
|
||||||
|
"resources",
|
||||||
|
{
|
||||||
|
"kind": "copy",
|
||||||
|
"name": "Embed Frameworks",
|
||||||
|
"bundle-base-path": "frameworks-directory",
|
||||||
|
}, {
|
||||||
|
"kind": "copy",
|
||||||
|
"name": "Copy Files",
|
||||||
|
"bundle-base-path": "root",
|
||||||
|
"relative-path": "Contents/Library/LoginItems",
|
||||||
|
}, {
|
||||||
|
"kind": "copy",
|
||||||
|
"name": "Embed XPC Services",
|
||||||
|
"bundle-base-path": "build-products-directory",
|
||||||
|
"relative-path": "$(CONTENTS_FOLDER_PATH)/XPCServices",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"package-product-members": [
|
||||||
|
{
|
||||||
|
"product-name": "Brief",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "CertificateKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "Common",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "Formatters",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SecretKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SecureEnclaveSecretKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SettingsKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SharedXPCServices",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SmartCardSecretKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
|
||||||
|
"CODE_SIGN_ENTITLEMENTS": "Secretive/Secretive.entitlements",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
|
||||||
|
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
|
||||||
|
"CODE_SIGN_STYLE[config=Release]": "Manual",
|
||||||
|
"CODE_SIGN_STYLE[config=Test]": "Manual",
|
||||||
|
"COMBINE_HIDPI_IMAGES": "YES",
|
||||||
|
"CURRENT_PROJECT_VERSION": "1",
|
||||||
|
"DEAD_CODE_STRIPPING": "YES",
|
||||||
|
"DEVELOPMENT_ASSET_PATHS": "\"Secretive/Preview Content\"",
|
||||||
|
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"ENABLE_APP_SANDBOX": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME[config=Debug]": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME[config=Release]": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME[config=Test]": "NO",
|
||||||
|
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
|
||||||
|
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "NO",
|
||||||
|
"ENABLE_PREVIEWS": "YES",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_PRINTING": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_USB": "NO",
|
||||||
|
"INFOPLIST_FILE": "Secretive/Info.plist",
|
||||||
|
"LD_RUNPATH_SEARCH_PATHS": [
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
],
|
||||||
|
"MARKETING_VERSION": "1",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).Host",
|
||||||
|
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[config=Debug]": "",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "Secretive - Host",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
"name": "SecretAgent",
|
||||||
|
"id": "50A3B78924026B7500D209EA",
|
||||||
|
"product": "Products/SecretAgent.app",
|
||||||
|
"product-type": "application",
|
||||||
|
"dependencies": [
|
||||||
|
"SecretiveUpdater",
|
||||||
|
"SecretAgentInputParser",
|
||||||
|
"SecretiveCertificateParser",
|
||||||
|
"SecretAgentHostsfileReader",
|
||||||
|
],
|
||||||
|
"build-phases": [
|
||||||
|
"compile-sources",
|
||||||
|
"frameworks",
|
||||||
|
"resources",
|
||||||
|
{
|
||||||
|
"kind": "copy",
|
||||||
|
"name": "Embed Frameworks",
|
||||||
|
"bundle-base-path": "frameworks-directory",
|
||||||
|
}, {
|
||||||
|
"kind": "copy",
|
||||||
|
"name": "Embed XPC Services",
|
||||||
|
"bundle-base-path": "build-products-directory",
|
||||||
|
"relative-path": "$(CONTENTS_FOLDER_PATH)/XPCServices",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"package-product-members": [
|
||||||
|
{
|
||||||
|
"product-name": "Brief",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "Common",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SecretAgentKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SecretKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SecureEnclaveSecretKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SharedXPCServices",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SmartCardSecretKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
|
||||||
|
"AUTOMATION_APPLE_EVENTS": "NO",
|
||||||
|
"CODE_SIGN_ENTITLEMENTS": "SecretAgent/SecretAgent.entitlements",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
|
||||||
|
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
|
||||||
|
"CODE_SIGN_STYLE[config=Release]": "Manual",
|
||||||
|
"CODE_SIGN_STYLE[config=Test]": "Manual",
|
||||||
|
"COMBINE_HIDPI_IMAGES": "YES",
|
||||||
|
"DEAD_CODE_STRIPPING": "YES",
|
||||||
|
"DEVELOPMENT_ASSET_PATHS": "\"SecretAgent/Preview Content\"",
|
||||||
|
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"ENABLE_APP_SANDBOX": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||||
|
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
|
||||||
|
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "NO",
|
||||||
|
"ENABLE_PREVIEWS": "YES",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_PRINTING": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_USB": "NO",
|
||||||
|
"INFOPLIST_FILE": "SecretAgent/Info.plist",
|
||||||
|
"LD_RUNPATH_SEARCH_PATHS": [
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
],
|
||||||
|
"MARKETING_VERSION": "1",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretAgent",
|
||||||
|
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "Secretive - Secret Agent",
|
||||||
|
"RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES": "NO",
|
||||||
|
"RUNTIME_EXCEPTION_ALLOW_JIT": "NO",
|
||||||
|
"RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY": "NO",
|
||||||
|
"RUNTIME_EXCEPTION_DEBUGGING_TOOL": "NO",
|
||||||
|
"RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION": "NO",
|
||||||
|
"RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION": "NO",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
"name": "SecretiveUpdater",
|
||||||
|
"id": "50692D112E6FDB880043C7BB",
|
||||||
|
"product": "Products/SecretiveUpdater.xpc",
|
||||||
|
"product-type": "xpc-service",
|
||||||
|
"dependencies": [
|
||||||
|
{ "kind": "package", "product-name": "Brief" },
|
||||||
|
{ "kind": "package", "product-name": "Formatters" },
|
||||||
|
{ "kind": "package", "product-name": "XPCWrappers" },
|
||||||
|
],
|
||||||
|
"build-phases": [
|
||||||
|
"compile-sources",
|
||||||
|
"frameworks",
|
||||||
|
"resources",
|
||||||
|
],
|
||||||
|
"package-product-members": [
|
||||||
|
{
|
||||||
|
"product-name": "Brief",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "Formatters",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "XPCWrappers",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
|
||||||
|
"CODE_SIGN_ENTITLEMENTS": "SecretiveUpdater/SecretiveUpdater.entitlements",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Debug][sdk=macosx*]": "Apple Development",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
|
||||||
|
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
|
||||||
|
"CODE_SIGN_STYLE[config=Release]": "Manual",
|
||||||
|
"CODE_SIGN_STYLE[config=Test]": "Automatic",
|
||||||
|
"COMBINE_HIDPI_IMAGES": "YES",
|
||||||
|
"CURRENT_PROJECT_VERSION": "1",
|
||||||
|
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release]": "",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"ENABLE_APP_SANDBOX": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||||
|
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
|
||||||
|
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_PRINTING": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_USB": "NO",
|
||||||
|
"GCC_C_LANGUAGE_STANDARD": "gnu17",
|
||||||
|
"GENERATE_INFOPLIST_FILE": "YES",
|
||||||
|
"INFOPLIST_FILE": "SecretiveUpdater/Info.plist",
|
||||||
|
"INFOPLIST_KEY_CFBundleDisplayName": "SecretiveUpdater",
|
||||||
|
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2025 Max Goedjen. All rights reserved.",
|
||||||
|
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
|
||||||
|
"MARKETING_VERSION": "1.0",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveUpdater",
|
||||||
|
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
|
||||||
|
"REGISTER_APP_GROUPS": "YES",
|
||||||
|
"SKIP_INSTALL": "YES",
|
||||||
|
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
|
||||||
|
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
|
||||||
|
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
||||||
|
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
|
||||||
|
"SWIFT_VERSION": "5.0",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
"name": "SecretiveCertificateParser",
|
||||||
|
"id": "50E205132FAAB81C00402380",
|
||||||
|
"product": "Products/SecretiveCertificateParser.xpc",
|
||||||
|
"product-type": "xpc-service",
|
||||||
|
"dependencies": [
|
||||||
|
{ "kind": "package", "product-name": "Formatters" },
|
||||||
|
{ "kind": "package", "product-name": "SSHProtocolKit" },
|
||||||
|
{ "kind": "package", "product-name": "XPCWrappers" },
|
||||||
|
],
|
||||||
|
"build-phases": [
|
||||||
|
"compile-sources",
|
||||||
|
"frameworks",
|
||||||
|
"resources",
|
||||||
|
],
|
||||||
|
"package-product-members": [
|
||||||
|
{
|
||||||
|
"product-name": "Formatters",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "SSHProtocolKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "XPCWrappers",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
|
||||||
|
"CODE_SIGN_ENTITLEMENTS": "SecretiveCertificateParser/SecretiveCertificateParser.entitlements",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
|
||||||
|
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
|
||||||
|
"CODE_SIGN_STYLE[config=Release]": "Manual",
|
||||||
|
"CODE_SIGN_STYLE[config=Test]": "Automatic",
|
||||||
|
"COMBINE_HIDPI_IMAGES": "YES",
|
||||||
|
"CURRENT_PROJECT_VERSION": "1",
|
||||||
|
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release]": "",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "Z72PRUAWF6",
|
||||||
|
"ENABLE_APP_SANDBOX": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||||
|
"GCC_C_LANGUAGE_STANDARD": "gnu17",
|
||||||
|
"GENERATE_INFOPLIST_FILE": "YES",
|
||||||
|
"INFOPLIST_FILE": "SecretiveCertificateParser/Info.plist",
|
||||||
|
"INFOPLIST_KEY_CFBundleDisplayName": "SecretiveCertificateParser",
|
||||||
|
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2026 Max Goedjen. All rights reserved.",
|
||||||
|
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
|
||||||
|
"MARKETING_VERSION": "1.0",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser",
|
||||||
|
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
|
||||||
|
"REGISTER_APP_GROUPS": "YES",
|
||||||
|
"SKIP_INSTALL": "YES",
|
||||||
|
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
|
||||||
|
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
|
||||||
|
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
||||||
|
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
|
||||||
|
"SWIFT_VERSION": "5.0",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
"name": "SecretAgentInputParser",
|
||||||
|
"id": "50692E4F2E6FF9D20043C7BB",
|
||||||
|
"product": "Products/SecretAgentInputParser.xpc",
|
||||||
|
"product-type": "xpc-service",
|
||||||
|
"dependencies": [
|
||||||
|
{ "kind": "package", "product-name": "SecretAgentKit" },
|
||||||
|
{ "kind": "package", "product-name": "XPCWrappers" },
|
||||||
|
],
|
||||||
|
"build-phases": [
|
||||||
|
"compile-sources",
|
||||||
|
"frameworks",
|
||||||
|
"resources",
|
||||||
|
],
|
||||||
|
"package-product-members": [
|
||||||
|
{
|
||||||
|
"product-name": "SecretAgentKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "XPCWrappers",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
|
||||||
|
"CODE_SIGN_ENTITLEMENTS": "SecretAgentInputParser/SecretAgentInputParser.entitlements",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
|
||||||
|
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
|
||||||
|
"CODE_SIGN_STYLE[config=Release]": "Manual",
|
||||||
|
"CODE_SIGN_STYLE[config=Test]": "Automatic",
|
||||||
|
"COMBINE_HIDPI_IMAGES": "YES",
|
||||||
|
"CURRENT_PROJECT_VERSION": "1",
|
||||||
|
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release]": "",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"ENABLE_APP_SANDBOX": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||||
|
"GCC_C_LANGUAGE_STANDARD": "gnu17",
|
||||||
|
"GENERATE_INFOPLIST_FILE": "YES",
|
||||||
|
"INFOPLIST_FILE": "SecretAgentInputParser/Info.plist",
|
||||||
|
"INFOPLIST_KEY_CFBundleDisplayName": "SecretAgentInputParser",
|
||||||
|
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2025 Max Goedjen. All rights reserved.",
|
||||||
|
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
|
||||||
|
"MARKETING_VERSION": "1.0",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretAgentInputParser",
|
||||||
|
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
|
||||||
|
"REGISTER_APP_GROUPS": "YES",
|
||||||
|
"SKIP_INSTALL": "YES",
|
||||||
|
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
|
||||||
|
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
|
||||||
|
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
||||||
|
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
|
||||||
|
"SWIFT_VERSION": "5.0",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
"name": "SecretAgentHostsfileReader",
|
||||||
|
"id": "5054028C3034B5E3000C3356",
|
||||||
|
"product": "Products/SecretAgentHostsfileReader.xpc",
|
||||||
|
"product-type": "xpc-service",
|
||||||
|
"dependencies": [
|
||||||
|
{ "kind": "package", "product-name": "SSHProtocolKit" },
|
||||||
|
{ "kind": "package", "product-name": "XPCWrappers" },
|
||||||
|
],
|
||||||
|
"build-phases": [
|
||||||
|
"compile-sources",
|
||||||
|
"frameworks",
|
||||||
|
"resources",
|
||||||
|
],
|
||||||
|
"package-product-members": [
|
||||||
|
{
|
||||||
|
"product-name": "SSHProtocolKit",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
}, {
|
||||||
|
"product-name": "XPCWrappers",
|
||||||
|
"build-phase": { "build-phase": "frameworks" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
|
||||||
|
"CODE_SIGN_ENTITLEMENTS": "SecretAgentHostsfileReader/SecretAgentHostsfileReader.entitlements",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Debug]": "Apple Development",
|
||||||
|
"CODE_SIGN_IDENTITY[config=Release]": "Developer ID Application",
|
||||||
|
"CODE_SIGN_STYLE[config=Debug]": "Automatic",
|
||||||
|
"CODE_SIGN_STYLE[config=Release]": "Manual",
|
||||||
|
"CODE_SIGN_STYLE[config=Test]": "Automatic",
|
||||||
|
"COMBINE_HIDPI_IMAGES": "YES",
|
||||||
|
"CURRENT_PROJECT_VERSION": "1",
|
||||||
|
"DEVELOPMENT_TEAM[config=Debug]": "$(SECRETIVE_DEVELOPMENT_TEAM)",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release]": "",
|
||||||
|
"DEVELOPMENT_TEAM[config=Release][sdk=macosx*]": "Z72PRUAWF6",
|
||||||
|
"ENABLE_APP_SANDBOX": "NO",
|
||||||
|
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||||
|
"GCC_C_LANGUAGE_STANDARD": "gnu17",
|
||||||
|
"GENERATE_INFOPLIST_FILE": "YES",
|
||||||
|
"INFOPLIST_FILE": "SecretAgentHostsfileReader/Info.plist",
|
||||||
|
"INFOPLIST_KEY_CFBundleDisplayName": "SecretAgentHostsfileReader",
|
||||||
|
"INFOPLIST_KEY_NSHumanReadableCopyright": "Copyright © 2026 Max Goedjen. All rights reserved.",
|
||||||
|
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
|
||||||
|
"MARKETING_VERSION": "1.0",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER": "$(SECRETIVE_BASE_BUNDLE_ID).SecretAgentHostsfileReader",
|
||||||
|
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[config=Release]": "",
|
||||||
|
"REGISTER_APP_GROUPS": "YES",
|
||||||
|
"SKIP_INSTALL": "YES",
|
||||||
|
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
|
||||||
|
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
|
||||||
|
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
||||||
|
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
|
||||||
|
"SWIFT_VERSION": "5.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"ALWAYS_SEARCH_USER_PATHS": "NO",
|
||||||
|
"ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS": "YES",
|
||||||
|
"CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED": "YES",
|
||||||
|
"CLANG_ANALYZER_NONNULL": "YES",
|
||||||
|
"CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION": "YES_AGGRESSIVE",
|
||||||
|
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++14",
|
||||||
|
"CLANG_CXX_LIBRARY": "libc++",
|
||||||
|
"CLANG_ENABLE_MODULES": "YES",
|
||||||
|
"CLANG_ENABLE_OBJC_ARC": "YES",
|
||||||
|
"CLANG_ENABLE_OBJC_WEAK": "YES",
|
||||||
|
"CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING": "YES",
|
||||||
|
"CLANG_WARN_BOOL_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_COMMA": "YES",
|
||||||
|
"CLANG_WARN_CONSTANT_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS": "YES",
|
||||||
|
"CLANG_WARN_DIRECT_OBJC_ISA_USAGE": "YES_ERROR",
|
||||||
|
"CLANG_WARN_DOCUMENTATION_COMMENTS": "YES",
|
||||||
|
"CLANG_WARN_EMPTY_BODY": "YES",
|
||||||
|
"CLANG_WARN_ENUM_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_INFINITE_RECURSION": "YES",
|
||||||
|
"CLANG_WARN_INT_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_NON_LITERAL_NULL_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF": "YES",
|
||||||
|
"CLANG_WARN_OBJC_LITERAL_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_OBJC_ROOT_CLASS": "YES_ERROR",
|
||||||
|
"CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER": "YES",
|
||||||
|
"CLANG_WARN_RANGE_LOOP_ANALYSIS": "YES",
|
||||||
|
"CLANG_WARN_STRICT_PROTOTYPES": "YES",
|
||||||
|
"CLANG_WARN_SUSPICIOUS_MOVE": "YES",
|
||||||
|
"CLANG_WARN_UNGUARDED_AVAILABILITY": "YES_AGGRESSIVE",
|
||||||
|
"CLANG_WARN_UNREACHABLE_CODE": "YES",
|
||||||
|
"CLANG_WARN__DUPLICATE_METHOD_MATCH": "YES",
|
||||||
|
"COPY_PHASE_STRIP": "NO",
|
||||||
|
"DEAD_CODE_STRIPPING": "YES",
|
||||||
|
"DEBUG_INFORMATION_FORMAT[config=Debug]": "dwarf",
|
||||||
|
"DEBUG_INFORMATION_FORMAT[config=Release]": "dwarf-with-dsym",
|
||||||
|
"DEBUG_INFORMATION_FORMAT[config=Test]": "dwarf",
|
||||||
|
"ENABLE_ENHANCED_SECURITY": "YES",
|
||||||
|
"ENABLE_NS_ASSERTIONS[config=Release]": "NO",
|
||||||
|
"ENABLE_POINTER_AUTHENTICATION": "YES",
|
||||||
|
"ENABLE_STRICT_OBJC_MSGSEND": "YES",
|
||||||
|
"ENABLE_TESTABILITY[config=Debug]": "YES",
|
||||||
|
"ENABLE_TESTABILITY[config=Test]": "YES",
|
||||||
|
"ENABLE_USER_SCRIPT_SANDBOXING": "YES",
|
||||||
|
"GCC_C_LANGUAGE_STANDARD": "gnu11",
|
||||||
|
"GCC_DYNAMIC_NO_PIC[config=Debug]": "NO",
|
||||||
|
"GCC_DYNAMIC_NO_PIC[config=Test]": "NO",
|
||||||
|
"GCC_NO_COMMON_BLOCKS": "YES",
|
||||||
|
"GCC_OPTIMIZATION_LEVEL[config=Debug]": "0",
|
||||||
|
"GCC_OPTIMIZATION_LEVEL[config=Test]": "0",
|
||||||
|
"GCC_PREPROCESSOR_DEFINITIONS[config=Debug]": [
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
],
|
||||||
|
"GCC_PREPROCESSOR_DEFINITIONS[config=Test]": [
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
],
|
||||||
|
"GCC_WARN_64_TO_32_BIT_CONVERSION": "YES",
|
||||||
|
"GCC_WARN_ABOUT_RETURN_TYPE": "YES_ERROR",
|
||||||
|
"GCC_WARN_UNDECLARED_SELECTOR": "YES",
|
||||||
|
"GCC_WARN_UNINITIALIZED_AUTOS": "YES_AGGRESSIVE",
|
||||||
|
"GCC_WARN_UNUSED_FUNCTION": "YES",
|
||||||
|
"GCC_WARN_UNUSED_VARIABLE": "YES",
|
||||||
|
"MACOSX_DEPLOYMENT_TARGET": "15.0",
|
||||||
|
"MTL_ENABLE_DEBUG_INFO[config=Debug]": "INCLUDE_SOURCE",
|
||||||
|
"MTL_ENABLE_DEBUG_INFO[config=Release]": "NO",
|
||||||
|
"MTL_ENABLE_DEBUG_INFO[config=Test]": "INCLUDE_SOURCE",
|
||||||
|
"MTL_FAST_MATH": "YES",
|
||||||
|
"ONLY_ACTIVE_ARCH[config=Debug]": "YES",
|
||||||
|
"ONLY_ACTIVE_ARCH[config=Test]": "YES",
|
||||||
|
"OTHER_SWIFT_FLAGS": "",
|
||||||
|
"SDKROOT": "macosx",
|
||||||
|
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
|
||||||
|
"STRIP_INSTALLED_PRODUCT": "NO",
|
||||||
|
"STRIP_SWIFT_SYMBOLS": "NO",
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG",
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Test]": "DEBUG",
|
||||||
|
"SWIFT_COMPILATION_MODE[config=Release]": "wholemodule",
|
||||||
|
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
||||||
|
"SWIFT_OPTIMIZATION_LEVEL[config=Debug]": "-Onone",
|
||||||
|
"SWIFT_OPTIMIZATION_LEVEL[config=Release]": "-O",
|
||||||
|
"SWIFT_OPTIMIZATION_LEVEL[config=Test]": "-Onone",
|
||||||
|
"SWIFT_STRICT_MEMORY_SAFETY": "YES",
|
||||||
|
"SWIFT_TREAT_WARNINGS_AS_ERRORS": "YES",
|
||||||
|
"SWIFT_VERSION": "6.0",
|
||||||
|
},
|
||||||
|
"last-upgrade": "27.0",
|
||||||
|
"last-swift-update": "27.0",
|
||||||
|
}
|
||||||
+5
-3
@@ -1,8 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-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">
|
<!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>iOSPackagesShouldBuildARM64e</key>
|
<key>iOSPackagesShouldBuildARM64e</key>
|
||||||
<true/>
|
<true />
|
||||||
</dict>
|
<key>macOSPackagesShouldBuildARM64e</key>
|
||||||
|
<true />
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -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"
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<TestPlans>
|
<TestPlans>
|
||||||
<TestPlanReference
|
<TestPlanReference
|
||||||
reference = "container:Config/Secretive.xctestplan">
|
reference = "container:Config/Secretive.xctestplan"
|
||||||
|
default = "YES">
|
||||||
</TestPlanReference>
|
</TestPlanReference>
|
||||||
</TestPlans>
|
</TestPlans>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction
|
<TestAction
|
||||||
buildConfiguration = "Test"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
ReferencedContainer = "container:Secretive.xcodeproj">
|
ReferencedContainer = "container:Secretive.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<MetalAPIValidationSettings
|
||||||
|
isEnabled = "No">
|
||||||
|
</MetalAPIValidationSettings>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SecretKit
|
|||||||
import SecureEnclaveSecretKit
|
import SecureEnclaveSecretKit
|
||||||
import SmartCardSecretKit
|
import SmartCardSecretKit
|
||||||
import Brief
|
import Brief
|
||||||
|
import CertificateKit
|
||||||
|
import SettingsKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Secretive: App {
|
struct Secretive: App {
|
||||||
@@ -14,6 +16,8 @@ struct Secretive: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(EnvironmentValues._secretStoreList)
|
.environment(EnvironmentValues._secretStoreList)
|
||||||
|
.environment(EnvironmentValues._certificateStore)
|
||||||
|
.environment(EnvironmentValues._settingsStore)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
Task {
|
Task {
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
@@ -23,7 +27,7 @@ struct Secretive: App {
|
|||||||
guard !agentLaunchController.developmentBuild else { return }
|
guard !agentLaunchController.developmentBuild else { return }
|
||||||
if justUpdatedChecker.justUpdatedBuild || !agentLaunchController.running {
|
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
|
||||||
try await agentLaunchController.forceLaunch()
|
try? await agentLaunchController.forceLaunch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +44,9 @@ struct Secretive: App {
|
|||||||
}
|
}
|
||||||
.windowStyle(.hiddenTitleBar)
|
.windowStyle(.hiddenTitleBar)
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
|
Settings {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -92,15 +99,19 @@ extension EnvironmentValues {
|
|||||||
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
||||||
let list = SecretStoreList()
|
let list = SecretStoreList()
|
||||||
let cryptoKit = SecureEnclave.Store()
|
let cryptoKit = SecureEnclave.Store()
|
||||||
let migrator = SecureEnclave.CryptoKitMigrator()
|
let cryptoKitMigrator = SecureEnclave.CryptoKitMigrator()
|
||||||
try? migrator.migrate(to: cryptoKit)
|
try? cryptoKitMigrator.migrate(to: cryptoKit)
|
||||||
list.add(store: cryptoKit)
|
list.add(store: cryptoKit)
|
||||||
list.add(store: SmartCard.Store())
|
list.add(store: SmartCard.Store())
|
||||||
return list
|
return list
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@MainActor fileprivate static let _certificateStore = CertificateStore()
|
||||||
|
@MainActor fileprivate static let _settingsStore = SettingsStore()
|
||||||
|
|
||||||
private static let _agentLaunchController = AgentLaunchController()
|
private static let _agentLaunchController = AgentLaunchController()
|
||||||
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
|
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
|
||||||
|
|
||||||
private static let _updater: any UpdaterProtocol = {
|
private static let _updater: any UpdaterProtocol = {
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
return Updater(checkOnLaunch: hasRunSetup)
|
return Updater(checkOnLaunch: hasRunSetup)
|
||||||
@@ -113,6 +124,14 @@ extension EnvironmentValues {
|
|||||||
@MainActor var secretStoreList: SecretStoreList {
|
@MainActor var secretStoreList: SecretStoreList {
|
||||||
EnvironmentValues._secretStoreList
|
EnvironmentValues._secretStoreList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor var certificateStore: CertificateStore {
|
||||||
|
EnvironmentValues._certificateStore
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor var settingsStore: SettingsStore {
|
||||||
|
EnvironmentValues._settingsStore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FocusedValues {
|
extension FocusedValues {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SecretKit
|
|||||||
import Observation
|
import Observation
|
||||||
import OSLog
|
import OSLog
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
|
import Common
|
||||||
|
|
||||||
@MainActor protocol AgentLaunchControllerProtocol: Observable, Sendable {
|
@MainActor protocol AgentLaunchControllerProtocol: Observable, Sendable {
|
||||||
var running: Bool { get }
|
var running: Bool { get }
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension URL {
|
|
||||||
|
|
||||||
static var agentHomeURL: URL {
|
|
||||||
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
|
|
||||||
}
|
|
||||||
|
|
||||||
static var socketPath: String {
|
|
||||||
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
|
|
||||||
var normalizedPathAndFolder: (String, String) {
|
|
||||||
// All foundation-based normalization methods replace this with the container directly.
|
|
||||||
let processedPath = replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
|
|
||||||
let url = URL(filePath: processedPath)
|
|
||||||
let folder = url.deletingLastPathComponent().path()
|
|
||||||
return (processedPath, folder)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
enum Preview {}
|
enum Preview {}
|
||||||
|
|
||||||
@@ -38,17 +39,10 @@ extension Preview {
|
|||||||
self.init(secrets: new)
|
self.init(secrets: new)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadSecrets() {
|
func reloadSecrets() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,16 +76,10 @@ extension Preview {
|
|||||||
self.init(secrets: new)
|
self.init(secrets: new)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadSecrets() {
|
func reloadSecrets() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,22 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.hardened-process</key>
|
<key>com.apple.security.hardened-process</key>
|
||||||
<true/>
|
<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>
|
<key>com.apple.security.hardened-process.dyld-ro</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.hardened-process.enhanced-security-version</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.hardened-process.platform-restrictions</key>
|
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
|
||||||
<integer>2</integer>
|
<string>2</string>
|
||||||
<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>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ struct IntegrationsView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hiddenToolbar()
|
.toolbarBackgroundVisibility(.hidden, for: .automatic)
|
||||||
.windowBackgroundStyle(.thinMaterial)
|
.containerBackground(.thinMaterial, for: .window)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
selectedInstruction = instructions.gettingStarted
|
selectedInstruction = instructions.gettingStarted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
import Common
|
||||||
|
|
||||||
struct ToolConfigurationView: View {
|
struct ToolConfigurationView: View {
|
||||||
|
|
||||||
@@ -111,10 +113,9 @@ struct ToolConfigurationView: View {
|
|||||||
let writer = OpenSSHPublicKeyWriter()
|
let writer = OpenSSHPublicKeyWriter()
|
||||||
let gitAllowedSignersString = [email.isEmpty ? String(localized: .integrationsConfigureUsingEmailPlaceholder) : email, writer.openSSHString(secret: selectedSecret)]
|
let gitAllowedSignersString = [email.isEmpty ? String(localized: .integrationsConfigureUsingEmailPlaceholder) : email, writer.openSSHString(secret: selectedSecret)]
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
let fileController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
|
||||||
return text
|
return text
|
||||||
.replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: gitAllowedSignersString)
|
.replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: gitAllowedSignersString)
|
||||||
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: fileController.publicKeyPath(for: selectedSecret))
|
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: URL.publicKeyPath(for: selectedSecret, in: URL.publicKeyDirectory))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct WindowBackgroundStyleModifier: ViewModifier {
|
|
||||||
|
|
||||||
let shapeStyle: any ShapeStyle
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(macOS 15.0, *) {
|
|
||||||
content
|
|
||||||
.containerBackground(
|
|
||||||
shapeStyle, for: .window
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
|
|
||||||
func windowBackgroundStyle(_ style: some ShapeStyle) -> some View {
|
|
||||||
modifier(WindowBackgroundStyleModifier(shapeStyle: style))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HiddenToolbarModifier: ViewModifier {
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(macOS 15.0, *) {
|
|
||||||
content
|
|
||||||
.toolbarBackgroundVisibility(.hidden, for: .automatic)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
|
|
||||||
func hiddenToolbar() -> some View {
|
|
||||||
modifier(HiddenToolbarModifier())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SecretKit
|
||||||
|
import Common
|
||||||
|
import CertificateKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CryptoKit
|
||||||
|
struct CertificateDetailView: View {
|
||||||
|
|
||||||
|
let certificate: Certificate
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
CopyableView(
|
||||||
|
title: .certificateDetailKeyIdLabel,
|
||||||
|
image: Image(systemName: "person.text.rectangle"),
|
||||||
|
text: certificate.keyID
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
CopyableView(
|
||||||
|
title: .certificateDetailSerialLabel,
|
||||||
|
image: Image(systemName: "number.circle"),
|
||||||
|
text: certificate.serial.formatted()
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
CopyableView(
|
||||||
|
title: .secretDetailSha256FingerprintLabel,
|
||||||
|
image: Image(systemName: "touchid"),
|
||||||
|
text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.publicKey)
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
CopyableView(
|
||||||
|
title: .secretDetailSha256FingerprintLabel,
|
||||||
|
image: Image(systemName: "touchid"),
|
||||||
|
text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.signingKey)
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
CopyableView(
|
||||||
|
title: .certificateDetailPathLabel,
|
||||||
|
image: Image(systemName: "checkmark.seal.text.page"),
|
||||||
|
text: URL.certificatePath(for: certificate.id, in: URL.certificatesDirectory),
|
||||||
|
showRevealInFinder: true
|
||||||
|
)
|
||||||
|
if let validityRange = certificate.validityRange {
|
||||||
|
let epoch = Date(timeIntervalSince1970: 0)
|
||||||
|
let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max))
|
||||||
|
switch (validityRange.lowerBound, validityRange.upperBound) {
|
||||||
|
case (epoch, end):
|
||||||
|
EmptyView()
|
||||||
|
case (epoch, let otherEnd):
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()])
|
||||||
|
case (let otherStart, end):
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()])
|
||||||
|
default:
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !certificate.principals.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals)
|
||||||
|
}
|
||||||
|
if !certificate.criticalOptions.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
MultilineInfoView(title: .certificateDetailCriticalOptionsLabel, image: Image(systemName: "person.2"), items: certificate.criticalOptions)
|
||||||
|
}
|
||||||
|
if !certificate.extensions.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
MultilineInfoView(title: .certificateDetailExtensionsLabel, image: Image(systemName: "person.2"), items: certificate.extensions)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(minHeight: 200, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import CertificateKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
|
struct CertificateListItemView: View {
|
||||||
|
|
||||||
|
@Environment(\.certificateStore) private var store
|
||||||
|
|
||||||
|
var certificate: Certificate
|
||||||
|
|
||||||
|
@State var isDeleting: Bool = false
|
||||||
|
@State var isRenaming: Bool = false
|
||||||
|
|
||||||
|
var deletedCertificate: (Certificate) -> Void
|
||||||
|
var renamedCertificate: (Certificate) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(value: certificate) {
|
||||||
|
Text(certificate.name)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isRenaming, onDismiss: {
|
||||||
|
renamedCertificate(certificate)
|
||||||
|
}, content: {
|
||||||
|
EditCertificateView(store: store, certificate: certificate)
|
||||||
|
})
|
||||||
|
.showingDeleteConfirmation(isPresented: $isDeleting, certificate, store) { deleted in
|
||||||
|
if deleted {
|
||||||
|
deletedCertificate(certificate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(action: { isRenaming = true }) {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
Text(.secretListEditButton)
|
||||||
|
}
|
||||||
|
Button(action: { isDeleting = true }) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
Text(.secretListDeleteButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import CertificateKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ certificate: Certificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View {
|
||||||
|
modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeleteCertificateConfirmationModifier: ViewModifier {
|
||||||
|
|
||||||
|
var isPresented: Binding<Bool>
|
||||||
|
var certificate: Certificate
|
||||||
|
var store: CertificateStore
|
||||||
|
var dismissalBlock: (Bool) -> ()
|
||||||
|
@State var confirmedSecretName = ""
|
||||||
|
@State private var errorText: String?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.confirmationDialog(
|
||||||
|
String(localized: .deleteConfirmationTitle(name: certificate.name)),
|
||||||
|
isPresented: isPresented,
|
||||||
|
titleVisibility: .visible,
|
||||||
|
actions: {
|
||||||
|
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||||
|
Button(.deleteConfirmationCancelButton, role: .cancel) {
|
||||||
|
dismissalBlock(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
|
||||||
|
.onExitCommand {
|
||||||
|
dismissalBlock(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try store.delete(certificate: certificate)
|
||||||
|
dismissalBlock(true)
|
||||||
|
} catch {
|
||||||
|
errorText = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
|
|||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
.deleteConfirmationTitle(secretName: secret.name),
|
.deleteConfirmationTitle(name: secret.name),
|
||||||
isPresented: isPresented,
|
isPresented: isPresented,
|
||||||
titleVisibility: .visible,
|
titleVisibility: .visible,
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SSHProtocolKit
|
||||||
|
import CertificateKit
|
||||||
|
|
||||||
|
struct EditCertificateView: View {
|
||||||
|
|
||||||
|
let store: CertificateStore
|
||||||
|
let certificate: Certificate
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State var errorText: String?
|
||||||
|
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
init(store: CertificateStore, certificate: Certificate) {
|
||||||
|
self.store = store
|
||||||
|
self.certificate = certificate
|
||||||
|
name = certificate.name
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: .renameCertificateLabel), text: $name, prompt: Text(.renameCertificateNamePlaceholder))
|
||||||
|
} footer: {
|
||||||
|
if let errorText {
|
||||||
|
Text(verbatim: errorText)
|
||||||
|
.errorStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button(.editCancelButton) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button(.editSaveButton, action: rename)
|
||||||
|
.disabled(name.isEmpty)
|
||||||
|
.keyboardShortcut(.return)
|
||||||
|
.primaryButton()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rename() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
var updated = certificate
|
||||||
|
updated.openSSHCertificate.name = name
|
||||||
|
try store.update(certificate: updated)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
errorText = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,60 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import Common
|
||||||
|
import CertificateKit
|
||||||
|
import SSHProtocolKit
|
||||||
|
|
||||||
struct SecretDetailView<SecretType: Secret>: View {
|
struct SecretDetailView<SecretType: Secret>: View {
|
||||||
|
|
||||||
let secret: SecretType
|
let secret: SecretType
|
||||||
|
let certificates: [Certificate]
|
||||||
|
let navigateToCertificate: ((Certificate) -> Void)?
|
||||||
|
|
||||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
|
CopyableView(
|
||||||
|
title: .secretDetailSha256FingerprintLabel,
|
||||||
|
image: Image(systemName: "touchid"),
|
||||||
|
text: keyWriter.openSSHSHA256Fingerprint(secret: secret)
|
||||||
|
)
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
|
CopyableView(
|
||||||
|
title: .secretDetailMd5FingerprintLabel,
|
||||||
|
image: Image(systemName: "touchid"),
|
||||||
|
text: keyWriter.openSSHMD5Fingerprint(secret: secret)
|
||||||
|
)
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString)
|
CopyableView(
|
||||||
|
title: .secretDetailPublicKeyLabel,
|
||||||
|
image: Image(systemName: "key"),
|
||||||
|
text: keyWriter.openSSHString(secret: secret)
|
||||||
|
)
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret), showRevealInFinder: true)
|
CopyableView(
|
||||||
|
title: .secretDetailPublicKeyPathLabel,
|
||||||
|
image: Image(systemName: "lock.doc"),
|
||||||
|
text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory),
|
||||||
|
showRevealInFinder: true
|
||||||
|
)
|
||||||
|
if !certificates.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
MultilineInfoView(title: .secretDetailCertificatePathLabel, image: Image(systemName: "checkmark.seal.text.page")) {
|
||||||
|
ForEach(certificates) { certificate in
|
||||||
|
Text(certificate.name)
|
||||||
|
.multilineItemAction(image: Image(systemName: "chevron.forward")) {
|
||||||
|
navigateToCertificate?(certificate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,10 +64,6 @@ struct SecretDetailView<SecretType: Secret>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var keyString: String {
|
|
||||||
keyWriter.openSSHString(secret: secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//#Preview {
|
//#Preview {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user