Compare commits
55 Commits
update_loc
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f74bd814f | ||
|
|
d9d93574f2 | ||
|
|
15e8ed1ec2 | ||
|
|
1df0c8e96b | ||
|
|
8213a8b451 | ||
|
|
af77fd4a21 | ||
|
|
85d0cab0f5 | ||
|
|
e8cdcdfb7f | ||
|
|
d7f8d5e56b | ||
|
|
3f247d628f | ||
|
|
dae9cead4e | ||
|
|
fe9f8613fa | ||
|
|
5d5ae5bab4 | ||
|
|
f76766a9d5 | ||
|
|
b308b10716 | ||
|
|
0e1e6813a1 | ||
|
|
27bf7c29e4 | ||
|
|
36b6c52979 | ||
|
|
67ec4fee12 | ||
|
|
21fc834fd9 | ||
|
|
726d0580d0 | ||
|
|
4f608ebbc6 | ||
|
|
6e7cf82618 | ||
|
|
96ef91df0c | ||
|
|
aa46d8fa48 | ||
|
|
cf7c6e9fbe | ||
|
|
7c7db56c1e | ||
|
|
cd12e4c828 | ||
|
|
a5b43ea046 | ||
|
|
8c516e128a | ||
|
|
6854c05763 | ||
|
|
5467474d88 | ||
|
|
5c2d039682 | ||
|
|
20e64604d6 | ||
|
|
c5a610d786 | ||
|
|
7d21e3983c | ||
|
|
2c38aaed6f | ||
|
|
63b42bd9df | ||
|
|
cf5ae49ebc | ||
|
|
61705af42f | ||
|
|
558ae15b2d | ||
|
|
902d5c4a1e | ||
|
|
e0c24917f2 | ||
|
|
3d5f0b45bd | ||
|
|
74f4f1c0b1 | ||
|
|
c8cf0db1c5 | ||
|
|
412687467b | ||
|
|
416a7d5f40 | ||
|
|
c4605fb60e | ||
|
|
61eed5987c | ||
|
|
cbef7c6181 | ||
|
|
63a09390b8 | ||
|
|
a4e1ab9eb6 | ||
|
|
147f4d9908 | ||
|
|
ddcb2a36ec |
47
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '26 15 * * 3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }}
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
packages: read
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
# Disable this until CodeQL supports Xcode 26 builds.
|
||||||
|
# - language: swift
|
||||||
|
# build-mode: manual
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
- if: matrix.build-mode == 'manual'
|
||||||
|
name: "Select Xcode"
|
||||||
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
||||||
|
- if: matrix.build-mode == 'manual'
|
||||||
|
name: "Build"
|
||||||
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
27
.github/workflows/nightly.yml
vendored
@@ -3,10 +3,15 @@ name: Nightly
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 8 * * *"
|
- cron: "0 8 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
# runs-on: macOS-latest
|
runs-on: macos-26
|
||||||
runs-on: macos-15
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
attestations: write
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -25,27 +30,29 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
run: |
|
run: |
|
||||||
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig
|
DATE=$(date "+%Y-%m-%d")
|
||||||
|
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_nightly-$DATE/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
||||||
- name: Build
|
- name: Build
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||||
- name: Create ZIPs
|
- name: Create ZIP
|
||||||
run: |
|
run: |
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
|
|
||||||
- name: Notarize
|
- name: Notarize
|
||||||
env:
|
env:
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
||||||
- name: Attest
|
|
||||||
id: attest
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-path: 'Secretive.zip'
|
|
||||||
- name: Upload App to Artifacts
|
- name: Upload App to Artifacts
|
||||||
|
id: upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Secretive.zip
|
name: Secretive.zip
|
||||||
path: Secretive.zip
|
path: Secretive.zip
|
||||||
|
- name: Attest
|
||||||
|
id: attest
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: "Secretive.zip"
|
||||||
|
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
||||||
|
|||||||
39
.github/workflows/release.yml
vendored
@@ -6,8 +6,9 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
# runs-on: macOS-latest
|
permissions:
|
||||||
runs-on: macos-15
|
contents: read
|
||||||
|
runs-on: macos-26
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -23,14 +24,15 @@ jobs:
|
|||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: swift test --build-system swiftbuild --package-path Sources/Packages
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||||
|
# SPM doesn't seem to pick up on the tests currently?
|
||||||
|
# run: swift test --build-system swiftbuild --package-path Sources/Packages
|
||||||
build:
|
build:
|
||||||
# runs-on: macOS-latest
|
|
||||||
runs-on: macos-15
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
attestations: write
|
attestations: write
|
||||||
|
runs-on: macos-26
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
@@ -53,42 +55,37 @@ jobs:
|
|||||||
export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//')
|
export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//')
|
||||||
sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
|
||||||
- name: Build
|
- name: Build
|
||||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||||
- name: Create ZIPs
|
- name: Create ZIP
|
||||||
run: |
|
run: |
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Xcode_Archive.zip
|
|
||||||
- name: Notarize
|
- name: Notarize
|
||||||
env:
|
env:
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
|
||||||
|
- name: Upload App to Artifacts
|
||||||
|
id: upload
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: Secretive.zip
|
||||||
|
path: Secretive.zip
|
||||||
- name: Attest
|
- name: Attest
|
||||||
id: attest
|
id: attest
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-path: 'Secretive.zip, Xcode_Archive.zip'
|
subject-name: "Secretive.zip"
|
||||||
|
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
run: |
|
run: |
|
||||||
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
|
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
|
||||||
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
|
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
|
||||||
gh release create $TAG_NAME -d -F .github/templates/release.md
|
gh release create $TAG_NAME -d -F .github/templates/release.md
|
||||||
gh release upload Secretive.zip
|
gh release upload $TAG_NAME Secretive.zip
|
||||||
gh release upload Xcode_Archive.zip
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG_NAME: ${{ github.ref }}
|
TAG_NAME: ${{ github.ref }}
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
|
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
|
||||||
- name: Upload App to Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Secretive.zip
|
|
||||||
path: Secretive.zip
|
|
||||||
- name: Upload Archive to Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Xcode_Archive.zip
|
|
||||||
path: Xcode_Archive.zip
|
|
||||||
|
|||||||
9
.github/workflows/test.yml
vendored
@@ -3,14 +3,17 @@ name: Test
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
# runs-on: macOS-latest
|
permissions:
|
||||||
runs-on: macos-15
|
contents: read
|
||||||
|
runs-on: macos-26
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
||||||
- name: Test Main Packages
|
- name: Test Main Packages
|
||||||
run: swift test --build-system swiftbuild --package-path Sources/Packages
|
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||||
|
# SPM doesn't seem to pick up on the tests currently?
|
||||||
|
# run: swift test --build-system swiftbuild --package-path Sources/Packages
|
||||||
- name: Test SecretKit Packages
|
- name: Test SecretKit Packages
|
||||||
run: swift test --build-system swiftbuild
|
run: swift test --build-system swiftbuild
|
||||||
|
|||||||
@@ -2,36 +2,35 @@
|
|||||||
|
|
||||||
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
|
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
|
||||||
|
|
||||||
## Getting Started
|
## Crowdin
|
||||||
|
|
||||||
### Download Xcode
|
[Secretive uses Crowdin for localization](https://crowdin.com/project/secretive/). Open the link and select your language to translate!
|
||||||
|
|
||||||
Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/).
|
### Manual Translation
|
||||||
|
|
||||||
### Clone Secretive
|
Crowdin is the easiest way to translate Secretive, but I'm happy to accept Pull Requests directly as well.
|
||||||
|
|
||||||
Clone Secretive using [these instructions from GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository).
|
|
||||||
|
|
||||||
### Open Secretive
|
|
||||||
|
|
||||||
Open [Sources/Secretive.xcodeproj](Sources/Secretive.xcodeproj) in Xcode.
|
|
||||||
|
|
||||||
### Translate
|
|
||||||
|
|
||||||
Navigate to [Secretive/Localizable](Sources/Secretive/Localizable.xcstrings).
|
|
||||||
|
|
||||||
<img src="/.github/readme/localize_sidebar.png" alt="Screenshot of Xcode navigating to the Localizable file" width="300">
|
|
||||||
|
|
||||||
If your language already has an in-progress localization, select it from the list. If it isn't there, hit the "+" button and choose your language from the list.
|
|
||||||
|
|
||||||
<img src="/.github/readme/localize_add.png" alt="Screenshot of Xcode adding a new language" width="600">
|
|
||||||
|
|
||||||
Start translating! You'll see a list of english phrases, and a space to add a translation of your language.
|
|
||||||
|
|
||||||
### Create a Pull Request
|
|
||||||
|
|
||||||
Push your changes and open a pull request.
|
|
||||||
|
|
||||||
### Questions
|
### Questions
|
||||||
|
|
||||||
Please open an issue if you have a question about translating the app. I'm more than happy to clarify any terms that are ambiguous or confusing. Thanks for contributing!
|
Please open an issue if you have a question about translating the app. I'm more than happy to clarify any terms that are ambiguous or confusing. Thanks for contributing!
|
||||||
|
|
||||||
|
### Thank You
|
||||||
|
|
||||||
|
Thanks to all the folks who have contributed localizations so far!
|
||||||
|
|
||||||
|
- @mtardy for the French localization
|
||||||
|
- @GravityRyu for the Chinese localization
|
||||||
|
- @Saeger for the Portuguese (Brazil) localization
|
||||||
|
- @moritzsternemann for the German localization
|
||||||
|
- @RoboRich00A16 for the Italian localization
|
||||||
|
- @akx for the Finnish localization
|
||||||
|
- @mog422 for the Korean localization
|
||||||
|
- @niw for the Japanese localization
|
||||||
|
- @truita for the Catalan localization
|
||||||
|
- @Adimac93 for the Polish localization
|
||||||
|
- @alongotv for the Russian localization
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
A special thanks to [Crowdin](https://crowdin.com) for their [generous support of open source projects](https://crowdin.com/page/open-source-project-setup-request).
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ let package = Package(
|
|||||||
)
|
)
|
||||||
|
|
||||||
var localization: Resource {
|
var localization: Resource {
|
||||||
.process("../../Localizable.xcstrings")
|
.process("../../Resources/Localizable.xcstrings")
|
||||||
}
|
}
|
||||||
|
|
||||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||||
|
|||||||
11
README.md
@@ -1,8 +1,7 @@
|
|||||||
# Secretive [](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) 
|
# Secretive [](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) 
|
||||||
|
|
||||||
|
|
||||||
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
|
Secretive is an app for protecting and managing SSH keys with the Secure Enclave.
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png">
|
||||||
<img src="/.github/readme/app-light.png" alt="Screenshot of Secretive" width="600">
|
<img src="/.github/readme/app-light.png" alt="Screenshot of Secretive" width="600">
|
||||||
@@ -62,3 +61,11 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
Secretive's security policy is detailed in [SECURITY.md](SECURITY.md). To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
|
Secretive's security policy is detailed in [SECURITY.md](SECURITY.md). To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
### sekey
|
||||||
|
Secretive was inspired by the [sekey project](https://github.com/sekey/sekey).
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
Secretive is localized to many languages by a generous team of volunteers. To learn more, see [LOCALIZING.md](LOCALIZING.md). Secretive's localization workflow is generously provided by [Crowdin](https://crowdin.com).
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -13,12 +13,24 @@
|
|||||||
},
|
},
|
||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
{
|
{
|
||||||
"enabled" : false,
|
|
||||||
"parallelizable" : true,
|
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Secretive.xcodeproj",
|
"containerPath" : "container:Packages",
|
||||||
"identifier" : "50617D9323FCE48E0099B055",
|
"identifier" : "BriefTests",
|
||||||
"name" : "SecretiveTests"
|
"name" : "BriefTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:Packages",
|
||||||
|
"identifier" : "SecretKitTests",
|
||||||
|
"name" : "SecretKitTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:Packages",
|
||||||
|
"identifier" : "SecretAgentKitTests",
|
||||||
|
"name" : "SecretAgentKitTests"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ let package = Package(
|
|||||||
targets: ["SmartCardSecretKit"]),
|
targets: ["SmartCardSecretKit"]),
|
||||||
.library(
|
.library(
|
||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
targets: ["SecretAgentKit"]),
|
targets: ["SecretAgentKit", "XPCWrappers"]),
|
||||||
.library(
|
|
||||||
name: "SecretAgentKitHeaders",
|
|
||||||
targets: ["SecretAgentKitHeaders"]),
|
|
||||||
.library(
|
.library(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
targets: ["Brief"]),
|
targets: ["Brief"]),
|
||||||
|
.library(
|
||||||
|
name: "XPCWrappers",
|
||||||
|
targets: ["XPCWrappers"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
@@ -57,20 +57,17 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
|
dependencies: ["SecretKit"],
|
||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
.systemLibrary(
|
|
||||||
name: "SecretAgentKitHeaders",
|
|
||||||
),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SecretAgentKitTests",
|
name: "SecretAgentKitTests",
|
||||||
dependencies: ["SecretAgentKit"],
|
dependencies: ["SecretAgentKit"],
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
dependencies: [],
|
dependencies: ["XPCWrappers"],
|
||||||
resources: [localization],
|
resources: [localization],
|
||||||
swiftSettings: swiftSettings,
|
swiftSettings: swiftSettings,
|
||||||
),
|
),
|
||||||
@@ -78,16 +75,21 @@ let package = Package(
|
|||||||
name: "BriefTests",
|
name: "BriefTests",
|
||||||
dependencies: ["Brief"],
|
dependencies: ["Brief"],
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "XPCWrappers",
|
||||||
|
swiftSettings: swiftSettings,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
var localization: Resource {
|
var localization: Resource {
|
||||||
.process("../../Localizable.xcstrings")
|
.process("../../Resources/Localizable.xcstrings")
|
||||||
}
|
}
|
||||||
|
|
||||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||||
[
|
[
|
||||||
.swiftLanguageMode(.v6),
|
.swiftLanguageMode(.v6),
|
||||||
.treatAllWarnings(as: .error),
|
.treatAllWarnings(as: .error),
|
||||||
|
.strictMemorySafety()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
25085
Sources/Packages/Resources/Localizable.xcstrings
Normal file
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
/// A release is a representation of a downloadable update.
|
/// A release is a representation of a downloadable update.
|
||||||
public struct Release: Codable, Sendable {
|
public struct Release: Codable, Sendable, Hashable {
|
||||||
|
|
||||||
/// The user-facing name of the release. Typically "Secretive 1.2.3"
|
/// The user-facing name of the release. Typically "Secretive 1.2.3"
|
||||||
public let name: String
|
public let name: String
|
||||||
@@ -15,6 +16,8 @@ public struct Release: Codable, Sendable {
|
|||||||
/// A user-facing description of the contents of the update.
|
/// A user-facing description of the contents of the update.
|
||||||
public let body: String
|
public let body: String
|
||||||
|
|
||||||
|
public let attributedBody: AttributedString
|
||||||
|
|
||||||
/// Initializes a Release.
|
/// Initializes a Release.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: The user-facing name of the release.
|
/// - name: The user-facing name of the release.
|
||||||
@@ -26,6 +29,56 @@ public struct Release: Codable, Sendable {
|
|||||||
self.prerelease = prerelease
|
self.prerelease = prerelease
|
||||||
self.html_url = html_url
|
self.html_url = html_url
|
||||||
self.body = body
|
self.body = body
|
||||||
|
self.attributedBody = AttributedString(_markdown: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ release: GitHubRelease) {
|
||||||
|
self.name = release.name
|
||||||
|
self.prerelease = release.prerelease
|
||||||
|
self.html_url = release.html_url
|
||||||
|
self.body = release.body
|
||||||
|
self.attributedBody = AttributedString(_markdown: release.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GitHubRelease: Codable, Sendable {
|
||||||
|
let name: String
|
||||||
|
let prerelease: Bool
|
||||||
|
let html_url: URL
|
||||||
|
let body: String
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate extension AttributedString {
|
||||||
|
|
||||||
|
init(_markdown markdown: String) {
|
||||||
|
let split = markdown.split(whereSeparator: \.isNewline)
|
||||||
|
let lines = split
|
||||||
|
.compactMap {
|
||||||
|
try? AttributedString(markdown: String($0), options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full))
|
||||||
|
}
|
||||||
|
.map { (string: AttributedString) in
|
||||||
|
guard case let .header(level) = string.runs.first?.presentationIntent?.components.first?.kind else { return string }
|
||||||
|
return AttributedString("\n") + string
|
||||||
|
.transformingAttributes(\.font) { font in
|
||||||
|
font.value = switch level {
|
||||||
|
case 2: .headline.bold()
|
||||||
|
case 3: .headline
|
||||||
|
default: .subheadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transformingAttributes(\.underlineStyle) { underline in
|
||||||
|
underline.value = switch level {
|
||||||
|
case 2: .single
|
||||||
|
default: .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ AttributedString("\n")
|
||||||
|
}
|
||||||
|
self = lines.reduce(into: AttributedString()) { partialResult, next in
|
||||||
|
partialResult.append(next)
|
||||||
|
partialResult.append(AttributedString("\n"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ public struct SemVer: Sendable {
|
|||||||
|
|
||||||
/// The SemVer broken into an array of integers.
|
/// The SemVer broken into an array of integers.
|
||||||
let versionNumbers: [Int]
|
let versionNumbers: [Int]
|
||||||
|
public let previewDescription: String?
|
||||||
|
|
||||||
|
public var isTestBuild: Bool {
|
||||||
|
versionNumbers == [0, 0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
/// Initializes a SemVer from a string representation.
|
/// Initializes a SemVer from a string representation.
|
||||||
/// - Parameter version: A string representation of the SemVer, formatted as "major.minor.patch".
|
/// - Parameter version: A string representation of the SemVer, formatted as "major.minor.patch".
|
||||||
public init(_ version: String) {
|
public init(_ version: String) {
|
||||||
// Betas have the format 1.2.3_beta1
|
// Betas have the format 1.2.3_beta1
|
||||||
let strippedBeta = version.split(separator: "_").first!
|
// Nightlies have the format 0.0.0_nightly-2025-09-03
|
||||||
|
let splitFull = version.split(separator: "_")
|
||||||
|
let strippedBeta = splitFull.first!
|
||||||
|
previewDescription = splitFull.count > 1 ? String(splitFull[1]) : nil
|
||||||
var split = strippedBeta.split(separator: ".").compactMap { Int($0) }
|
var split = strippedBeta.split(separator: ".").compactMap { Int($0) }
|
||||||
while split.count < 3 {
|
while split.count < 3 {
|
||||||
split.append(0)
|
split.append(0)
|
||||||
@@ -22,6 +30,7 @@ public struct SemVer: Sendable {
|
|||||||
/// - Parameter version: An `OperatingSystemVersion` representation of the SemVer.
|
/// - Parameter version: An `OperatingSystemVersion` representation of the SemVer.
|
||||||
public init(_ version: OperatingSystemVersion) {
|
public init(_ version: OperatingSystemVersion) {
|
||||||
versionNumbers = [version.majorVersion, version.minorVersion, version.patchVersion]
|
versionNumbers = [version.majorVersion, version.minorVersion, version.patchVersion]
|
||||||
|
previewDescription = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import XPCWrappers
|
||||||
|
|
||||||
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
|
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
|
||||||
@Observable public final class Updater: UpdaterProtocol, Sendable {
|
@Observable public final class Updater: UpdaterProtocol, Sendable {
|
||||||
@@ -13,12 +14,11 @@ import Observation
|
|||||||
state.update
|
state.update
|
||||||
}
|
}
|
||||||
|
|
||||||
public let testBuild: Bool
|
/// The current version of the app that is running.
|
||||||
|
public let currentVersion: SemVer
|
||||||
|
|
||||||
/// The current OS version.
|
/// The current OS version.
|
||||||
private let osVersion: SemVer
|
private let osVersion: SemVer
|
||||||
/// The current version of the app that is running.
|
|
||||||
private let currentVersion: SemVer
|
|
||||||
|
|
||||||
/// Initializes an Updater.
|
/// Initializes an Updater.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -34,28 +34,25 @@ import Observation
|
|||||||
) {
|
) {
|
||||||
self.osVersion = osVersion
|
self.osVersion = osVersion
|
||||||
self.currentVersion = currentVersion
|
self.currentVersion = currentVersion
|
||||||
testBuild = currentVersion == SemVer("0.0.0")
|
|
||||||
if checkOnLaunch {
|
|
||||||
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
|
|
||||||
Task {
|
|
||||||
await checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Task {
|
Task {
|
||||||
|
if checkOnLaunch {
|
||||||
|
try await checkForUpdates()
|
||||||
|
}
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
|
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
|
||||||
await checkForUpdates()
|
try await checkForUpdates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manually trigger an update check.
|
/// Manually trigger an update check.
|
||||||
public func checkForUpdates() async {
|
public func checkForUpdates() async throws {
|
||||||
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL) else { return }
|
let session = try await XPCTypedSession<[Release], Never>(serviceName: "com.maxgoedjen.Secretive.SecretiveUpdater")
|
||||||
guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
|
await evaluate(releases: try await session.send())
|
||||||
await evaluate(releases: releases)
|
session.complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
|
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
|
||||||
/// - Parameter release: The release to ignore.
|
/// - Parameter release: The release to ignore.
|
||||||
public func ignore(release: Release) async {
|
public func ignore(release: Release) async {
|
||||||
@@ -102,11 +99,3 @@ extension Updater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Updater {
|
|
||||||
|
|
||||||
enum Constants {
|
|
||||||
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")!
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ public protocol UpdaterProtocol: Observable, Sendable {
|
|||||||
|
|
||||||
/// The latest update
|
/// The latest update
|
||||||
@MainActor var update: Release? { get }
|
@MainActor var update: Release? { get }
|
||||||
/// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
|
|
||||||
var testBuild: Bool { get }
|
var currentVersion: SemVer { get }
|
||||||
|
|
||||||
func ignore(release: Release) async
|
func ignore(release: Release) async
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -31,46 +31,29 @@ public final class Agent: Sendable {
|
|||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
/// Handles an incoming request.
|
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
|
||||||
/// - Parameters:
|
|
||||||
/// - data: The data to handle.
|
|
||||||
/// - provenance: The origin of the request.
|
|
||||||
/// - Returns: A response data payload.
|
|
||||||
public func handle(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
|
||||||
logger.debug("Agent handling new data")
|
|
||||||
guard data.count > 4 else {
|
|
||||||
throw InvalidDataProvidedError()
|
|
||||||
}
|
|
||||||
let requestTypeInt = data[4]
|
|
||||||
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
|
||||||
return SSHAgent.ResponseType.agentFailure.data.lengthAndData
|
|
||||||
}
|
|
||||||
logger.debug("Agent handling request of type \(requestType.debugDescription)")
|
|
||||||
let subData = Data(data[5...])
|
|
||||||
let response = await handle(requestType: requestType, data: subData, provenance: provenance)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handle(requestType: SSHAgent.RequestType, data: Data, provenance: SigningRequestProvenance) async -> Data {
|
|
||||||
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
||||||
await reloadSecretsIfNeccessary()
|
await reloadSecretsIfNeccessary()
|
||||||
var response = Data()
|
var response = Data()
|
||||||
do {
|
do {
|
||||||
switch requestType {
|
switch request {
|
||||||
case .requestIdentities:
|
case .requestIdentities:
|
||||||
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
response.append(SSHAgent.Response.agentIdentitiesAnswer.data)
|
||||||
response.append(await identities())
|
response.append(await identities())
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
|
||||||
case .signRequest:
|
case .signRequest(let context):
|
||||||
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
response.append(SSHAgent.Response.agentSignResponse.data)
|
||||||
response.append(try await sign(data: data, provenance: provenance))
|
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
|
||||||
|
case .unknown(let value):
|
||||||
|
logger.error("Agent received unknown request of type \(value).")
|
||||||
|
default:
|
||||||
|
logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.")
|
||||||
|
throw UnhandledRequestError()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
response.removeAll()
|
response = SSHAgent.Response.agentFailure.data
|
||||||
response.append(SSHAgent.ResponseType.agentFailure.data)
|
logger.debug("Agent returned \(SSHAgent.Response.agentFailure.debugDescription)")
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
|
||||||
}
|
}
|
||||||
return response.lengthAndData
|
return response.lengthAndData
|
||||||
}
|
}
|
||||||
@@ -101,7 +84,7 @@ extension Agent {
|
|||||||
}
|
}
|
||||||
logger.log("Agent enumerated \(count) identities")
|
logger.log("Agent enumerated \(count) identities")
|
||||||
var countBigEndian = UInt32(count).bigEndian
|
var countBigEndian = UInt32(count).bigEndian
|
||||||
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
|
let countData = unsafe Data(bytes: &countBigEndian, count: MemoryLayout<UInt32>.size)
|
||||||
return countData + keyData
|
return countData + keyData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,27 +93,16 @@ extension Agent {
|
|||||||
/// - data: The data to sign.
|
/// - data: The data to sign.
|
||||||
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
|
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
|
||||||
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
||||||
func sign(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
let reader = OpenSSHReader(data: data)
|
guard let (secret, store) = await secret(matching: keyBlob) else {
|
||||||
let payloadHash = reader.readNextChunk()
|
let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined()
|
||||||
let hash: Data
|
logger.debug("Agent did not have a key matching \(keyBlobHex)")
|
||||||
|
|
||||||
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
|
||||||
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
|
||||||
hash = certificatePublicKey
|
|
||||||
} else {
|
|
||||||
hash = payloadHash
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let (secret, store) = await secret(matching: hash) else {
|
|
||||||
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
|
||||||
throw NoMatchingKeyError()
|
throw NoMatchingKeyError()
|
||||||
}
|
}
|
||||||
|
|
||||||
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
||||||
|
|
||||||
let dataToSign = reader.readNextChunk()
|
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
|
||||||
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
|
||||||
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||||
|
|
||||||
try await witness?.witness(accessTo: secret, from: store, by: provenance)
|
try await witness?.witness(accessTo: secret, from: store, by: provenance)
|
||||||
@@ -169,16 +141,16 @@ extension Agent {
|
|||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
struct InvalidDataProvidedError: Error {}
|
|
||||||
struct NoMatchingKeyError: Error {}
|
struct NoMatchingKeyError: Error {}
|
||||||
|
struct UnhandledRequestError: Error {}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SSHAgent.ResponseType {
|
extension SSHAgent.Response {
|
||||||
|
|
||||||
var data: Data {
|
var data: Data {
|
||||||
var raw = self.rawValue
|
var raw = self.rawValue
|
||||||
return Data(bytes: &raw, count: UInt8.bitWidth/8)
|
return unsafe Data(bytes: &raw, count: MemoryLayout<UInt8>.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol abstraction of the reading aspects of FileHandle.
|
extension FileHandle {
|
||||||
public protocol FileHandleReader: Sendable {
|
|
||||||
|
|
||||||
/// Gets data that is available for reading.
|
|
||||||
var availableData: Data { get }
|
|
||||||
/// A file descriptor of the handle.
|
|
||||||
var fileDescriptor: Int32 { get }
|
|
||||||
/// The process ID of the process coonnected to the other end of the FileHandle.
|
|
||||||
var pidOfConnectedProcess: Int32 { get }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol abstraction of the writing aspects of FileHandle.
|
|
||||||
public protocol FileHandleWriter: Sendable {
|
|
||||||
|
|
||||||
/// Writes data to the handle.
|
|
||||||
func write(_ data: Data)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FileHandle: FileHandleReader, FileHandleWriter {
|
|
||||||
|
|
||||||
public var pidOfConnectedProcess: Int32 {
|
public var pidOfConnectedProcess: Int32 {
|
||||||
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1)
|
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: MemoryLayout<Int32>.size, alignment: 1)
|
||||||
var len = socklen_t(MemoryLayout<Int32>.size)
|
var len = socklen_t(MemoryLayout<Int32>.size)
|
||||||
getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
|
unsafe getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
|
||||||
return pidPointer.load(as: Int32.self)
|
return unsafe pidPointer.load(as: Int32.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
/// Manages storage and lookup for OpenSSH certificates.
|
/// Manages storage and lookup for OpenSSH certificates.
|
||||||
public actor OpenSSHCertificateHandler: Sendable {
|
public actor OpenSSHCertificateHandler: Sendable {
|
||||||
|
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||||
private let writer = OpenSSHPublicKeyWriter()
|
private let writer = OpenSSHPublicKeyWriter()
|
||||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
||||||
@@ -25,29 +26,6 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
|
|
||||||
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
|
||||||
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
|
|
||||||
public func publicKeyHash(from hash: Data) -> Data? {
|
|
||||||
let reader = OpenSSHReader(data: hash)
|
|
||||||
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
|
|
||||||
switch certType {
|
|
||||||
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
||||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
|
||||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
|
|
||||||
_ = reader.readNextChunk() // nonce
|
|
||||||
let curveIdentifier = reader.readNextChunk()
|
|
||||||
let publicKey = reader.readNextChunk()
|
|
||||||
|
|
||||||
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
|
||||||
return openSSHIdentifier.lengthAndData +
|
|
||||||
curveIdentifier.lengthAndData +
|
|
||||||
publicKey.lengthAndData
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||||
/// - Parameter secret: The secret to search for a certificate with
|
/// - Parameter secret: The secret to search for a certificate with
|
||||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
||||||
47
Sources/Packages/Sources/SecretAgentKit/OpenSSHReader.swift
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Reads OpenSSH protocol data.
|
||||||
|
final class OpenSSHReader {
|
||||||
|
|
||||||
|
var remaining: Data
|
||||||
|
|
||||||
|
/// Initialize the reader with an OpenSSH data payload.
|
||||||
|
/// - Parameter data: The data to read.
|
||||||
|
init(data: Data) {
|
||||||
|
remaining = Data(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the next chunk of data from the playload.
|
||||||
|
/// - Returns: The next chunk of data.
|
||||||
|
func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
|
||||||
|
let littleEndianLength = try readNextBytes(as: UInt32.self)
|
||||||
|
let length = convertEndianness ? Int(littleEndianLength.bigEndian) : Int(littleEndianLength)
|
||||||
|
guard remaining.count >= length else { throw .beyondBounds }
|
||||||
|
let dataRange = 0..<length
|
||||||
|
let ret = Data(remaining[dataRange])
|
||||||
|
remaining.removeSubrange(dataRange)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNextBytes<T>(as: T.Type) throws(OpenSSHReaderError) -> T {
|
||||||
|
let size = MemoryLayout<T>.size
|
||||||
|
guard remaining.count >= size else { throw .beyondBounds }
|
||||||
|
let lengthRange = 0..<size
|
||||||
|
let lengthChunk = remaining[lengthRange]
|
||||||
|
remaining.removeSubrange(lengthRange)
|
||||||
|
return unsafe lengthChunk.bytes.unsafeLoad(as: T.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
|
||||||
|
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
|
||||||
|
OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenSSHReaderError: Error, Codable {
|
||||||
|
case beyondBounds
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
public protocol SSHAgentInputParserProtocol {
|
||||||
|
|
||||||
|
func parse(data: Data) async throws -> SSHAgent.Request
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
|
||||||
|
logger.debug("Parsing new data")
|
||||||
|
guard data.count > 4 else {
|
||||||
|
throw .invalidData
|
||||||
|
}
|
||||||
|
let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4
|
||||||
|
let rawRequestInt = data[4]
|
||||||
|
let remainingDataRange = 5..<min(Int(specifiedLength), data.count)
|
||||||
|
lazy var body: Data = { Data(data[remainingDataRange]) }()
|
||||||
|
switch rawRequestInt {
|
||||||
|
case SSHAgent.Request.requestIdentities.protocolID:
|
||||||
|
return .requestIdentities
|
||||||
|
case SSHAgent.Request.signRequest(.empty).protocolID:
|
||||||
|
do {
|
||||||
|
return .signRequest(try signatureRequestContext(from: body))
|
||||||
|
} catch {
|
||||||
|
throw .openSSHReader(error)
|
||||||
|
}
|
||||||
|
case SSHAgent.Request.addIdentity.protocolID:
|
||||||
|
return .addIdentity
|
||||||
|
case SSHAgent.Request.removeIdentity.protocolID:
|
||||||
|
return .removeIdentity
|
||||||
|
case SSHAgent.Request.removeAllIdentities.protocolID:
|
||||||
|
return .removeAllIdentities
|
||||||
|
case SSHAgent.Request.addIDConstrained.protocolID:
|
||||||
|
return .addIDConstrained
|
||||||
|
case SSHAgent.Request.addSmartcardKey.protocolID:
|
||||||
|
return .addSmartcardKey
|
||||||
|
case SSHAgent.Request.removeSmartcardKey.protocolID:
|
||||||
|
return .removeSmartcardKey
|
||||||
|
case SSHAgent.Request.lock.protocolID:
|
||||||
|
return .lock
|
||||||
|
case SSHAgent.Request.unlock.protocolID:
|
||||||
|
return .unlock
|
||||||
|
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
|
||||||
|
return .addSmartcardKeyConstrained
|
||||||
|
case SSHAgent.Request.protocolExtension.protocolID:
|
||||||
|
return .protocolExtension
|
||||||
|
default:
|
||||||
|
return .unknown(rawRequestInt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SSHAgentInputParser {
|
||||||
|
|
||||||
|
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
|
||||||
|
let reader = OpenSSHReader(data: data)
|
||||||
|
let rawKeyBlob = try reader.readNextChunk()
|
||||||
|
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
|
||||||
|
let dataToSign = try reader.readNextChunk()
|
||||||
|
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign)
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificatePublicKeyBlob(from hash: Data) -> Data? {
|
||||||
|
let reader = OpenSSHReader(data: hash)
|
||||||
|
do {
|
||||||
|
let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self)
|
||||||
|
switch certType {
|
||||||
|
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||||
|
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||||
|
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
|
||||||
|
_ = try reader.readNextChunk() // nonce
|
||||||
|
let curveIdentifier = try reader.readNextChunk()
|
||||||
|
let publicKey = try reader.readNextChunk()
|
||||||
|
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||||
|
return openSSHIdentifier.lengthAndData +
|
||||||
|
curveIdentifier.lengthAndData +
|
||||||
|
publicKey.lengthAndData
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension SSHAgentInputParser {
|
||||||
|
|
||||||
|
public enum AgentParsingError: Error, Codable {
|
||||||
|
case unknownRequest
|
||||||
|
case unhandledRequest
|
||||||
|
case invalidData
|
||||||
|
case openSSHReader(OpenSSHReaderError)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,39 +6,92 @@ public enum SSHAgent {}
|
|||||||
extension SSHAgent {
|
extension SSHAgent {
|
||||||
|
|
||||||
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
public enum RequestType: UInt8, CustomDebugStringConvertible {
|
public enum Request: CustomDebugStringConvertible, Codable, Sendable {
|
||||||
|
|
||||||
case requestIdentities = 11
|
case requestIdentities
|
||||||
case signRequest = 13
|
case signRequest(SignatureRequestContext)
|
||||||
|
case addIdentity
|
||||||
|
case removeIdentity
|
||||||
|
case removeAllIdentities
|
||||||
|
case addIDConstrained
|
||||||
|
case addSmartcardKey
|
||||||
|
case removeSmartcardKey
|
||||||
|
case lock
|
||||||
|
case unlock
|
||||||
|
case addSmartcardKeyConstrained
|
||||||
|
case protocolExtension
|
||||||
|
case unknown(UInt8)
|
||||||
|
|
||||||
|
public var protocolID: UInt8 {
|
||||||
|
switch self {
|
||||||
|
case .requestIdentities: 11
|
||||||
|
case .signRequest: 13
|
||||||
|
case .addIdentity: 17
|
||||||
|
case .removeIdentity: 18
|
||||||
|
case .removeAllIdentities: 19
|
||||||
|
case .addIDConstrained: 25
|
||||||
|
case .addSmartcardKey: 20
|
||||||
|
case .removeSmartcardKey: 21
|
||||||
|
case .lock: 22
|
||||||
|
case .unlock: 23
|
||||||
|
case .addSmartcardKeyConstrained: 26
|
||||||
|
case .protocolExtension: 27
|
||||||
|
case .unknown(let value): value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public var debugDescription: String {
|
public var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .requestIdentities:
|
case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES"
|
||||||
return "RequestIdentities"
|
case .signRequest: "SSH_AGENTC_SIGN_REQUEST"
|
||||||
case .signRequest:
|
case .addIdentity: "SSH_AGENTC_ADD_IDENTITY"
|
||||||
return "SignRequest"
|
case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY"
|
||||||
|
case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES"
|
||||||
|
case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED"
|
||||||
|
case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY"
|
||||||
|
case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY"
|
||||||
|
case .lock: "SSH_AGENTC_LOCK"
|
||||||
|
case .unlock: "SSH_AGENTC_UNLOCK"
|
||||||
|
case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED"
|
||||||
|
case .protocolExtension: "SSH_AGENTC_EXTENSION"
|
||||||
|
case .unknown: "UNKNOWN_MESSAGE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SignatureRequestContext: Sendable, Codable {
|
||||||
|
public let keyBlob: Data
|
||||||
|
public let dataToSign: Data
|
||||||
|
|
||||||
|
public init(keyBlob: Data, dataToSign: Data) {
|
||||||
|
self.keyBlob = keyBlob
|
||||||
|
self.dataToSign = dataToSign
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var empty: SignatureRequestContext {
|
||||||
|
SignatureRequestContext(keyBlob: Data(), dataToSign: Data())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
public enum ResponseType: UInt8, CustomDebugStringConvertible {
|
public enum Response: UInt8, CustomDebugStringConvertible {
|
||||||
|
|
||||||
case agentFailure = 5
|
case agentFailure = 5
|
||||||
case agentSuccess = 6
|
case agentSuccess = 6
|
||||||
case agentIdentitiesAnswer = 12
|
case agentIdentitiesAnswer = 12
|
||||||
case agentSignResponse = 14
|
case agentSignResponse = 14
|
||||||
|
case agentExtensionFailure = 28
|
||||||
|
case agentExtensionResponse = 29
|
||||||
|
|
||||||
public var debugDescription: String {
|
public var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .agentFailure:
|
case .agentFailure: "SSH_AGENT_FAILURE"
|
||||||
return "AgentFailure"
|
case .agentSuccess: "SSH_AGENT_SUCCESS"
|
||||||
case .agentSuccess:
|
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
|
||||||
return "AgentSuccess"
|
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
|
||||||
case .agentIdentitiesAnswer:
|
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
|
||||||
return "AgentIdentitiesAnswer"
|
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
|
||||||
case .agentSignResponse:
|
|
||||||
return "AgentSignResponse"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Foundation
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Security
|
import Security
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import SecretAgentKitHeaders
|
|
||||||
|
|
||||||
/// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects.
|
/// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects.
|
||||||
struct SigningRequestTracer {
|
struct SigningRequestTracer {
|
||||||
@@ -10,12 +9,11 @@ struct SigningRequestTracer {
|
|||||||
|
|
||||||
extension SigningRequestTracer {
|
extension SigningRequestTracer {
|
||||||
|
|
||||||
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandleReader``.
|
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandle``.
|
||||||
/// - Parameter fileHandleReader: The reader involved in processing the request.
|
/// - Parameter fileHandle: The reader involved in processing the request.
|
||||||
/// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request.
|
/// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request.
|
||||||
func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance {
|
func provenance(from fileHandle: FileHandle) -> SigningRequestProvenance {
|
||||||
let firstInfo = process(from: fileHandleReader.pidOfConnectedProcess)
|
let firstInfo = process(from: fileHandle.pidOfConnectedProcess)
|
||||||
|
|
||||||
var provenance = SigningRequestProvenance(root: firstInfo)
|
var provenance = SigningRequestProvenance(root: firstInfo)
|
||||||
while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil {
|
while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil {
|
||||||
provenance.chain.append(process(from: provenance.origin.parentPID!))
|
provenance.chain.append(process(from: provenance.origin.parentPID!))
|
||||||
@@ -27,11 +25,11 @@ extension SigningRequestTracer {
|
|||||||
/// - Parameter pid: The process ID to look up.
|
/// - Parameter pid: The process ID to look up.
|
||||||
/// - Returns: a `kinfo_proc` struct describing the process ID.
|
/// - Returns: a `kinfo_proc` struct describing the process ID.
|
||||||
func pidAndNameInfo(from pid: Int32) -> kinfo_proc {
|
func pidAndNameInfo(from pid: Int32) -> kinfo_proc {
|
||||||
var len = MemoryLayout<kinfo_proc>.size
|
var len = unsafe MemoryLayout<kinfo_proc>.size
|
||||||
let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1)
|
let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1)
|
||||||
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
||||||
sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
|
unsafe sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
|
||||||
return infoPointer.load(as: kinfo_proc.self)
|
return unsafe infoPointer.load(as: kinfo_proc.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID.
|
/// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID.
|
||||||
@@ -39,18 +37,18 @@ extension SigningRequestTracer {
|
|||||||
/// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process.
|
/// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process.
|
||||||
func process(from pid: Int32) -> SigningRequestProvenance.Process {
|
func process(from pid: Int32) -> SigningRequestProvenance.Process {
|
||||||
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
|
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
|
||||||
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
let ppid = unsafe pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
||||||
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
||||||
String(cString: pointer)
|
unsafe String(cString: pointer)
|
||||||
}
|
}
|
||||||
|
|
||||||
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
||||||
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
||||||
let path = String(cString: pathPointer)
|
let path = unsafe String(cString: pathPointer)
|
||||||
var secCode: Unmanaged<SecCode>!
|
var secCode: Unmanaged<SecCode>!
|
||||||
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
|
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
|
||||||
SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
|
unsafe SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
|
||||||
let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
|
let valid = unsafe SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
|
||||||
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
|
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,3 +79,11 @@ extension SigningRequestTracer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// from libproc.h
|
||||||
|
@_silgen_name("proc_pidpath")
|
||||||
|
@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32
|
||||||
|
|
||||||
|
//// from SecTask.h
|
||||||
|
@_silgen_name("SecCodeCreateWithPID")
|
||||||
|
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus
|
||||||
|
|||||||
@@ -133,22 +133,21 @@ private extension SocketPort {
|
|||||||
|
|
||||||
convenience init(path: String) {
|
convenience init(path: String) {
|
||||||
var addr = sockaddr_un()
|
var addr = sockaddr_un()
|
||||||
addr.sun_family = sa_family_t(AF_UNIX)
|
|
||||||
|
|
||||||
var len: Int = 0
|
let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
||||||
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
unsafe path.withCString { cstring in
|
||||||
path.withCString { cstring in
|
let len = unsafe strlen(cstring)
|
||||||
len = strlen(cstring)
|
unsafe strncpy(pointer, cstring, len)
|
||||||
strncpy(pointer, cstring, len)
|
return len
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addr.sun_len = UInt8(len+2)
|
// This doesn't seem to be _strictly_ neccessary with SocketPort.
|
||||||
|
// but just for good form.
|
||||||
var data: Data!
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
withUnsafePointer(to: &addr) { pointer in
|
// This mirrors the SUN_LEN macro format.
|
||||||
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
|
addr.sun_len = UInt8(MemoryLayout<sockaddr_un>.size - MemoryLayout.size(ofValue: addr.sun_path) + length)
|
||||||
}
|
|
||||||
|
|
||||||
|
let data = unsafe Data(bytes: &addr, count: MemoryLayout<sockaddr_un>.size)
|
||||||
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <Security/Security.h>
|
|
||||||
|
|
||||||
|
|
||||||
// Forward declarations
|
|
||||||
|
|
||||||
// from libproc.h
|
|
||||||
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
|
|
||||||
|
|
||||||
// from SecTask.h
|
|
||||||
OSStatus SecCodeCreateWithPID(int32_t, SecCSFlags, SecCodeRef *);
|
|
||||||
|
|
||||||
//! Project version number for SecretAgentKit.
|
|
||||||
FOUNDATION_EXPORT double SecretAgentKitVersionNumber;
|
|
||||||
|
|
||||||
//! Project version string for SecretAgentKit.
|
|
||||||
FOUNDATION_EXPORT const unsigned char SecretAgentKitVersionString[];
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
module SecretAgentKitHeaders [system] {
|
|
||||||
header "include/SecretAgentKit.h"
|
|
||||||
export *
|
|
||||||
}
|
|
||||||
@@ -64,7 +64,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
|
|||||||
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
|
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
|
||||||
private let _delete: @Sendable (AnySecret) async throws -> Void
|
private let _delete: @Sendable (AnySecret) async throws -> Void
|
||||||
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
||||||
private let _supportedKeyTypes: @Sendable () -> [KeyType]
|
private let _supportedKeyTypes: @Sendable () -> KeyAvailability
|
||||||
|
|
||||||
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
||||||
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
|
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
|
||||||
@@ -87,7 +87,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
|
|||||||
try await _update(secret, name, attributes)
|
try await _update(secret, name, attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var supportedKeyTypes: [KeyType] {
|
public var supportedKeyTypes: KeyAvailability {
|
||||||
_supportedKeyTypes()
|
_supportedKeyTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ public struct KeychainError: Error {
|
|||||||
/// A signing-related error.
|
/// A signing-related error.
|
||||||
public struct SigningError: Error {
|
public struct SigningError: Error {
|
||||||
/// The underlying error reported by the API, if one was returned.
|
/// The underlying error reported by the API, if one was returned.
|
||||||
public let error: SecurityError?
|
public let error: CFError?
|
||||||
|
|
||||||
/// Initializes a SigningError with an optional SecurityError.
|
/// Initializes a SigningError with an optional SecurityError.
|
||||||
/// - Parameter statusCode: The SecurityError, if one is applicable.
|
/// - Parameter statusCode: The SecurityError, if one is applicable.
|
||||||
public init(error: SecurityError?) {
|
public init(error: SecurityError?) {
|
||||||
self.error = error
|
self.error = unsafe error?.takeRetainedValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ extension Data {
|
|||||||
package var lengthAndData: Data {
|
package var lengthAndData: Data {
|
||||||
let rawLength = UInt32(count)
|
let rawLength = UInt32(count)
|
||||||
var endian = rawLength.bigEndian
|
var endian = rawLength.bigEndian
|
||||||
return Data(bytes: &endian, count: UInt32.bitWidth/8) + self
|
return unsafe Data(bytes: &endian, count: MemoryLayout<UInt32>.size) + self
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ extension OpenSSHPublicKeyWriter {
|
|||||||
|
|
||||||
extension OpenSSHPublicKeyWriter {
|
extension OpenSSHPublicKeyWriter {
|
||||||
|
|
||||||
public func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
|
func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
|
||||||
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
|
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
|
||||||
// Keychain stores it as a thin ASN.1 wrapper with this format:
|
// Keychain stores it as a thin ASN.1 wrapper with this format:
|
||||||
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
|
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Reads OpenSSH protocol data.
|
|
||||||
public final class OpenSSHReader {
|
|
||||||
|
|
||||||
var remaining: Data
|
|
||||||
|
|
||||||
/// Initialize the reader with an OpenSSH data payload.
|
|
||||||
/// - Parameter data: The data to read.
|
|
||||||
public init(data: Data) {
|
|
||||||
remaining = Data(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the next chunk of data from the playload.
|
|
||||||
/// - Returns: The next chunk of data.
|
|
||||||
public func readNextChunk() -> Data {
|
|
||||||
let lengthRange = 0..<(UInt32.bitWidth/8)
|
|
||||||
let lengthChunk = remaining[lengthRange]
|
|
||||||
remaining.removeSubrange(lengthRange)
|
|
||||||
let littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self)
|
|
||||||
let length = Int(littleEndianLength.bigEndian)
|
|
||||||
let dataRange = 0..<length
|
|
||||||
let ret = Data(remaining[dataRange])
|
|
||||||
remaining.removeSubrange(dataRange)
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,12 @@ import OSLog
|
|||||||
public final class PublicKeyFileStoreController: Sendable {
|
public final class PublicKeyFileStoreController: Sendable {
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
||||||
private let directory: String
|
private let directory: URL
|
||||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
|
|
||||||
/// Initializes a PublicKeyFileStoreController.
|
/// Initializes a PublicKeyFileStoreController.
|
||||||
public init(homeDirectory: String) {
|
public init(homeDirectory: URL) {
|
||||||
directory = homeDirectory.appending("/PublicKeys")
|
directory = homeDirectory.appending(component: "PublicKeys")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes out the keys specified to disk.
|
/// Writes out the keys specified to disk.
|
||||||
@@ -19,17 +19,19 @@ public final class PublicKeyFileStoreController: Sendable {
|
|||||||
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
||||||
logger.log("Writing public keys to disk")
|
logger.log("Writing public keys to disk")
|
||||||
if clear {
|
if clear {
|
||||||
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
|
let validPaths = Set(secrets.map { publicKeyPath(for: $0) })
|
||||||
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []
|
.union(Set(secrets.map { sshCertificatePath(for: $0) }))
|
||||||
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
|
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
|
||||||
|
let fullPathContents = contentsOfDirectory.map { directory.appending(path: $0).path() }
|
||||||
|
|
||||||
let untracked = Set(fullPathContents)
|
let untracked = Set(fullPathContents)
|
||||||
.subtracting(validPaths)
|
.subtracting(validPaths)
|
||||||
for path in untracked {
|
for path in untracked {
|
||||||
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
|
// string instead of fileURLWithPath since we're already using fileURL format.
|
||||||
|
try? FileManager.default.removeItem(at: URL(string: path)!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let path = publicKeyPath(for: secret)
|
let path = publicKeyPath(for: secret)
|
||||||
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
||||||
@@ -44,14 +46,14 @@ public final class PublicKeyFileStoreController: Sendable {
|
|||||||
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
||||||
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
|
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||||
return directory.appending("/").appending("\(minimalHex).pub")
|
return directory.appending(component: "\(minimalHex).pub").path()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
|
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
|
||||||
public var hasAnyCertificates: Bool {
|
public var hasAnyCertificates: Bool {
|
||||||
do {
|
do {
|
||||||
return try FileManager.default
|
return try FileManager.default
|
||||||
.contentsOfDirectory(atPath: directory)
|
.contentsOfDirectory(atPath: directory.path())
|
||||||
.filter { $0.hasSuffix("-cert.pub") }
|
.filter { $0.hasSuffix("-cert.pub") }
|
||||||
.isEmpty == false
|
.isEmpty == false
|
||||||
} catch {
|
} catch {
|
||||||
@@ -65,7 +67,7 @@ public final class PublicKeyFileStoreController: Sendable {
|
|||||||
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
|
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
|
||||||
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
|
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||||
return directory.appending("/").appending("\(minimalHex)-cert.pub")
|
return directory.appending(component: "\(minimalHex)-cert.pub").path()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,37 @@ public protocol SecretStoreModifiable<SecretType>: SecretStore {
|
|||||||
/// - attributes: The new attributes for the secret.
|
/// - attributes: The new attributes for the secret.
|
||||||
func update(secret: SecretType, name: String, attributes: Attributes) async throws
|
func update(secret: SecretType, name: String, attributes: Attributes) async throws
|
||||||
|
|
||||||
var supportedKeyTypes: [KeyType] { get }
|
var supportedKeyTypes: KeyAvailability { get }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct KeyAvailability: Sendable {
|
||||||
|
|
||||||
|
public let available: [KeyType]
|
||||||
|
public let unavailable: [UnavailableKeyType]
|
||||||
|
|
||||||
|
public init(available: [KeyType], unavailable: [UnavailableKeyType]) {
|
||||||
|
self.available = available
|
||||||
|
self.unavailable = unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UnavailableKeyType: Sendable {
|
||||||
|
public let keyType: KeyType
|
||||||
|
public let reason: Reason
|
||||||
|
|
||||||
|
public init(keyType: KeyType, reason: Reason) {
|
||||||
|
self.keyType = keyType
|
||||||
|
self.reason = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Reason: Sendable {
|
||||||
|
case macOSUpdateRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
extension NSNotification.Name {
|
extension NSNotification.Name {
|
||||||
|
|
||||||
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)
|
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ extension SecureEnclave {
|
|||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var privateUntyped: CFTypeRef?
|
var privateUntyped: CFTypeRef?
|
||||||
SecItemCopyMatching(privateAttributes, &privateUntyped)
|
unsafe SecItemCopyMatching(privateAttributes, &privateUntyped)
|
||||||
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
||||||
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
|
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
|
||||||
var migrated = false
|
var migratedAny = false
|
||||||
for key in privateTyped {
|
for key in privateTyped {
|
||||||
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||||
let id = key[kSecAttrApplicationLabel] as! Data
|
let id = key[kSecAttrApplicationLabel] as! Data
|
||||||
@@ -40,35 +40,39 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
let ref = key[kSecValueRef] as! SecKey
|
let ref = key[kSecValueRef] as! SecKey
|
||||||
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
|
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
|
||||||
let tokenObjectID = attributes[Constants.tokenObjectID] as! Data
|
let tokenObjectID = unsafe attributes[Constants.tokenObjectID] as! Data
|
||||||
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
|
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
|
||||||
// Best guess.
|
// Best guess.
|
||||||
let auth: AuthenticationRequirement = String(describing: accessControl)
|
let auth: AuthenticationRequirement = String(describing: accessControl)
|
||||||
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
|
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
|
||||||
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
|
do {
|
||||||
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
|
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
|
||||||
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
|
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
|
||||||
logger.log("Skipping \(name), public key already present. Marking as migrated.")
|
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
|
||||||
try markMigrated(secret: secret, oldID: id)
|
logger.log("Skipping \(name), public key already present. Marking as migrated.")
|
||||||
continue
|
markMigrated(secret: secret, oldID: id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.log("Migrating \(name).")
|
||||||
|
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
||||||
|
logger.log("Migrated \(name).")
|
||||||
|
markMigrated(secret: secret, oldID: id)
|
||||||
|
migratedAny = true
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to migrate \(name): \(error.localizedDescription).")
|
||||||
}
|
}
|
||||||
logger.log("Migrating \(name).")
|
|
||||||
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
|
||||||
logger.log("Migrated \(name).")
|
|
||||||
try markMigrated(secret: secret, oldID: id)
|
|
||||||
migrated = true
|
|
||||||
}
|
}
|
||||||
if migrated {
|
if migratedAny {
|
||||||
store.reloadSecrets()
|
store.reloadSecrets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public func markMigrated(secret: Secret, oldID: Data) throws {
|
public func markMigrated(secret: Secret, oldID: Data) {
|
||||||
let updateQuery = KeychainDictionary([
|
let updateQuery = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrApplicationLabel: secret.id
|
kSecAttrApplicationLabel: oldID
|
||||||
])
|
])
|
||||||
|
|
||||||
let newID = oldID + Constants.migrationMagicNumber
|
let newID = oldID + Constants.migrationMagicNumber
|
||||||
@@ -78,7 +82,7 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
logger.warning("Failed to mark \(secret.name) as migrated: \(status).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ extension SecureEnclave {
|
|||||||
/// - duration: The duration of the authorization context, in seconds.
|
/// - duration: The duration of the authorization context, in seconds.
|
||||||
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
self.context = context
|
unsafe self.context = context
|
||||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||||
}
|
}
|
||||||
@@ -56,11 +56,9 @@ extension SecureEnclave {
|
|||||||
formatter.unitsStyle = .spellOut
|
formatter.unitsStyle = .spellOut
|
||||||
formatter.allowedUnits = [.hour, .minute, .day]
|
formatter.allowedUnits = [.hour, .minute, .day]
|
||||||
|
|
||||||
if let durationString = formatter.string(from: duration) {
|
|
||||||
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
let durationString = formatter.string(from: duration)!
|
||||||
} else {
|
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
||||||
newContext.localizedReason = String(localized: .authContextPersistForDurationUnknown(secretName: secret.name))
|
|
||||||
}
|
|
||||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||||
guard success else { return }
|
guard success else { return }
|
||||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
@preconcurrency import LocalAuthentication
|
import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ extension SecureEnclave {
|
|||||||
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||||
guard Constants.notificationToken != (note.object as? String) else {
|
guard Constants.notificationToken != (note.object as? String) else {
|
||||||
// Don't reload if we're the ones triggering this by reloading.
|
// Don't reload if we're the ones triggering this by reloading.
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
reloadSecrets()
|
reloadSecrets()
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ extension SecureEnclave {
|
|||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
var context: LAContext
|
var context: LAContext
|
||||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||||
context = existing.context
|
context = unsafe existing.context
|
||||||
} else {
|
} else {
|
||||||
let newContext = LAContext()
|
let newContext = LAContext()
|
||||||
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||||
@@ -57,7 +57,7 @@ extension SecureEnclave {
|
|||||||
kSecReturnData: true,
|
kSecReturnData: true,
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(queryAttributes, &untyped)
|
let status = unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
@@ -121,12 +121,12 @@ extension SecureEnclave {
|
|||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
let access =
|
let access =
|
||||||
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
unsafe SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
||||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
flags,
|
flags,
|
||||||
&accessError)
|
&accessError)
|
||||||
if let error = accessError {
|
if let error = unsafe accessError {
|
||||||
throw error.takeRetainedValue() as Error
|
throw unsafe error.takeRetainedValue() as Error
|
||||||
}
|
}
|
||||||
let dataRep: Data
|
let dataRep: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
@@ -186,17 +186,22 @@ extension SecureEnclave {
|
|||||||
await reloadSecrets()
|
await reloadSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var supportedKeyTypes: [KeyType] {
|
public let supportedKeyTypes: KeyAvailability = {
|
||||||
if #available(macOS 26, *) {
|
let macOS26Keys: [KeyType] = [.mldsa65, .mldsa87]
|
||||||
[
|
let isAtLeastMacOS26 = if #available(macOS 26, *) {
|
||||||
.ecdsa256,
|
true
|
||||||
.mldsa65,
|
|
||||||
.mldsa87,
|
|
||||||
]
|
|
||||||
} else {
|
} else {
|
||||||
[.ecdsa256]
|
false
|
||||||
}
|
}
|
||||||
}
|
return KeyAvailability(
|
||||||
|
available: [
|
||||||
|
.ecdsa256,
|
||||||
|
] + (isAtLeastMacOS26 ? macOS26Keys : []),
|
||||||
|
unavailable: (isAtLeastMacOS26 ? [] : macOS26Keys).map {
|
||||||
|
KeyAvailability.UnavailableKeyType(keyType: $0, reason: .macOSUpdateRequired)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -214,7 +219,7 @@ extension SecureEnclave.Store {
|
|||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
SecItemCopyMatching(queryAttributes, &untyped)
|
unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
|
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
@preconcurrency import CryptoTokenKit
|
@unsafe @preconcurrency import CryptoTokenKit
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ extension SmartCard {
|
|||||||
kSecReturnRef: true
|
kSecReturnRef: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(attributes, &untyped)
|
let status = unsafe SecItemCopyMatching(attributes, &untyped)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
@@ -80,8 +80,8 @@ extension SmartCard {
|
|||||||
let key = untypedSafe as! SecKey
|
let key = untypedSafe as! SecKey
|
||||||
var signError: SecurityError?
|
var signError: SecurityError?
|
||||||
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
|
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
|
||||||
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
|
guard let signature = unsafe SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
|
||||||
throw SigningError(error: signError)
|
throw unsafe SigningError(error: signError)
|
||||||
}
|
}
|
||||||
return signature as Data
|
return signature as Data
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ extension SmartCard.Store {
|
|||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
SecItemCopyMatching(attributes, &untyped)
|
unsafe SecItemCopyMatching(attributes, &untyped)
|
||||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let wrapped: [SecretType] = typed.compactMap {
|
let wrapped: [SecretType] = typed.compactMap {
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||||
|
|||||||
14
Sources/Packages/Sources/XPCWrappers/XPCProtocol.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc protocol _XPCProtocol: Sendable {
|
||||||
|
func process(_ data: Data, with reply: @Sendable @escaping (Data?, Error?) -> Void)
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol XPCProtocol<Input, Output>: Sendable {
|
||||||
|
|
||||||
|
associatedtype Input: Codable
|
||||||
|
associatedtype Output: Codable
|
||||||
|
|
||||||
|
func process(_ data: Input) async throws -> Output
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
|
||||||
|
|
||||||
|
private let exportedObject: ErasedXPCProtocol
|
||||||
|
|
||||||
|
public init<XPCProtocolType: XPCProtocol>(exportedObject: XPCProtocolType) {
|
||||||
|
self.exportedObject = ErasedXPCProtocol(exportedObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
|
||||||
|
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
||||||
|
let exportedObject = exportedObject
|
||||||
|
newConnection.exportedObject = exportedObject
|
||||||
|
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
|
||||||
|
newConnection.resume()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private final class ErasedXPCProtocol: NSObject, _XPCProtocol {
|
||||||
|
|
||||||
|
let _process: @Sendable (Data, @Sendable @escaping (Data?, (any Error)?) -> Void) -> Void
|
||||||
|
|
||||||
|
public init<XPCProtocolType: XPCProtocol>(_ exportedObject: XPCProtocolType) {
|
||||||
|
_process = { data, reply in
|
||||||
|
Task { [reply] in
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(XPCProtocolType.Input.self, from: data)
|
||||||
|
let result = try await exportedObject.process(decoded)
|
||||||
|
let encoded = try JSONEncoder().encode(result)
|
||||||
|
reply(encoded, nil)
|
||||||
|
} catch {
|
||||||
|
if let error = error as? Codable & Error {
|
||||||
|
reply(nil, NSError(error))
|
||||||
|
} else {
|
||||||
|
// Sending cast directly tries to serialize it and crashes XPCEncoder.
|
||||||
|
let cast = error as NSError
|
||||||
|
reply(nil, NSError(domain: cast.domain, code: cast.code, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func process(_ data: Data, with reply: @Sendable @escaping (Data?, (any Error)?) -> Void) {
|
||||||
|
_process(data, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSError {
|
||||||
|
|
||||||
|
private enum Constants {
|
||||||
|
static let domain = "com.maxgoedjen.secretive.xpcwrappers"
|
||||||
|
static let code = -1
|
||||||
|
static let dataKey = "underlying"
|
||||||
|
}
|
||||||
|
|
||||||
|
@nonobjc convenience init<ErrorType: Codable & Error>(_ error: ErrorType) {
|
||||||
|
let encoded = try? JSONEncoder().encode(error)
|
||||||
|
self.init(domain: Constants.domain, code: Constants.code, userInfo: [Constants.dataKey: encoded as Any])
|
||||||
|
}
|
||||||
|
|
||||||
|
@nonobjc public func underlying<ErrorType: Codable & Error>(as errorType: ErrorType.Type) -> ErrorType? {
|
||||||
|
guard domain == Constants.domain && code == Constants.code, let data = userInfo[Constants.dataKey] as? Data else { return nil }
|
||||||
|
return try? JSONDecoder().decode(ErrorType.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
53
Sources/Packages/Sources/XPCWrappers/XPCTypedSession.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error & Codable>: ~Copyable {
|
||||||
|
|
||||||
|
private let connection: NSXPCConnection
|
||||||
|
private let proxy: _XPCProtocol
|
||||||
|
|
||||||
|
public init(serviceName: String, warmup: Bool = false) async throws {
|
||||||
|
let connection = NSXPCConnection(serviceName: serviceName)
|
||||||
|
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
||||||
|
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
|
||||||
|
connection.resume()
|
||||||
|
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
|
||||||
|
self.connection = connection
|
||||||
|
self.proxy = proxy
|
||||||
|
if warmup {
|
||||||
|
_ = try? await send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func send(_ message: some Encodable = Data()) async throws -> ResponseType {
|
||||||
|
let encoded = try JSONEncoder().encode(message)
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
proxy.process(encoded) { data, error in
|
||||||
|
do {
|
||||||
|
if let error {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
guard let data else {
|
||||||
|
throw NoDataError()
|
||||||
|
}
|
||||||
|
let decoded = try JSONDecoder().decode(ResponseType.self, from: data)
|
||||||
|
continuation.resume(returning: decoded)
|
||||||
|
} catch {
|
||||||
|
if let typed = (error as NSError).underlying(as: ErrorType.self) {
|
||||||
|
continuation.resume(throwing: typed)
|
||||||
|
} else {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public func complete() {
|
||||||
|
connection.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NoDataError: Error {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,19 +8,22 @@ import CryptoKit
|
|||||||
|
|
||||||
// MARK: Identity Listing
|
// MARK: Identity Listing
|
||||||
|
|
||||||
|
|
||||||
// let testProvenance = SigningRequestProvenance(root: .init(pid: 0, processName: "Test", appName: "Test", iconURL: nil, path: /, validSignature: true, parentPID: nil))
|
|
||||||
|
|
||||||
@Test func emptyStores() async throws {
|
@Test func emptyStores() async throws {
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||||
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func identitiesList() async throws {
|
@Test func identitiesList() async throws {
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||||
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
|
|
||||||
|
let actual = OpenSSHReader(data: response)
|
||||||
|
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
|
||||||
|
print(actual, expected)
|
||||||
#expect(response == Constants.Responses.requestIdentitiesMultiple)
|
#expect(response == Constants.Responses.requestIdentitiesMultiple)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,40 +32,42 @@ import CryptoKit
|
|||||||
@Test func noMatchingIdentities() async throws {
|
@Test func noMatchingIdentities() async throws {
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
let response = try await agent.handle(data: Constants.Requests.requestSignatureWithNoneMatching, provenance: .test)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
|
||||||
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Test func ecdsaSignature() async throws {
|
@Test func ecdsaSignature() async throws {
|
||||||
// let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
// let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
guard case SSHAgent.Request.signRequest(let context) = request else { return }
|
||||||
// _ = requestReader.readNextChunk()
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
// let dataToSign = requestReader.readNextChunk()
|
let agent = Agent(storeList: list)
|
||||||
// let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
// let agent = Agent(storeList: list)
|
let responseReader = OpenSSHReader(data: response)
|
||||||
// await agent.handle(reader: stubReader, writer: stubWriter)
|
let length = try responseReader.readNextBytes(as: UInt32.self).bigEndian
|
||||||
// let outer = OpenSSHReader(data: stubWriter.data[5...])
|
let type = try responseReader.readNextBytes(as: UInt8.self).bigEndian
|
||||||
// let payload = outer.readNextChunk()
|
#expect(length == response.count - MemoryLayout<UInt32>.size)
|
||||||
// let inner = OpenSSHReader(data: payload)
|
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
|
||||||
// _ = inner.readNextChunk()
|
let outer = OpenSSHReader(data: responseReader.remaining)
|
||||||
// let signedData = inner.readNextChunk()
|
let inner = try outer.readNextChunkAsSubReader()
|
||||||
// let rsData = OpenSSHReader(data: signedData)
|
_ = try inner.readNextChunk()
|
||||||
// var r = rsData.readNextChunk()
|
let rsData = try inner.readNextChunkAsSubReader()
|
||||||
// var s = rsData.readNextChunk()
|
var r = try rsData.readNextChunk()
|
||||||
// // This is fine IRL, but it freaks out CryptoKit
|
var s = try rsData.readNextChunk()
|
||||||
// if r[0] == 0 {
|
// This is fine IRL, but it freaks out CryptoKit
|
||||||
// r.removeFirst()
|
if r[0] == 0 {
|
||||||
// }
|
r.removeFirst()
|
||||||
// if s[0] == 0 {
|
}
|
||||||
// s.removeFirst()
|
if s[0] == 0 {
|
||||||
// }
|
s.removeFirst()
|
||||||
// var rs = r
|
}
|
||||||
// rs.append(s)
|
var rs = r
|
||||||
// let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
rs.append(s)
|
||||||
// // Correct signature
|
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||||
// #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
// Correct signature
|
||||||
// .isValidSignature(signature, for: dataToSign))
|
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||||
// }
|
.isValidSignature(signature, for: context.dataToSign))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Witness protocol
|
// MARK: Witness protocol
|
||||||
|
|
||||||
@@ -72,7 +77,7 @@ import CryptoKit
|
|||||||
return true
|
return true
|
||||||
}, witness: { _, _ in })
|
}, witness: { _, _ in })
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +90,8 @@ import CryptoKit
|
|||||||
witnessed = true
|
witnessed = true
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
|
_ = await agent.handle(request: request, provenance: .test)
|
||||||
#expect(witnessed)
|
#expect(witnessed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +106,8 @@ import CryptoKit
|
|||||||
witnessTrace = trace
|
witnessTrace = trace
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
|
_ = await agent.handle(request: request, provenance: .test)
|
||||||
#expect(witnessTrace == speakNowTrace)
|
#expect(witnessTrace == speakNowTrace)
|
||||||
#expect(witnessTrace == .test)
|
#expect(witnessTrace == .test)
|
||||||
}
|
}
|
||||||
@@ -112,7 +119,8 @@ import CryptoKit
|
|||||||
let store = await list.stores.first?.base as! Stub.Store
|
let store = await list.stores.first?.base as! Stub.Store
|
||||||
store.shouldThrow = true
|
store.shouldThrow = true
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||||
|
let response = await agent.handle(request: request, provenance: .test)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +128,7 @@ import CryptoKit
|
|||||||
|
|
||||||
@Test func unhandledAdd() async throws {
|
@Test func unhandledAdd() async throws {
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
let response = try await agent.handle(data: Constants.Requests.addIdentity, provenance: .test)
|
let response = await agent.handle(request: .addIdentity, provenance: .test)
|
||||||
#expect(response == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +154,13 @@ extension AgentTests {
|
|||||||
|
|
||||||
enum Requests {
|
enum Requests {
|
||||||
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
|
||||||
static let addIdentity = Data(base64Encoded: "AAAAARE=")!
|
|
||||||
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
|
||||||
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Responses {
|
enum Responses {
|
||||||
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
|
||||||
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")!
|
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
|
||||||
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import SecretKit
|
@testable import SecretAgentKit
|
||||||
@testable import SecureEnclaveSecretKit
|
@testable import SecureEnclaveSecretKit
|
||||||
@testable import SmartCardSecretKit
|
@testable import SmartCardSecretKit
|
||||||
|
|
||||||
@Suite struct OpenSSHReaderTests {
|
@Suite struct OpenSSHReaderTests {
|
||||||
|
|
||||||
@Test func signatureRequest() {
|
@Test func signatureRequest() throws {
|
||||||
let reader = OpenSSHReader(data: Constants.signatureRequest)
|
let reader = OpenSSHReader(data: Constants.signatureRequest)
|
||||||
let hash = reader.readNextChunk()
|
let hash = try reader.readNextChunk()
|
||||||
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
|
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
|
||||||
let dataToSign = reader.readNextChunk()
|
let dataToSign = try reader.readNextChunk()
|
||||||
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
|
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
|
||||||
let empty = reader.readNextChunk()
|
let empty = try reader.readNextChunk()
|
||||||
#expect(empty.isEmpty)
|
#expect(empty.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ extension Stub {
|
|||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
|
|
||||||
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
||||||
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired)
|
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired, publicKeyAttribution: "ecdsa-\(keySize)@example.com")
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}()
|
}()
|
||||||
private let updater = Updater(checkOnLaunch: true)
|
private let updater = Updater(checkOnLaunch: true)
|
||||||
private let notifier = Notifier()
|
private let notifier = Notifier()
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
|
||||||
private lazy var agent: Agent = {
|
private lazy var agent: Agent = {
|
||||||
Agent(storeList: storeList, witness: notifier)
|
Agent(storeList: storeList, witness: notifier)
|
||||||
}()
|
}()
|
||||||
@@ -34,11 +34,13 @@ 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 {
|
||||||
for await message in session.messages {
|
for await message in session.messages {
|
||||||
let agentResponse = try await agent.handle(data: message, provenance: session.provenance)
|
let request = try await inputParser.parse(data: message)
|
||||||
|
let agentResponse = await agent.handle(request: request, provenance: session.provenance)
|
||||||
try await session.write(agentResponse)
|
try await session.write(agentResponse)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -58,7 +60,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
updater.update
|
updater.update
|
||||||
} onChange: { [updater, notifier] in
|
} onChange: { [updater, notifier] in
|
||||||
Task {
|
Task {
|
||||||
guard !updater.testBuild else { return }
|
guard !updater.currentVersion.isTestBuild else { return }
|
||||||
await notifier.notify(update: updater.update!) { release in
|
await notifier.notify(update: updater.update!) { release in
|
||||||
await updater.ignore(release: release)
|
await updater.ignore(release: release)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,22 +9,7 @@
|
|||||||
<key>Website</key>
|
<key>Website</key>
|
||||||
<string>https://github.com/maxgoedjen/secretive</string>
|
<string>https://github.com/maxgoedjen/secretive</string>
|
||||||
<key>Connections</key>
|
<key>Connections</key>
|
||||||
<array>
|
<array/>
|
||||||
<dict>
|
|
||||||
<key>IsIncoming</key>
|
|
||||||
<false/>
|
|
||||||
<key>Host</key>
|
|
||||||
<string>api.github.com</string>
|
|
||||||
<key>NetworkProtocol</key>
|
|
||||||
<string>TCP</string>
|
|
||||||
<key>Port</key>
|
|
||||||
<string>443</string>
|
|
||||||
<key>Purpose</key>
|
|
||||||
<string>Secretive checks GitHub for new versions and security updates.</string>
|
|
||||||
<key>DenyConsequences</key>
|
|
||||||
<string>If you deny these connections, you will not be notified about new versions and critical security updates.</string>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>Services</key>
|
<key>Services</key>
|
||||||
<array/>
|
<array/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
29
Sources/SecretAgent/XPCInputParser.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import SecretAgentKit
|
||||||
|
import Brief
|
||||||
|
import XPCWrappers
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
|
||||||
|
public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "XPCAgentInputParser")
|
||||||
|
private let session: XPCTypedSession<SSHAgent.Request, SSHAgentInputParser.AgentParsingError>
|
||||||
|
|
||||||
|
public init() async throws {
|
||||||
|
logger.debug("Creating XPCAgentInputParser")
|
||||||
|
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentInputParser", warmup: true)
|
||||||
|
logger.debug("XPCAgentInputParser is warmed up.")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parse(data: Data) async throws -> SSHAgent.Request {
|
||||||
|
logger.debug("Parsing input")
|
||||||
|
defer { logger.debug("Parsed input") }
|
||||||
|
return try await session.send(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
session.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
11
Sources/SecretAgentInputParser/Info.plist
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>XPCService</key>
|
||||||
|
<dict>
|
||||||
|
<key>ServiceType</key>
|
||||||
|
<string>Application</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
17
Sources/SecretAgentInputParser/SecretAgentInputParser.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import XPCWrappers
|
||||||
|
import SecretAgentKit
|
||||||
|
|
||||||
|
final class SecretAgentInputParser: NSObject, XPCProtocol {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretAgentInputParser", category: "SecretAgentInputParser")
|
||||||
|
|
||||||
|
func process(_ data: Data) async throws -> SSHAgent.Request {
|
||||||
|
let parser = SSHAgentInputParser()
|
||||||
|
let result = try parser.parse(data: data)
|
||||||
|
logger.log("Parser parsed message as type \(result.debugDescription)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
7
Sources/SecretAgentInputParser/main.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
import XPCWrappers
|
||||||
|
|
||||||
|
let delegate = XPCServiceDelegate(exportedObject: SecretAgentInputParser())
|
||||||
|
let listener = NSXPCListener.service()
|
||||||
|
listener.delegate = delegate
|
||||||
|
listener.resume()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<TestPlans>
|
||||||
|
<TestPlanReference
|
||||||
|
reference = "container:Config/Secretive.xctestplan">
|
||||||
|
</TestPlanReference>
|
||||||
|
</TestPlans>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -4,6 +4,110 @@ import SecureEnclaveSecretKit
|
|||||||
import SmartCardSecretKit
|
import SmartCardSecretKit
|
||||||
import Brief
|
import Brief
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct Secretive: App {
|
||||||
|
|
||||||
|
@Environment(\.agentStatusChecker) var agentStatusChecker
|
||||||
|
@Environment(\.justUpdatedChecker) var justUpdatedChecker
|
||||||
|
|
||||||
|
@SceneBuilder var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(EnvironmentValues._secretStoreList)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
|
guard hasRunSetup else { return }
|
||||||
|
agentStatusChecker.check()
|
||||||
|
if agentStatusChecker.running && justUpdatedChecker.justUpdatedBuild {
|
||||||
|
// Relaunch the agent, since it'll be running from earlier update still
|
||||||
|
reinstallAgent()
|
||||||
|
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
|
||||||
|
forceLaunchAgent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.commands {
|
||||||
|
AppCommands()
|
||||||
|
}
|
||||||
|
WindowGroup(id: String(describing: IntegrationsView.self)) {
|
||||||
|
IntegrationsView()
|
||||||
|
}
|
||||||
|
.windowResizability(.contentMinSize)
|
||||||
|
WindowGroup(id: String(describing: AboutView.self)) {
|
||||||
|
AboutView()
|
||||||
|
}
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Secretive {
|
||||||
|
|
||||||
|
struct AppCommands: Commands {
|
||||||
|
|
||||||
|
@Environment(\.openWindow) var openWindow
|
||||||
|
@Environment(\.openURL) var openURL
|
||||||
|
@FocusedValue(\.showCreateSecret) var showCreateSecret
|
||||||
|
|
||||||
|
var body: some Commands {
|
||||||
|
CommandGroup(replacing: .appInfo) {
|
||||||
|
Button(.aboutMenuBarTitle, systemImage: "info.circle") {
|
||||||
|
openWindow(id: String(describing: AboutView.self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandGroup(before: CommandGroupPlacement.appSettings) {
|
||||||
|
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
|
||||||
|
openWindow(id: String(describing: IntegrationsView.self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandGroup(after: CommandGroupPlacement.newItem) {
|
||||||
|
Button(.appMenuNewSecretButton, systemImage: "plus") {
|
||||||
|
showCreateSecret?()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
||||||
|
.disabled(showCreateSecret?.isEnabled == false)
|
||||||
|
}
|
||||||
|
CommandGroup(replacing: .help) {
|
||||||
|
Button(.appMenuHelpButton) {
|
||||||
|
openURL(Constants.helpURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SidebarCommands()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Secretive {
|
||||||
|
|
||||||
|
private func reinstallAgent() {
|
||||||
|
Task {
|
||||||
|
_ = await LaunchAgentController().install()
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
agentStatusChecker.check()
|
||||||
|
if !agentStatusChecker.running {
|
||||||
|
forceLaunchAgent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forceLaunchAgent() {
|
||||||
|
// We've run setup, we didn't just update, launchd is just not doing it's thing.
|
||||||
|
// Force a launch directly.
|
||||||
|
Task {
|
||||||
|
_ = await LaunchAgentController().forceLaunch()
|
||||||
|
agentStatusChecker.check()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Constants {
|
||||||
|
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
|
|
||||||
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
||||||
@@ -25,90 +129,30 @@ extension EnvironmentValues {
|
|||||||
}()
|
}()
|
||||||
@Entry var updater: any UpdaterProtocol = _updater
|
@Entry var updater: any UpdaterProtocol = _updater
|
||||||
|
|
||||||
|
private static let _justUpdatedChecker = JustUpdatedChecker()
|
||||||
|
@Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker
|
||||||
|
|
||||||
@MainActor var secretStoreList: SecretStoreList {
|
@MainActor var secretStoreList: SecretStoreList {
|
||||||
EnvironmentValues._secretStoreList
|
EnvironmentValues._secretStoreList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
extension FocusedValues {
|
||||||
struct Secretive: App {
|
@Entry var showCreateSecret: OpenSheet?
|
||||||
|
}
|
||||||
|
|
||||||
private let justUpdatedChecker = JustUpdatedChecker()
|
final class OpenSheet {
|
||||||
@Environment(\.agentStatusChecker) var agentStatusChecker
|
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
|
||||||
@State private var showingSetup = false
|
|
||||||
@State private var showingCreation = false
|
|
||||||
|
|
||||||
@SceneBuilder var body: some Scene {
|
let closure: () -> Void
|
||||||
WindowGroup {
|
let isEnabled: Bool
|
||||||
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
|
|
||||||
.environment(EnvironmentValues._secretStoreList)
|
init(isEnabled: Bool = true, closure: @escaping () -> Void) {
|
||||||
.onAppear {
|
self.isEnabled = isEnabled
|
||||||
if !hasRunSetup {
|
self.closure = closure
|
||||||
showingSetup = true
|
}
|
||||||
}
|
|
||||||
}
|
func callAsFunction() {
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
closure()
|
||||||
guard hasRunSetup else { return }
|
|
||||||
agentStatusChecker.check()
|
|
||||||
if agentStatusChecker.running && justUpdatedChecker.justUpdated {
|
|
||||||
// Relaunch the agent, since it'll be running from earlier update still
|
|
||||||
reinstallAgent()
|
|
||||||
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
|
|
||||||
forceLaunchAgent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.commands {
|
|
||||||
CommandGroup(after: CommandGroupPlacement.newItem) {
|
|
||||||
Button(.appMenuNewSecretButton) {
|
|
||||||
showingCreation = true
|
|
||||||
}
|
|
||||||
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
|
||||||
}
|
|
||||||
CommandGroup(replacing: .help) {
|
|
||||||
Button(.appMenuHelpButton) {
|
|
||||||
NSWorkspace.shared.open(Constants.helpURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CommandGroup(after: .help) {
|
|
||||||
Button(.appMenuSetupButton) {
|
|
||||||
showingSetup = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SidebarCommands()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Secretive {
|
|
||||||
|
|
||||||
private func reinstallAgent() {
|
|
||||||
justUpdatedChecker.check()
|
|
||||||
Task {
|
|
||||||
await LaunchAgentController().install()
|
|
||||||
try? await Task.sleep(for: .seconds(1))
|
|
||||||
agentStatusChecker.check()
|
|
||||||
if !agentStatusChecker.running {
|
|
||||||
forceLaunchAgent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func forceLaunchAgent() {
|
|
||||||
// We've run setup, we didn't just update, launchd is just not doing it's thing.
|
|
||||||
// Force a launch directly.
|
|
||||||
Task {
|
|
||||||
_ = await LaunchAgentController().forceLaunch()
|
|
||||||
agentStatusChecker.check()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private enum Constants {
|
|
||||||
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
@@ -1,68 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-16x16@1x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "16x16"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-16x16@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "16x16"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-32x32@1x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-32x32@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-128x128@1x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-128x128@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-256x256@1x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-256x256@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-512x512@1x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "512x512"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "Icon-macOS-ClearDark-1024x1024@1x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "512x512"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 856 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 356 KiB |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,14 @@ import Observation
|
|||||||
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
|
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
|
||||||
var running: Bool { get }
|
var running: Bool { get }
|
||||||
var developmentBuild: Bool { get }
|
var developmentBuild: Bool { get }
|
||||||
|
var process: NSRunningApplication? { get }
|
||||||
func check()
|
func check()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
|
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
|
||||||
|
|
||||||
var running: Bool = false
|
var running: Bool = false
|
||||||
|
var process: NSRunningApplication? = nil
|
||||||
|
|
||||||
nonisolated init() {
|
nonisolated init() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -20,32 +22,39 @@ import Observation
|
|||||||
}
|
}
|
||||||
|
|
||||||
func check() {
|
func check() {
|
||||||
running = instanceSecretAgentProcess != nil
|
process = instanceSecretAgentProcess
|
||||||
|
running = process != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// All processes, including ones from older versions, etc
|
// All processes, including ones from older versions, etc
|
||||||
var secretAgentProcesses: [NSRunningApplication] {
|
var allSecretAgentProcesses: [NSRunningApplication] {
|
||||||
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.agentBundleID)
|
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.agentBundleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The process corresponding to this instance of Secretive
|
// The process corresponding to this instance of Secretive
|
||||||
var instanceSecretAgentProcess: NSRunningApplication? {
|
var instanceSecretAgentProcess: NSRunningApplication? {
|
||||||
let agents = secretAgentProcesses
|
// FIXME: CHECK VERSION
|
||||||
|
let agents = allSecretAgentProcesses
|
||||||
for agent in agents {
|
for agent in agents {
|
||||||
guard let url = agent.bundleURL else { continue }
|
guard let url = agent.bundleURL else { continue }
|
||||||
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) {
|
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) || (url.isXcodeURL && developmentBuild) {
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Whether Secretive is being run in an Xcode environment.
|
// Whether Secretive is being run in an Xcode environment.
|
||||||
var developmentBuild: Bool {
|
var developmentBuild: Bool {
|
||||||
Bundle.main.bundleURL.absoluteString.contains("/Library/Developer/Xcode")
|
Bundle.main.bundleURL.isXcodeURL
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
|
||||||
|
var isXcodeURL: Bool {
|
||||||
|
absoluteString.contains("/Library/Developer/Xcode")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
protocol JustUpdatedCheckerProtocol: Observable {
|
@MainActor protocol JustUpdatedCheckerProtocol: Observable {
|
||||||
var justUpdated: Bool { get }
|
var justUpdatedBuild: Bool { get }
|
||||||
|
var justUpdatedOS: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable class JustUpdatedChecker: JustUpdatedCheckerProtocol {
|
@Observable @MainActor class JustUpdatedChecker: JustUpdatedCheckerProtocol {
|
||||||
|
|
||||||
var justUpdated: Bool = false
|
var justUpdatedBuild: Bool = false
|
||||||
|
var justUpdatedOS: Bool = false
|
||||||
|
|
||||||
init() {
|
nonisolated init() {
|
||||||
check()
|
Task { @MainActor in
|
||||||
|
check()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func check() {
|
private func check() {
|
||||||
let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String ?? "None"
|
let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String
|
||||||
|
let lastOS = UserDefaults.standard.object(forKey: Constants.previousOSVersionUserDefaultsKey) as? String
|
||||||
let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
|
let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
|
||||||
|
let osRaw = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
let currentOS = "\(osRaw.majorVersion).\(osRaw.minorVersion).\(osRaw.patchVersion)"
|
||||||
UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey)
|
UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey)
|
||||||
justUpdated = lastBuild != currentBuild
|
UserDefaults.standard.set(currentOS, forKey: Constants.previousOSVersionUserDefaultsKey)
|
||||||
|
justUpdatedBuild = lastBuild != currentBuild
|
||||||
|
// To prevent this showing on first lauch for every user, only show if lastBuild is non-nil.
|
||||||
|
justUpdatedOS = lastBuild != nil && lastOS != currentOS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +38,7 @@ extension JustUpdatedChecker {
|
|||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild"
|
static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild"
|
||||||
|
static let previousOSVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastOS"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,28 @@ struct LaunchAgentController {
|
|||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
|
||||||
|
|
||||||
func install() async {
|
func install() async -> Bool {
|
||||||
logger.debug("Installing agent")
|
logger.debug("Installing agent")
|
||||||
_ = setEnabled(false)
|
_ = setEnabled(false)
|
||||||
// This is definitely a bit of a "seems to work better" thing but:
|
// This is definitely a bit of a "seems to work better" thing but:
|
||||||
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
|
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
|
||||||
// and start new?
|
// and start new?
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
await MainActor.run {
|
let result = await MainActor.run {
|
||||||
_ = setEnabled(true)
|
setEnabled(true)
|
||||||
}
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func uninstall() async -> Bool {
|
||||||
|
logger.debug("Uninstalling agent")
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
let result = await MainActor.run {
|
||||||
|
setEnabled(false)
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func forceLaunch() async -> Bool {
|
func forceLaunch() async -> Bool {
|
||||||
@@ -28,6 +40,7 @@ struct LaunchAgentController {
|
|||||||
do {
|
do {
|
||||||
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
|
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
|
||||||
logger.debug("Agent force launched")
|
logger.debug("Agent force launched")
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error force launching \(error.localizedDescription)")
|
logger.error("Error force launching \(error.localizedDescription)")
|
||||||
@@ -36,7 +49,7 @@ struct LaunchAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setEnabled(_ enabled: Bool) -> Bool {
|
private func setEnabled(_ enabled: Bool) -> Bool {
|
||||||
let service = SMAppService.loginItem(identifier: Bundle.main.agentBundleID)
|
let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
|
||||||
do {
|
do {
|
||||||
if enabled {
|
if enabled {
|
||||||
try service.register()
|
try service.register()
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Cocoa
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
struct ShellConfigurationController {
|
|
||||||
|
|
||||||
let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
|
|
||||||
|
|
||||||
var shellInstructions: [ShellConfigInstruction] {
|
|
||||||
[
|
|
||||||
ShellConfigInstruction(shell: "global",
|
|
||||||
shellConfigDirectory: "~/.ssh/",
|
|
||||||
shellConfigFilename: "config",
|
|
||||||
text: "Host *\n\tIdentityAgent \(socketPath)"),
|
|
||||||
ShellConfigInstruction(shell: "zsh",
|
|
||||||
shellConfigDirectory: "~/",
|
|
||||||
shellConfigFilename: ".zshrc",
|
|
||||||
text: "export SSH_AUTH_SOCK=\(socketPath)"),
|
|
||||||
ShellConfigInstruction(shell: "bash",
|
|
||||||
shellConfigDirectory: "~/",
|
|
||||||
shellConfigFilename: ".bashrc",
|
|
||||||
text: "export SSH_AUTH_SOCK=\(socketPath)"),
|
|
||||||
ShellConfigInstruction(shell: "fish",
|
|
||||||
shellConfigDirectory: "~/.config/fish",
|
|
||||||
shellConfigFilename: "config.fish",
|
|
||||||
text: "set -x SSH_AUTH_SOCK \(socketPath)"),
|
|
||||||
]
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@MainActor func addToShell(shellInstructions: ShellConfigInstruction) -> Bool {
|
|
||||||
let openPanel = NSOpenPanel()
|
|
||||||
// This is sync, so no need to strongly retain
|
|
||||||
let delegate = Delegate(name: shellInstructions.shellConfigFilename)
|
|
||||||
openPanel.delegate = delegate
|
|
||||||
openPanel.message = "Select \(shellInstructions.shellConfigFilename) to let Secretive configure your shell automatically."
|
|
||||||
openPanel.prompt = "Add to \(shellInstructions.shellConfigFilename)"
|
|
||||||
openPanel.canChooseFiles = true
|
|
||||||
openPanel.canChooseDirectories = false
|
|
||||||
openPanel.showsHiddenFiles = true
|
|
||||||
openPanel.directoryURL = URL(fileURLWithPath: shellInstructions.shellConfigDirectory)
|
|
||||||
openPanel.nameFieldStringValue = shellInstructions.shellConfigFilename
|
|
||||||
openPanel.allowedContentTypes = [.symbolicLink, .data, .plainText]
|
|
||||||
openPanel.runModal()
|
|
||||||
guard let fileURL = openPanel.urls.first else { return false }
|
|
||||||
let handle: FileHandle
|
|
||||||
do {
|
|
||||||
handle = try FileHandle(forUpdating: fileURL)
|
|
||||||
guard let existing = try handle.readToEnd(),
|
|
||||||
let existingString = String(data: existing, encoding: .utf8) else { return false }
|
|
||||||
guard !existingString.contains(shellInstructions.text) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
try handle.seekToEnd()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
handle.write(Data("\n# Secretive Config\n\(shellInstructions.text)\n".utf8))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
25
Sources/Secretive/Controllers/URLs.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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,36 +0,0 @@
|
|||||||
{\rtf1\ansi\ansicpg1252\cocoartf2580
|
|
||||||
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
|
|
||||||
{\colortbl;\red255\green255\blue255;}
|
|
||||||
{\*\expandedcolortbl;;}
|
|
||||||
\margl1440\margr1440\vieww9000\viewh8400\viewkind0
|
|
||||||
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6119\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive"}}{\fldrslt
|
|
||||||
\f0\fs24 \cf0 GitHub Repository}}
|
|
||||||
\f0\fs24 \
|
|
||||||
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
|
|
||||||
\cf0 \
|
|
||||||
{\field{\*\fldinst{HYPERLINK "GITHUB_BUILD_URL"}}{\fldrslt Build Log}}\
|
|
||||||
\
|
|
||||||
Special Thanks To:\
|
|
||||||
\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive/graphs/contributors"}}{\fldrslt Contributors}}:\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/0xflotus"}}{\fldrslt 0xflotus}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/aaron-trout"}}{\fldrslt Aaron Trout}}\
|
|
||||||
\pard\pardeftab720\partightenfactor0
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/EppO"}}{\fldrslt \cf0 Florent Monbillard}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/vladimyr"}}{\fldrslt Dario Vladovi\uc0\u263 }}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/lavalleeale"}}{\fldrslt Alex Lavallee}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/joshheyse"}}{\fldrslt Josh}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/diesal11"}}{\fldrslt Dylan Lundy}}\
|
|
||||||
\
|
|
||||||
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
|
|
||||||
\cf0 Testers:\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/bdash"}}{\fldrslt Mark Rowe}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/danielctull"}}{\fldrslt Daniel Tull}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/davedelong"}}{\fldrslt Dave DeLong}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/esttorhe"}}{\fldrslt Esteban Torres}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/joeblau"}}{\fldrslt Joe Blau}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/marksands"}}{\fldrslt Mark Sands}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/mergesort"}}{\fldrslt Joe Fabisevich}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/phillco"}}{\fldrslt Phil Cohen}}\
|
|
||||||
{\field{\*\fldinst{HYPERLINK "https://github.com/zackdotcomputer"}}{\fldrslt Zack Sheppard}}}
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
extension Bundle {
|
extension Bundle {
|
||||||
public var agentBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "Host", with: "SecretAgent"))!}
|
public static var agentBundleID: String {
|
||||||
public var hostBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "SecretAgent", with: "Host"))!}
|
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "Host", with: "SecretAgent")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var hostBundleID: String {
|
||||||
|
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "SecretAgent", with: "Host")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>$(PRODUCT_NAME) is MIT Licensed.</string>
|
<string>$(PRODUCT_NAME) is MIT Licensed.</string>
|
||||||
|
<key>GitHubBuildLog</key>
|
||||||
|
<string>$(CI_BUILD_LINK)</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<string>NSApplication</string>
|
||||||
<key>NSSupportsAutomaticTermination</key>
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
|
|||||||
@@ -9,22 +9,7 @@
|
|||||||
<key>Website</key>
|
<key>Website</key>
|
||||||
<string>https://github.com/maxgoedjen/secretive</string>
|
<string>https://github.com/maxgoedjen/secretive</string>
|
||||||
<key>Connections</key>
|
<key>Connections</key>
|
||||||
<array>
|
<array/>
|
||||||
<dict>
|
|
||||||
<key>IsIncoming</key>
|
|
||||||
<false/>
|
|
||||||
<key>Host</key>
|
|
||||||
<string>api.github.com</string>
|
|
||||||
<key>NetworkProtocol</key>
|
|
||||||
<string>TCP</string>
|
|
||||||
<key>Port</key>
|
|
||||||
<string>443</string>
|
|
||||||
<key>Purpose</key>
|
|
||||||
<string>Secretive checks GitHub for new versions and security updates.</string>
|
|
||||||
<key>DenyConsequences</key>
|
|
||||||
<string>If you deny these connections, you will not be notified about new versions and critical security updates.</string>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>Services</key>
|
<key>Services</key>
|
||||||
<array/>
|
<array/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
|
class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
|
||||||
|
|
||||||
let running: Bool
|
let running: Bool
|
||||||
|
let process: NSRunningApplication?
|
||||||
let developmentBuild = false
|
let developmentBuild = false
|
||||||
|
|
||||||
init(running: Bool = true) {
|
init(running: Bool = true, process: NSRunningApplication? = nil) {
|
||||||
self.running = running
|
self.running = running
|
||||||
|
self.process = process
|
||||||
}
|
}
|
||||||
|
|
||||||
func check() {
|
func check() {
|
||||||
|
|||||||
@@ -60,16 +60,17 @@ extension Preview {
|
|||||||
let id = UUID()
|
let id = UUID()
|
||||||
var name: String { "Modifiable Preview Store" }
|
var name: String { "Modifiable Preview Store" }
|
||||||
let secrets: [Secret]
|
let secrets: [Secret]
|
||||||
var supportedKeyTypes: [KeyType] {
|
var supportedKeyTypes: KeyAvailability {
|
||||||
if #available(macOS 26, *) {
|
return KeyAvailability(
|
||||||
[
|
available: [
|
||||||
.ecdsa256,
|
.ecdsa256,
|
||||||
.mldsa65,
|
.mldsa65,
|
||||||
.mldsa87,
|
.mldsa87
|
||||||
|
],
|
||||||
|
unavailable: [
|
||||||
|
.init(keyType: .ecdsa384, reason: .macOSUpdateRequired)
|
||||||
]
|
]
|
||||||
} else {
|
)
|
||||||
[.ecdsa256]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(secrets: [Secret]) {
|
init(secrets: [Secret]) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Brief
|
|||||||
|
|
||||||
var update: Release? = nil
|
var update: Release? = nil
|
||||||
|
|
||||||
let testBuild = false
|
let currentVersion = SemVer("0.0.0_preview")
|
||||||
|
|
||||||
init(update: Update = .none) {
|
init(update: Update = .none) {
|
||||||
switch update {
|
switch update {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct PrimaryButtonModifier: ViewModifier {
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
// Tinted glass prominent is really hard to read on 26.0.
|
|
||||||
if #available(macOS 26.0, *), colorScheme == .dark {
|
|
||||||
content.buttonStyle(.glassProminent)
|
|
||||||
} else {
|
|
||||||
content.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
|
|
||||||
func primary() -> some View {
|
|
||||||
modifier(PrimaryButtonModifier())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConfigurationItemView<Content: View>: View {
|
||||||
|
|
||||||
|
enum Action: Hashable {
|
||||||
|
case copy(String)
|
||||||
|
case revealInFinder(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let content: Content
|
||||||
|
let action: Action?
|
||||||
|
|
||||||
|
init(title: LocalizedStringResource, value: String, action: Action? = nil) where Content == Text {
|
||||||
|
self.title = title
|
||||||
|
self.content = Text(value)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
init(title: LocalizedStringResource, action: Action? = nil, content: () -> Content) {
|
||||||
|
self.title = title
|
||||||
|
self.content = content()
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
Spacer()
|
||||||
|
switch action {
|
||||||
|
case .copy(let string):
|
||||||
|
Button(.copyableClickToCopyButton, systemImage: "doc.on.doc") {
|
||||||
|
NSPasteboard.general.declareTypes([.string], owner: nil)
|
||||||
|
NSPasteboard.general.setString(string, forType: .string)
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
case .revealInFinder(let rawPath):
|
||||||
|
Button(.revealInFinderButton, systemImage: "folder") {
|
||||||
|
let (processedPath, folder) = rawPath.normalizedPathAndFolder
|
||||||
|
NSWorkspace.shared.selectFile(processedPath, inFileViewerRootedAtPath: folder)
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
case nil:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GettingStartedView: View {
|
||||||
|
|
||||||
|
private let instructions = Instructions()
|
||||||
|
|
||||||
|
@Binding var selectedInstruction: ConfigurationFileInstructions?
|
||||||
|
|
||||||
|
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
|
||||||
|
_selectedInstruction = selectedInstruction
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section(.integrationsGettingStartedTitle) {
|
||||||
|
Text(.integrationsGettingStartedTitleDescription)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Group {
|
||||||
|
Text(.integrationsGettingStartedSuggestionSsh)
|
||||||
|
.onTapGesture {
|
||||||
|
self.selectedInstruction = instructions.ssh
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(.integrationsGettingStartedSuggestionShell)
|
||||||
|
Text(.integrationsGettingStartedSuggestionShellDefault(shellName: String(localized: instructions.defaultShell.tool)))
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
self.selectedInstruction = instructions.defaultShell
|
||||||
|
}
|
||||||
|
Text(.integrationsGettingStartedSuggestionGit)
|
||||||
|
.onTapGesture {
|
||||||
|
self.selectedInstruction = instructions.git
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.link)
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text(.integrationsGettingStartedWhatShouldIConfigureTitle)
|
||||||
|
}
|
||||||
|
footer: {
|
||||||
|
Text(.integrationsGettingStartedMultipleConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
179
Sources/Secretive/Views/Configuration/Instructions.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Instructions {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
static let publicKeyPathPlaceholder = "_PUBLIC_KEY_PATH_PLACEHOLDER_"
|
||||||
|
static let publicKeyPlaceholder = "_PUBLIC_KEY_PLACEHOLDER_"
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultShell: ConfigurationFileInstructions {
|
||||||
|
zsh
|
||||||
|
}
|
||||||
|
|
||||||
|
var gettingStarted: ConfigurationFileInstructions = ConfigurationFileInstructions(.integrationsGettingStartedRowTitle, id: .gettingStarted)
|
||||||
|
|
||||||
|
var ssh: ConfigurationFileInstructions {
|
||||||
|
ConfigurationFileInstructions(
|
||||||
|
tool: LocalizedStringResource.integrationsToolNameSsh,
|
||||||
|
configPath: "~/.ssh/config",
|
||||||
|
configText: "Host *\n\tIdentityAgent \(URL.socketPath)",
|
||||||
|
website: URL(string: "https://man.openbsd.org/ssh_config.5")!,
|
||||||
|
note: .integrationsSshSpecificKeyNote,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var git: ConfigurationFileInstructions {
|
||||||
|
ConfigurationFileInstructions(
|
||||||
|
tool: .integrationsToolNameGitSigning,
|
||||||
|
steps: [
|
||||||
|
.init(path: "~/.gitconfig", steps: [
|
||||||
|
.integrationsGitStepGitconfigDescription(publicKeyPathPlaceholder: Constants.publicKeyPathPlaceholder)
|
||||||
|
],
|
||||||
|
note: .integrationsGitStepGitconfigSectionNote
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
path: "~/.gitallowedsigners",
|
||||||
|
steps: [
|
||||||
|
LocalizedStringResource(stringLiteral: Constants.publicKeyPlaceholder)
|
||||||
|
],
|
||||||
|
note: .integrationsGitStepGitallowedsignersDescription
|
||||||
|
),
|
||||||
|
],
|
||||||
|
website: URL(string: "https://git-scm.com/docs/git-config")!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var zsh: ConfigurationFileInstructions {
|
||||||
|
ConfigurationFileInstructions(
|
||||||
|
tool: .integrationsToolNameZsh,
|
||||||
|
configPath: "~/.zshrc",
|
||||||
|
configText: "export SSH_AUTH_SOCK=\(URL.socketPath)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var instructions: [ConfigurationGroup] {
|
||||||
|
[
|
||||||
|
ConfigurationGroup(name: .integrationsGettingStartedSectionTitle, instructions: [
|
||||||
|
gettingStarted
|
||||||
|
]),
|
||||||
|
ConfigurationGroup(
|
||||||
|
name: .integrationsSystemSectionTitle,
|
||||||
|
instructions: [
|
||||||
|
ssh,
|
||||||
|
git,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
ConfigurationGroup(name: .integrationsShellSectionTitle, instructions: [
|
||||||
|
zsh,
|
||||||
|
ConfigurationFileInstructions(
|
||||||
|
tool: .integrationsToolNameBash,
|
||||||
|
configPath: "~/.bashrc",
|
||||||
|
configText: "export SSH_AUTH_SOCK=\(URL.socketPath)"
|
||||||
|
),
|
||||||
|
ConfigurationFileInstructions(
|
||||||
|
tool: .integrationsToolNameFish,
|
||||||
|
configPath: "~/.config/fish/config.fish",
|
||||||
|
configText: "set -x SSH_AUTH_SOCK \(URL.socketPath)"
|
||||||
|
),
|
||||||
|
ConfigurationFileInstructions(.integrationsOtherShellRowTitle, id: .otherShell),
|
||||||
|
]),
|
||||||
|
ConfigurationGroup(name: .integrationsOtherSectionTitle, instructions: [
|
||||||
|
ConfigurationFileInstructions(.integrationsAppsRowTitle, id: .otherApp),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConfigurationGroup: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var name: LocalizedStringResource
|
||||||
|
var instructions: [ConfigurationFileInstructions] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConfigurationFileInstructions: Hashable, Identifiable {
|
||||||
|
|
||||||
|
struct StepGroup: Hashable, Identifiable {
|
||||||
|
let path: String
|
||||||
|
let steps: [LocalizedStringResource]
|
||||||
|
let note: LocalizedStringResource?
|
||||||
|
var id: String { path }
|
||||||
|
|
||||||
|
init(path: String, steps: [LocalizedStringResource], note: LocalizedStringResource? = nil) {
|
||||||
|
self.path = path
|
||||||
|
self.steps = steps
|
||||||
|
self.note = note
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
id.hash(into: &hasher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: ID
|
||||||
|
var tool: LocalizedStringResource
|
||||||
|
var steps: [StepGroup]
|
||||||
|
var requiresSecret: Bool
|
||||||
|
var website: URL?
|
||||||
|
|
||||||
|
init(
|
||||||
|
tool: LocalizedStringResource,
|
||||||
|
configPath: String,
|
||||||
|
configText: LocalizedStringResource,
|
||||||
|
requiresSecret: Bool = false,
|
||||||
|
website: URL? = nil,
|
||||||
|
note: LocalizedStringResource? = nil
|
||||||
|
) {
|
||||||
|
self.id = .tool(String(localized: tool))
|
||||||
|
self.tool = tool
|
||||||
|
self.steps = [StepGroup(path: configPath, steps: [configText], note: note)]
|
||||||
|
self.requiresSecret = requiresSecret
|
||||||
|
self.website = website
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
tool: LocalizedStringResource,
|
||||||
|
steps: [StepGroup],
|
||||||
|
requiresSecret: Bool = false,
|
||||||
|
website: URL? = nil
|
||||||
|
) {
|
||||||
|
self.id = .tool(String(localized: tool))
|
||||||
|
self.tool = tool
|
||||||
|
self.steps = steps
|
||||||
|
self.requiresSecret = true
|
||||||
|
self.website = website
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ name: LocalizedStringResource, id: ID) {
|
||||||
|
self.id = id
|
||||||
|
tool = name
|
||||||
|
steps = []
|
||||||
|
requiresSecret = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
id.hash(into: &hasher)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ID: Identifiable, Hashable {
|
||||||
|
case gettingStarted
|
||||||
|
case tool(String)
|
||||||
|
case otherShell
|
||||||
|
case otherApp
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .gettingStarted:
|
||||||
|
"getting_started"
|
||||||
|
case .tool(let name):
|
||||||
|
name
|
||||||
|
case .otherShell:
|
||||||
|
"other_shell"
|
||||||
|
case .otherApp:
|
||||||
|
"other_app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
87
Sources/Secretive/Views/Configuration/IntegrationsView.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct IntegrationsView: View {
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var selectedInstruction: ConfigurationFileInstructions?
|
||||||
|
private let instructions = Instructions()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
List(selection: $selectedInstruction) {
|
||||||
|
ForEach(instructions.instructions) { group in
|
||||||
|
Section(group.name) {
|
||||||
|
ForEach(group.instructions) { instruction in
|
||||||
|
Text(instruction.tool)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.tag(instruction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} detail: {
|
||||||
|
IntegrationsDetailView(selectedInstruction: $selectedInstruction)
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
Button(.setupDoneButton) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hiddenToolbar()
|
||||||
|
.windowBackgroundStyle(.thinMaterial)
|
||||||
|
.onAppear {
|
||||||
|
selectedInstruction = instructions.gettingStarted
|
||||||
|
}
|
||||||
|
.frame(minWidth: 400, minHeight: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IntegrationsDetailView: View {
|
||||||
|
|
||||||
|
@Binding private var selectedInstruction: ConfigurationFileInstructions?
|
||||||
|
|
||||||
|
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
|
||||||
|
_selectedInstruction = selectedInstruction
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let selectedInstruction {
|
||||||
|
switch selectedInstruction.id {
|
||||||
|
case .gettingStarted:
|
||||||
|
GettingStartedView(selectedInstruction: $selectedInstruction)
|
||||||
|
case .tool:
|
||||||
|
ToolConfigurationView(selectedInstruction: selectedInstruction)
|
||||||
|
case .otherShell:
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/shells")!)
|
||||||
|
} header: {
|
||||||
|
Text(.integrationsCommunityShellListDescription)
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
case .otherApp:
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/apps")!)
|
||||||
|
} header: {
|
||||||
|
Text(.integrationsCommunityAppsListDescription)
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
IntegrationsView()
|
||||||
|
.frame(height: 500)
|
||||||
|
}
|
||||||
207
Sources/Secretive/Views/Configuration/SetupView.swift
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SetupView: View {
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Binding var setupComplete: Bool
|
||||||
|
|
||||||
|
@State var showingIntegrations = false
|
||||||
|
@State var buttonWidth: CGFloat?
|
||||||
|
|
||||||
|
@State var installed = false
|
||||||
|
@State var updates = false
|
||||||
|
@State var integrations = false
|
||||||
|
var allDone: Bool {
|
||||||
|
installed && updates && integrations
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
StepView(
|
||||||
|
title: .setupAgentTitle,
|
||||||
|
description: .setupAgentDescription,
|
||||||
|
detail: .setupAgentActivityMonitorDescription,
|
||||||
|
systemImage: "lock.laptopcomputer",
|
||||||
|
) {
|
||||||
|
SetupButton(
|
||||||
|
.setupAgentInstallButton,
|
||||||
|
complete: installed,
|
||||||
|
width: buttonWidth
|
||||||
|
) {
|
||||||
|
installed = true
|
||||||
|
Task {
|
||||||
|
await LaunchAgentController().install()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
StepView(
|
||||||
|
title: .setupUpdatesTitle,
|
||||||
|
description: .setupUpdatesDescription,
|
||||||
|
systemImage: "network.badge.shield.half.filled",
|
||||||
|
) {
|
||||||
|
SetupButton(
|
||||||
|
.setupUpdatesOkButton,
|
||||||
|
complete: updates,
|
||||||
|
width: buttonWidth
|
||||||
|
) {
|
||||||
|
updates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
StepView(
|
||||||
|
title: .setupIntegrationsTitle,
|
||||||
|
description: .setupIntegrationsDescription,
|
||||||
|
systemImage: "firewall",
|
||||||
|
) {
|
||||||
|
SetupButton(
|
||||||
|
.setupIntegrationsButton,
|
||||||
|
complete: integrations,
|
||||||
|
width: buttonWidth
|
||||||
|
) {
|
||||||
|
showingIntegrations = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onPreferenceChange(SetupButton.WidthKey.self) { width in
|
||||||
|
buttonWidth = width
|
||||||
|
}
|
||||||
|
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.frame(minWidth: 600, maxWidth: .infinity)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(.setupDoneButton) {
|
||||||
|
setupComplete = true
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(!allDone)
|
||||||
|
.primaryButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled()
|
||||||
|
.padding()
|
||||||
|
.sheet(isPresented: $showingIntegrations, onDismiss: {
|
||||||
|
integrations = true
|
||||||
|
}, content: {
|
||||||
|
IntegrationsView()
|
||||||
|
.frame(minWidth: 500, minHeight: 400)
|
||||||
|
})
|
||||||
|
.frame(idealWidth: 600)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SetupButton: View {
|
||||||
|
|
||||||
|
struct WidthKey: @MainActor PreferenceKey {
|
||||||
|
@MainActor static var defaultValue: CGFloat? = nil
|
||||||
|
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||||
|
if let next = nextValue(), next > (value ?? -1) {
|
||||||
|
value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
let label: LocalizedStringResource
|
||||||
|
let complete: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
let width: CGFloat?
|
||||||
|
@State var currentWidth: CGFloat?
|
||||||
|
|
||||||
|
init(_ label: LocalizedStringResource, complete: Bool, width: CGFloat? = nil, action: @escaping () -> Void) {
|
||||||
|
self.label = label
|
||||||
|
self.complete = complete
|
||||||
|
self.action = action
|
||||||
|
self.width = width
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if complete {
|
||||||
|
Text(.setupStepCompleteButton)
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
} else {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: width)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.onGeometryChange(for: CGFloat.self) { proxy in
|
||||||
|
proxy.size.width
|
||||||
|
} action: { newValue in
|
||||||
|
currentWidth = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preference(key: WidthKey.self, value: currentWidth)
|
||||||
|
.primaryButton()
|
||||||
|
.disabled(complete)
|
||||||
|
.tint(complete ? .green : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StepView<Content: View>: View {
|
||||||
|
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let icon: Image
|
||||||
|
let description: LocalizedStringResource
|
||||||
|
let detail: LocalizedStringResource?
|
||||||
|
let actions: Content
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: LocalizedStringResource,
|
||||||
|
description: LocalizedStringResource,
|
||||||
|
detail: LocalizedStringResource? = nil,
|
||||||
|
systemImage: String,
|
||||||
|
actions: () -> Content
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.icon = Image(systemName: systemImage)
|
||||||
|
self.description = description
|
||||||
|
self.detail = detail
|
||||||
|
self.actions = actions()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
icon
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 24)
|
||||||
|
Spacer()
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.bold()
|
||||||
|
Text(description)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let detail {
|
||||||
|
Text(detail)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.font(.callout)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 20)
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SetupView {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")!
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SetupView(setupComplete: .constant(false))
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
struct ToolConfigurationView: View {
|
||||||
|
|
||||||
|
private let instructions = Instructions()
|
||||||
|
let selectedInstruction: ConfigurationFileInstructions
|
||||||
|
|
||||||
|
@Environment(\.secretStoreList) private var secretStoreList
|
||||||
|
|
||||||
|
@State var creating = false
|
||||||
|
@State var selectedSecret: AnySecret?
|
||||||
|
|
||||||
|
init(selectedInstruction: ConfigurationFileInstructions) {
|
||||||
|
self.selectedInstruction = selectedInstruction
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
if selectedInstruction.requiresSecret {
|
||||||
|
if secretStoreList.allSecrets.isEmpty {
|
||||||
|
Section {
|
||||||
|
Text(.integrationsConfigureUsingSecretEmptyCreate)
|
||||||
|
if let store = secretStoreList.modifiableStore {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(.createSecretTitle) {
|
||||||
|
creating = true
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $creating) {
|
||||||
|
CreateSecretView(store: store) { created in
|
||||||
|
selectedSecret = created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
Picker(.integrationsConfigureUsingSecretSecretTitle, selection: $selectedSecret) {
|
||||||
|
if selectedSecret == nil {
|
||||||
|
Text(.integrationsConfigureUsingSecretNoSecret)
|
||||||
|
.tag(nil as (AnySecret?))
|
||||||
|
}
|
||||||
|
ForEach(secretStoreList.allSecrets) { secret in
|
||||||
|
Text(secret.name)
|
||||||
|
.tag(secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(.integrationsConfigureUsingSecretHeader)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
selectedSecret = secretStoreList.allSecrets.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(selectedInstruction.steps) { stepGroup in
|
||||||
|
Section {
|
||||||
|
ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path))
|
||||||
|
ForEach(stepGroup.steps, id: \.self.key) { step in
|
||||||
|
ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(String(localized: step))) {
|
||||||
|
HStack {
|
||||||
|
Text(placeholdersReplaced(text: String(localized: step)))
|
||||||
|
.padding(8)
|
||||||
|
.font(.system(.subheadline, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(.black.opacity(0.05))
|
||||||
|
.stroke(.separator, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if let note = stepGroup.note {
|
||||||
|
Text(note)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let url = selectedInstruction.website {
|
||||||
|
Section {
|
||||||
|
Link(destination: url) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(.integrationsWebLink)
|
||||||
|
.font(.headline)
|
||||||
|
Text(url.absoluteString)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholdersReplaced(text: String) -> String {
|
||||||
|
guard let selectedSecret else { return text }
|
||||||
|
let writer = OpenSSHPublicKeyWriter()
|
||||||
|
let fileController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
||||||
|
return text
|
||||||
|
.replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: writer.openSSHString(secret: selectedSecret))
|
||||||
|
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: fileController.publicKeyPath(for: selectedSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SecretKit
|
|
||||||
import SecureEnclaveSecretKit
|
|
||||||
import SmartCardSecretKit
|
|
||||||
import Brief
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
|
|
||||||
@Binding var showingCreation: Bool
|
|
||||||
@Binding var runningSetup: Bool
|
|
||||||
@Binding var hasRunSetup: Bool
|
|
||||||
@State var showingAgentInfo = false
|
|
||||||
@State var activeSecret: AnySecret?
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
|
|
||||||
@Environment(\.secretStoreList) private var storeList
|
|
||||||
@Environment(\.updater) private var updater: any UpdaterProtocol
|
|
||||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
|
||||||
|
|
||||||
@State private var selectedUpdate: Release?
|
|
||||||
@State private var showingAppPathNotice = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
if storeList.anyAvailable {
|
|
||||||
StoreListView(activeSecret: $activeSecret)
|
|
||||||
} else {
|
|
||||||
NoStoresView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minWidth: 640, minHeight: 320)
|
|
||||||
.toolbar {
|
|
||||||
toolbarItem(updateNoticeView, id: "update")
|
|
||||||
toolbarItem(runningOrRunSetupView, id: "setup")
|
|
||||||
toolbarItem(appPathNoticeView, id: "appPath")
|
|
||||||
toolbarItem(newItemView, id: "new")
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $runningSetup) {
|
|
||||||
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ContentView {
|
|
||||||
|
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
|
||||||
func toolbarItem(_ view: some View, id: String) -> some ToolbarContent {
|
|
||||||
if #available(macOS 26.0, *) {
|
|
||||||
ToolbarItem(id: id) { view }
|
|
||||||
.sharedBackgroundVisibility(.hidden)
|
|
||||||
} else {
|
|
||||||
ToolbarItem(id: id) { view }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var needsSetup: Bool {
|
|
||||||
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message
|
|
||||||
/// These two are mutually exclusive
|
|
||||||
@ViewBuilder
|
|
||||||
var runningOrRunSetupView: some View {
|
|
||||||
if needsSetup {
|
|
||||||
setupNoticeView
|
|
||||||
} else {
|
|
||||||
runningNoticeView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var updateNoticeContent: (LocalizedStringResource, Color)? {
|
|
||||||
guard let update = updater.update else { return nil }
|
|
||||||
if update.critical {
|
|
||||||
return (.updateCriticalNoticeTitle, .red)
|
|
||||||
} else {
|
|
||||||
if updater.testBuild {
|
|
||||||
return (.updateTestNoticeTitle, .blue)
|
|
||||||
} else {
|
|
||||||
return (.updateNormalNoticeTitle, .orange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var updateNoticeView: some View {
|
|
||||||
if let update = updater.update, let (text, color) = updateNoticeContent {
|
|
||||||
Button(action: {
|
|
||||||
selectedUpdate = update
|
|
||||||
}, label: {
|
|
||||||
Text(text)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
})
|
|
||||||
.buttonStyle(ToolbarButtonStyle(color: color))
|
|
||||||
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
|
|
||||||
UpdateDetailView(update: update)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var newItemView: some View {
|
|
||||||
if storeList.modifiableStore?.isAvailable ?? false {
|
|
||||||
Button(action: {
|
|
||||||
showingCreation = true
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
})
|
|
||||||
.sheet(isPresented: $showingCreation) {
|
|
||||||
if let modifiable = storeList.modifiableStore {
|
|
||||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
|
||||||
.onDisappear {
|
|
||||||
guard let newest = modifiable.secrets.last else { return }
|
|
||||||
activeSecret = newest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var setupNoticeView: some View {
|
|
||||||
Button(action: {
|
|
||||||
runningSetup = true
|
|
||||||
}, label: {
|
|
||||||
Group {
|
|
||||||
if hasRunSetup && !agentStatusChecker.running {
|
|
||||||
Text(.agentNotRunningNoticeTitle)
|
|
||||||
} else {
|
|
||||||
Text(.agentSetupNoticeTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
})
|
|
||||||
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var runningNoticeView: some View {
|
|
||||||
Button(action: {
|
|
||||||
showingAgentInfo = true
|
|
||||||
}, label: {
|
|
||||||
HStack {
|
|
||||||
Text(.agentRunningNoticeTitle)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
|
||||||
Circle()
|
|
||||||
.frame(width: 10, height: 10)
|
|
||||||
.foregroundColor(Color.green)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
|
|
||||||
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
|
|
||||||
VStack {
|
|
||||||
Text(.agentRunningNoticeDetailTitle)
|
|
||||||
.font(.title)
|
|
||||||
.padding(5)
|
|
||||||
Text(.agentRunningNoticeDetailDescription)
|
|
||||||
.frame(width: 300)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var appPathNoticeView: some View {
|
|
||||||
if !ApplicationDirectoryController().isInApplicationsDirectory {
|
|
||||||
Button(action: {
|
|
||||||
showingAppPathNotice = true
|
|
||||||
}, label: {
|
|
||||||
Group {
|
|
||||||
Text(.appNotInApplicationsNoticeTitle)
|
|
||||||
}
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
})
|
|
||||||
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
|
||||||
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
|
|
||||||
VStack {
|
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: 64)
|
|
||||||
Text(.appNotInApplicationsNoticeDetailDescription)
|
|
||||||
.frame(maxWidth: 300)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var attachmentAnchor: PopoverAttachmentAnchor {
|
|
||||||
// Ideally .point(.bottom), but broken on Sonoma (FB12726503)
|
|
||||||
.rect(.bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
Group {
|
|
||||||
// Empty on modifiable and nonmodifiable
|
|
||||||
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
|
|
||||||
.environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
|
|
||||||
.environment(PreviewUpdater())
|
|
||||||
|
|
||||||
// 5 items on modifiable and nonmodifiable
|
|
||||||
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
|
|
||||||
.environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
|
|
||||||
.environment(PreviewUpdater())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
94
Sources/Secretive/Views/Modifiers/ActionButtonStyle.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PrimaryButtonModifier: ViewModifier {
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@Environment(\.isEnabled) var isEnabled
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
// Tinted glass prominent is really hard to read on 26.0.
|
||||||
|
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
|
||||||
|
content.buttonStyle(.glassProminent)
|
||||||
|
} else {
|
||||||
|
content.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func primaryButton() -> some View {
|
||||||
|
modifier(PrimaryButtonModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolbarCircleButtonModifier: ViewModifier {
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
content
|
||||||
|
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func toolbarCircleButton() -> some View {
|
||||||
|
modifier(ToolbarCircleButtonModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NormalButtonModifier: ViewModifier {
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
content.buttonStyle(.glass)
|
||||||
|
} else {
|
||||||
|
content.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func normalButton() -> some View {
|
||||||
|
modifier(NormalButtonModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DangerButtonModifier: ViewModifier {
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
// Tinted glass prominent is really hard to read on 26.0.
|
||||||
|
if #available(macOS 26.0, *), colorScheme == .dark {
|
||||||
|
content.buttonStyle(.glassProminent)
|
||||||
|
.tint(.red)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
} else {
|
||||||
|
content.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func danger() -> some View {
|
||||||
|
modifier(DangerButtonModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
32
Sources/Secretive/Views/Modifiers/BoxBackgroundStyle.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BoxBackgroundModifier: ViewModifier {
|
||||||
|
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.fill(color.opacity(0.3))
|
||||||
|
.stroke(color, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func boxBackground(color: Color) -> some View {
|
||||||
|
modifier(BoxBackgroundModifier(color: color))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
Text("Hello")
|
||||||
|
.boxBackground(color: .red)
|
||||||
|
.padding()
|
||||||
|
Text("Hello")
|
||||||
|
.boxBackground(color: .orange)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
19
Sources/Secretive/Views/Modifiers/ErrorStyle.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ErrorStyleModifier: ViewModifier {
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func errorStyle() -> some View {
|
||||||
|
modifier(ErrorStyleModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ToolbarButtonStyle: ButtonStyle {
|
struct ToolbarStatusButtonStyle: ButtonStyle {
|
||||||
|
|
||||||
private let lightColor: Color
|
private let lightColor: Color
|
||||||
private let darkColor: Color
|
private let darkColor: Color
|
||||||
@@ -39,6 +39,7 @@ struct ToolbarButtonStyle: ButtonStyle {
|
|||||||
} else {
|
} else {
|
||||||
configuration
|
configuration
|
||||||
.label
|
.label
|
||||||
|
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
|
||||||
.background(colorScheme == .light ? lightColor : darkColor)
|
.background(colorScheme == .light ? lightColor : darkColor)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
@@ -55,3 +56,24 @@ struct ToolbarButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ToolbarButtonStyle: PrimitiveButtonStyle {
|
||||||
|
|
||||||
|
var tint: Color = .white.opacity(0.1)
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
configuration
|
||||||
|
.label
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.glassEffect(.regular.interactive().tint(tint))
|
||||||
|
} else {
|
||||||
|
BorderedButtonStyle().makeBody(configuration: configuration)
|
||||||
|
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,13 +4,15 @@ import SecretKit
|
|||||||
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
||||||
|
|
||||||
@State var store: StoreType
|
@State var store: StoreType
|
||||||
@Binding var showing: Bool
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
var createdSecret: (AnySecret?) -> Void
|
||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var keyAttribution = ""
|
@State private var keyAttribution = ""
|
||||||
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
|
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
|
||||||
@State private var keyType: KeyType?
|
@State private var keyType: KeyType?
|
||||||
@State var advanced = false
|
@State var advanced = false
|
||||||
|
@State var errorText: String?
|
||||||
|
|
||||||
private var authenticationOptions: [AuthenticationRequirement] {
|
private var authenticationOptions: [AuthenticationRequirement] {
|
||||||
if advanced || authenticationRequirement == .biometryCurrent {
|
if advanced || authenticationRequirement == .biometryCurrent {
|
||||||
@@ -26,7 +28,7 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
Section {
|
Section {
|
||||||
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Picker(.createSecretRequireAuthenticationTitle, selection: $authenticationRequirement) {
|
Picker(.createSecretProtectionLevelTitle, selection: $authenticationRequirement) {
|
||||||
ForEach(authenticationOptions) { option in
|
ForEach(authenticationOptions) { option in
|
||||||
HStack {
|
HStack {
|
||||||
switch option {
|
switch option {
|
||||||
@@ -64,7 +66,7 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
Text(.createSecretBiometryCurrentWarning)
|
Text(.createSecretBiometryCurrentWarning)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
|
.boxBackground(color: .red)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -73,17 +75,30 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
Section {
|
Section {
|
||||||
VStack {
|
VStack {
|
||||||
Picker(.createSecretKeyTypeLabel, selection: $keyType) {
|
Picker(.createSecretKeyTypeLabel, selection: $keyType) {
|
||||||
ForEach(store.supportedKeyTypes, id: \.self) { option in
|
ForEach(store.supportedKeyTypes.available, id: \.self) { option in
|
||||||
Text(String(describing: option))
|
Text(String(describing: option))
|
||||||
.tag(option)
|
.tag(option)
|
||||||
.font(.caption)
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(store.supportedKeyTypes.unavailable, id: \.keyType) { option in
|
||||||
|
VStack {
|
||||||
|
Button {
|
||||||
|
} label: {
|
||||||
|
Text(String(describing: option.keyType))
|
||||||
|
switch option.reason {
|
||||||
|
case .macOSUpdateRequired:
|
||||||
|
Text(.createSecretKeyTypeMacOSUpdateRequiredLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.selectionDisabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keyType?.algorithm == .mldsa {
|
if keyType?.algorithm == .mldsa {
|
||||||
Text(.createSecretMldsaWarning)
|
Text(.createSecretMldsaWarning)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
|
.boxBackground(color: .orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -94,22 +109,30 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let errorText {
|
||||||
|
Section {
|
||||||
|
} footer: {
|
||||||
|
Text(verbatim: errorText)
|
||||||
|
.errorStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
|
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
|
||||||
.toggleStyle(.button)
|
.toggleStyle(.button)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(.createSecretCancelButton, role: .cancel) {
|
Button(.createSecretCancelButton, role: .cancel) {
|
||||||
showing = false
|
dismiss()
|
||||||
}
|
}
|
||||||
Button(.createSecretCreateButton, action: save)
|
Button(.createSecretCreateButton, action: save)
|
||||||
.primary()
|
.keyboardShortcut(.return)
|
||||||
|
.primaryButton()
|
||||||
.disabled(name.isEmpty)
|
.disabled(name.isEmpty)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
keyType = store.supportedKeyTypes.first
|
keyType = store.supportedKeyTypes.available.first
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
@@ -117,20 +140,25 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
func save() {
|
func save() {
|
||||||
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
||||||
Task {
|
Task {
|
||||||
try! await store.create(
|
do {
|
||||||
name: name,
|
let new = try await store.create(
|
||||||
attributes: .init(
|
name: name,
|
||||||
keyType: keyType!,
|
attributes: .init(
|
||||||
authentication: authenticationRequirement,
|
keyType: keyType!,
|
||||||
publicKeyAttribution: attribution
|
authentication: authenticationRequirement,
|
||||||
|
publicKeyAttribution: attribution
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
createdSecret(AnySecret(new))
|
||||||
showing = false
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
errorText = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
//#Preview {
|
||||||
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
|
// CreateSecretView(store: Preview.StoreModifiable()) { _ in }
|
||||||
}
|
//}
|
||||||
@@ -28,8 +28,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
|
|||||||
TextField(secret.name, text: $confirmedSecretName)
|
TextField(secret.name, text: $confirmedSecretName)
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(verbatim: errorText)
|
Text(verbatim: errorText)
|
||||||
.foregroundStyle(.red)
|
.errorStyle()
|
||||||
.font(.callout)
|
|
||||||
}
|
}
|
||||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||||
.disabled(confirmedSecretName != secret.name)
|
.disabled(confirmedSecretName != secret.name)
|
||||||
@@ -5,16 +5,16 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
|
|
||||||
let store: StoreType
|
let store: StoreType
|
||||||
let secret: StoreType.SecretType
|
let secret: StoreType.SecretType
|
||||||
let dismissalBlock: (_ renamed: Bool) -> ()
|
|
||||||
|
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var publicKeyAttribution: String
|
@State private var publicKeyAttribution: String
|
||||||
@State var errorText: String?
|
@State var errorText: String?
|
||||||
|
|
||||||
init(store: StoreType, secret: StoreType.SecretType, dismissalBlock: @escaping (Bool) -> ()) {
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
init(store: StoreType, secret: StoreType.SecretType) {
|
||||||
self.store = store
|
self.store = store
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
self.dismissalBlock = dismissalBlock
|
|
||||||
name = secret.name
|
name = secret.name
|
||||||
publicKeyAttribution = secret.publicKeyAttribution ?? ""
|
publicKeyAttribution = secret.publicKeyAttribution ?? ""
|
||||||
}
|
}
|
||||||
@@ -30,21 +30,22 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
} footer: {
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(verbatim: errorText)
|
Text(verbatim: errorText)
|
||||||
.foregroundStyle(.red)
|
.errorStyle()
|
||||||
.font(.callout)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
|
Button(.editCancelButton) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
Button(.editSaveButton, action: rename)
|
Button(.editSaveButton, action: rename)
|
||||||
.disabled(name.isEmpty)
|
.disabled(name.isEmpty)
|
||||||
.keyboardShortcut(.return)
|
.keyboardShortcut(.return)
|
||||||
Button(.editCancelButton) {
|
.primaryButton()
|
||||||
dismissalBlock(false)
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.cancelAction)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await store.update(secret: secret, name: name, attributes: attributes)
|
try await store.update(secret: secret, name: name, attributes: attributes)
|
||||||
dismissalBlock(true)
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
errorText = error.localizedDescription
|
errorText = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,8 @@ struct EmptyStoreImmutableView: View {
|
|||||||
|
|
||||||
struct EmptyStoreModifiableView: View {
|
struct EmptyStoreModifiableView: View {
|
||||||
|
|
||||||
|
@Environment(\.justUpdatedChecker) var justUpdatedChecker
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { windowGeometry in
|
GeometryReader { windowGeometry in
|
||||||
VStack {
|
VStack {
|
||||||
@@ -51,21 +53,35 @@ struct EmptyStoreModifiableView: View {
|
|||||||
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
|
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
|
||||||
Text(.emptyStoreModifiableClickHereTitle).bold()
|
Text(.emptyStoreModifiableClickHereTitle).bold()
|
||||||
Text(.emptyStoreModifiableClickHereDescription)
|
Text(.emptyStoreModifiableClickHereDescription)
|
||||||
|
if justUpdatedChecker.justUpdatedOS {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text(.emptyStoreModifiableEmptyOsWarningTitle)
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
Text(.emptyStoreModifiableEmptyOsWarningDescription)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.boxBackground(color: .orange)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
|
|
||||||
struct EmptyStoreModifiableView_Previews: PreviewProvider {
|
#Preview {
|
||||||
static var previews: some View {
|
EmptyStoreImmutableView()
|
||||||
Group {
|
}
|
||||||
EmptyStoreImmutableView()
|
#Preview {
|
||||||
EmptyStoreModifiableView()
|
EmptyStoreImmutableView()
|
||||||
}
|
// .environment(\.justUpdatedChecker, <#T##value: V##V#>)
|
||||||
}
|
}
|
||||||
|
#Preview {
|
||||||
|
EmptyStoreModifiableView()
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -13,12 +13,7 @@ struct NoStoresView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#Preview {
|
||||||
|
NoStoresView()
|
||||||
struct NoStoresView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NoStoresView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
|
||||||