Compare commits

..

63 Commits

Author SHA1 Message Date
Max Goedjen
688b6380bd Fix compiler diff for unsafe 2026-06-19 23:28:11 -07:00
Max Goedjen
702e3f2cb0 UI tweaks 2026-06-19 23:24:53 -07:00
Max Goedjen
76baba746c Fix compiler diff for unsafe 2026-06-19 22:39:20 -07:00
Max Goedjen
18107257ba Xcode 26 change 2026-06-19 22:02:53 -07:00
Max Goedjen
98e2f38e46 WIP 2026-06-19 21:53:19 -07:00
Max Goedjen
a727d110c8 Fix typo in nightly.yml (#807)
Bad copy pasta, thanks @
2026-05-12 19:34:13 +00:00
Max Goedjen
fbc4133f39 Update to use actions/attest (#806)
* Update to use actions/attest

* Update oneoff.yml

* Update release

* Update nightly
2026-05-11 20:55:14 +00:00
Max Goedjen
437386b87e Expand parsing + bug fixes for cert UI (#802)
* Expand parsing and display of cert types, some additional cleanup

* Tweak cert
2026-05-07 03:01:40 +00:00
Max Goedjen
9bdf9775d2 Fix xpc signing (#801)
* Fix deployment version for new xpc service

* Fix signing on xpc service
2026-05-06 22:09:33 +00:00
Max Goedjen
03a31fb474 Fix deployment version for new xpc service (#800) 2026-05-06 14:57:09 -07:00
Max Goedjen
b337b24641 Certificate UI/Import (#798)
* Sketching out.

* WIP

* WIP

* Dump

* Apply stash

* Merge + WIP

* UI

* More WIP

* Agent config

* UI cleanup

* Restore dirty files

* XPC

* Edit/delete

* UI fixes

* Cleanup

* Change id for OpenSSHCertificate to hex of md5

* Fix runtime warning for confirmation dialog

* Mark strings as reviewed

* Cleanup

* Fix agent tests
2026-05-06 08:03:21 +00:00
Max Goedjen
2f4d10d70d Establish AI/LLM contribution policy (#799)
Added a policy regarding the use of AI or LLM tools for contributions.
2026-05-06 00:07:04 -07:00
Max Goedjen
4033a5b947 Xcode 26.4 Updates (#793)
* Bump CI to 26.4

* Update entitlements

* Move inputParser inside task scope to avoid races.

* Update project settings.
2026-03-12 19:48:12 +00:00
Max Goedjen
2cc0157290 Fix internet access policy linking (#794) 2026-03-11 22:16:06 +00:00
Guilherme Rambo
faa622e379 Project setup to facilitate external contributions (#783) 2026-01-06 16:35:04 +00:00
Max Goedjen
9f2c6d9e84 Add hardware sec flags (#781)
* Add hardware sec flags

* Add hardware sec flags to xpc too
2025-12-30 21:18:22 +00:00
Thijs Mergaert
afb48529c7 Implement LAContext persistence for SmartCardStore (signature fixed) (#760)
* Implement LAContext persistence for SmartCardStore

* Consolidating persistence

---------

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2025-12-14 21:13:57 +00:00
Sergei Razmetov
6c56039ece Fix SSH ECDSA signature mpint encoding (#772)
* Fix SSH ECDSA signature mpint encoding

OpenSSHSignatureWriter was emitting non-canonical mpints (keeping
fixed-width leading 0x00 bytes), which breaks strict parsers.
Canonicalize r/s mpints and add a regression test.

* Cleanup and consolidation

---------

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2025-12-14 20:00:34 +00:00
Max Goedjen
2b712864d6 Pulling out a bunch of openssh stuff to dedicated package. (#775) 2025-12-14 19:54:56 +00:00
Max Goedjen
845b1ec313 Reorder modifiers to fix context menus on macOS 14 (#774) 2025-12-14 04:29:06 +00:00
Max Goedjen
595de41f03 Update runners to 26.2 (#773) 2025-12-13 23:29:26 +00:00
Jamie
d82f404166 [fix]: eliminate race condition in SocketController (#769)
* [fix]: eliminate race condition in SocketController

* [refactor]: use sequence- rather than iterator-based iteration

* [fix]: remove now-superfluous await

---------

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2025-12-06 15:27:45 -08:00
Max Goedjen
a3bfcb316c Add support for one-off builds instead of using nightly workflow (#768) 2025-11-29 21:40:29 +00:00
Max Goedjen
bba4fb9e7c Update runners to use Xcode 26.1 (#767)
* Update images to 26.1 builders

* Try running tests via spm again

* Revert "Try running tests via spm again"

This reverts commit ec9cb609dc.
2025-11-29 21:36:41 +00:00
Max Goedjen
32a1a0bca9 Use separate socket for debug builds (#766) 2025-11-29 21:32:34 +00:00
Max Goedjen
bb0b6d8dc3 Run filehandle listening methods in main actor directly vs jumping (#765) 2025-11-27 20:00:53 +00:00
Max Goedjen
c63d87cbec Fix bug where connection would be closed without returning unhandled response for unknown messages (#747) 2025-10-25 18:28:50 +00:00
Max Goedjen
65bc6c1a69 Fix allowedsigners formatting (#744) 2025-10-08 04:25:55 +00:00
Max Goedjen
275b6ef9bb Fix bug where agent could relaunch after being disabled (#743) 2025-10-07 20:54:38 -07:00
Max Goedjen
f13bc23991 Pull in updated localizations (#742) 2025-10-07 20:52:35 -07:00
Max Goedjen
3a67d59519 Fix key copying. (#738) 2025-10-01 07:00:51 +00:00
Max Goedjen
d9a3f0c813 Change how agent launch/relaunch is performed (#737) 2025-10-01 06:57:06 +00:00
Max Goedjen
84d5a56fb0 Zip parent directory to prevent double-zip/confusing attestation (#732)
* .

* Test

* Release

* Release

* Release

* -r

* ls

* Fix yml

* Path

* Path

* Path

* List

* Zip direct

* Zip direct

* Sha

* sha

* Zip direct

* Auth.

* .

* .

* .

* .

* .

* .
2025-09-28 01:02:45 +00:00
Daniel Néri
3bb0cc4a0e Fix attestation of release zip file (#731) 2025-09-26 07:15:11 +00:00
Max Goedjen
516e37fdde Fix primitive button style trigger (#727) 2025-09-23 05:27:45 +00:00
Max Goedjen
f9dc947b59 Actually fix URL for about screen (#722) 2025-09-17 05:05:00 +00:00
Max Goedjen
9c042d1956 Update strings and remove base localization (#721)
* Update strings

* Remove base localization.
2025-09-17 00:45:10 +00:00
Max Goedjen
08e0c4b63b Update readme for keychain notes (#719)
Clarified the description of key storage and access in Secretive.
2025-09-15 04:20:57 +00:00
Max Goedjen
f80cbdaf04 Fix xcconfig build url parsing. (#718) 2025-09-15 03:14:17 +00:00
Max Goedjen
7a53a85615 Readme updates (#717)
* Readme tweaks

* Delete .github/readme/localize_add.png

* Delete .github/readme/localize_sidebar.png

* Delete .github/readme/localize_translate.png

* Add files via upload

* Add files via upload

* Add files via upload

* Update README for image source based on color scheme
2025-09-15 02:38:29 +00:00
Max Goedjen
1f74bd814f Include tag name in release upload command (#716) 2025-09-14 23:28:56 +00:00
Max Goedjen
d9d93574f2 Fix repeat setup (#712)
* Fix repeat setup

* Ideal width
2025-09-14 23:15:32 +00:00
Max Goedjen
15e8ed1ec2 Fix issue where “mark as migrated” could fail (#715) 2025-09-14 23:11:54 +00:00
Max Goedjen
1df0c8e96b Switch to icon composer source. (#714) 2025-09-14 23:01:18 +00:00
Max Goedjen
8213a8b451 Ideal width (#713) 2025-09-14 22:41:05 +00:00
Max Goedjen
af77fd4a21 Fix release digest formatting (#711) 2025-09-14 22:17:53 +00:00
Max Goedjen
85d0cab0f5 Disable preview (#710) 2025-09-14 21:53:13 +00:00
Max Goedjen
e8cdcdfb7f Adding some size fixing (#709) 2025-09-14 21:48:22 +00:00
Max Goedjen
d7f8d5e56b Add descriptions for unavailable keys (#708)
* Describe unavailable key types

* Cleanup
2025-09-14 21:42:41 +00:00
Max Goedjen
3f247d628f About screen. (#707) 2025-09-14 21:39:20 +00:00
Vladimir
dae9cead4e Update Russian localization (#706) 2025-09-14 21:05:34 +00:00
Max Goedjen
fe9f8613fa Fix move app later text (#705) 2025-09-14 08:47:40 +00:00
Max Goedjen
5d5ae5bab4 Add app folder notice. (#704) 2025-09-14 08:43:00 +00:00
Max Goedjen
f76766a9d5 Updater UI (#703)
* Parse markdown oop

* Update UI.

* Tweaks.
2025-09-14 08:20:10 +00:00
Max Goedjen
b308b10716 UI tweaks. (#701) 2025-09-14 00:03:20 +00:00
Max Goedjen
0e1e6813a1 Readme updates (#700) 2025-09-13 22:50:05 +00:00
Max Goedjen
27bf7c29e4 Fix deployment version for xpc services (#699) 2025-09-13 11:56:37 -07:00
Max Goedjen
36b6c52979 Logging for xpc input parser (#698) 2025-09-13 16:45:51 +00:00
Max Goedjen
67ec4fee12 More UI tweaks and fixes (#697)
* Integrations to window

* Cleanup of presenting.

* Older name for copy

* For copyable view too
2025-09-13 08:16:23 +00:00
Max Goedjen
21fc834fd9 Fix incorrect deletion of tracked files in public key standin folder. (#696) 2025-09-13 01:52:17 +00:00
Max Goedjen
726d0580d0 Fix minor ui glitches on older macOS (#695)
* Fix padding on toolbar buttons

* Fix sizing on setup view.
2025-09-12 08:40:57 +00:00
Max Goedjen
4f608ebbc6 Clear out needs review status (#694) 2025-09-12 01:57:09 +00:00
Max Goedjen
6e7cf82618 Fix quotes (#693)
* Fix up strings (hopefully)

* Few more

* Fixed back sides
2025-09-12 01:46:20 +00:00
127 changed files with 5768 additions and 1973 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 230 KiB

View File

@@ -37,7 +37,7 @@ jobs:
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual' - if: matrix.build-mode == 'manual'
name: "Select Xcode" name: "Select Xcode"
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- if: matrix.build-mode == 'manual' - if: matrix.build-mode == 'manual'
name: "Build" name: "Build"
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO

View File

@@ -3,7 +3,6 @@ name: Nightly
on: on:
schedule: schedule:
- cron: "0 8 * * *" - cron: "0 8 * * *"
workflow_dispatch:
jobs: jobs:
build: build:
@@ -12,6 +11,8 @@ jobs:
id-token: write id-token: write
contents: write contents: write
attestations: write attestations: write
artifact-metadata: write
actions: read
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
@@ -25,7 +26,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Update Build Number - name: Update Build Number
env: env:
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}
@@ -33,26 +34,33 @@ jobs:
DATE=$(date "+%Y-%m-%d") 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_CI_VERSION/0.0.0_nightly-$DATE/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
- name: Build - name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIP - name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v7
with:
name: Secretive
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
- name: Notarize - name: Notarize
env: env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: 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@v4
with: with:
subject-name: "Secretive.zip" subject-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }} subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}

65
.github/workflows/oneoff.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: One-Off Build
on:
workflow_dispatch:
jobs:
build:
runs-on: macos-26
permissions:
id-token: write
contents: write
attestations: write
artifact-metadata: write
actions: read
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Update Build Number
env:
RUN_ID: ${{ github.run_id }}
run: |
DATE=$(date "+%Y-%m-%d")
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_oneoff-$DATE/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v7
with:
name: Secretive
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Attest
id: attest
uses: actions/attest@v4
with:
subject-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}

View File

@@ -22,7 +22,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Test - name: Test
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently? # SPM doesn't seem to pick up on the tests currently?
@@ -32,6 +32,8 @@ jobs:
id-token: write id-token: write
contents: write contents: write
attestations: write attestations: write
artifact-metadata: write
actions: read
runs-on: macos-26 runs-on: macos-26
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
@@ -46,7 +48,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment - name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
- name: Update Build Number - name: Update Build Number
env: env:
TAG_NAME: ${{ github.ref }} TAG_NAME: ${{ github.ref }}
@@ -55,37 +57,43 @@ jobs:
export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//') export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//')
sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf sed -i '' -e "s/GITHUB_BUILD_URL/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Config/Config.xcconfig
- name: Build - name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIP - name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v7
with:
name: Secretive.zip
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip curl -L -H "Authorization: Bearer $GITHUB_TOKEN" -L \
https://api.github.com/repos/maxgoedjen/secretive/actions/artifacts/$ZIP_ID/zip > Secretive.zip
- name: Notarize - name: Notarize
env: env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: 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@v4
with: with:
subject-name: "Secretive.zip" subject-path: "Secretive.zip"
subject-digest: ${{ steps.upload.outputs.artifact-digest }}
- name: Create Release - name: Create Release
run: |
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
gh release create $TAG_NAME -d -F .github/templates/release.md
gh release upload Secretive.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref }} TAG_NAME: ${{ github.ref }}
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }} ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
run: |
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
gh release create $TAG_NAME -d -F .github/templates/release.md
gh release upload $TAG_NAME Secretive.zip

View File

@@ -10,7 +10,7 @@ jobs:
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.4.app
- name: Test Main Packages - name: Test Main Packages
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently? # SPM doesn't seem to pick up on the tests currently?

4
.gitignore vendored
View File

@@ -93,3 +93,7 @@ iOSInjectionProject/
Archive.xcarchive Archive.xcarchive
.DS_Store .DS_Store
contents.xcworkspacedata contents.xcworkspacedata
# Per-User Configs
Sources/Config/OpenSource.xcconfig

View File

@@ -10,6 +10,10 @@ Security is obviously paramount for a project like Secretive. As such, any contr
Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected. Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected.
### AI/LLM Policy
For security and auditing reasons similar to the policy Secretive has on dependencies, any code generated with AI or LLM tools will not be accepted.
## Code of Conduct ## Code of Conduct
All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md) All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md)

View File

@@ -22,6 +22,15 @@ let package = Package(
.library( .library(
name: "SmartCardSecretKit", name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]), targets: ["SmartCardSecretKit"]),
.library(
name: "CertificateKit",
targets: ["CertificateKit"]),
.library(
name: "SSHProtocolKit",
targets: ["SSHProtocolKit"]),
.library(
name: "Formatters",
targets: ["Formatters"]),
], ],
dependencies: [ dependencies: [
], ],
@@ -53,6 +62,33 @@ let package = Package(
resources: [localization], resources: [localization],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.target(
name: "CertificateKit",
dependencies: ["SecretKit", "Formatters"],
path: "Sources/Packages/Sources/CertificateKit",
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SSHProtocolKit",
dependencies: ["SecretKit", "CertificateKit"],
path: "Sources/Packages/Sources/SSHProtocolKit",
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SSHProtocolKitTests",
dependencies: ["SSHProtocolKit"],
path: "Sources/Packages/Tests/SSHProtocolKitTests",
swiftSettings: swiftSettings,
),
.target(
name: "Formatters",
dependencies: [],
path: "Sources/Packages/Sources/Formatters",
resources: [localization],
swiftSettings: swiftSettings,
),
] ]
) )

View File

@@ -1,11 +1,11 @@
# Secretive [![Test](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg) # Secretive [![Test](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg)
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app. Secretive is an app for protecting and managing SSH keys with the Secure Enclave.
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png"> <source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png">
<img src="/.github/readme/app-light.png" alt="Screenshot of Secretive" width="600"> <source media="(prefers-color-scheme: light)" srcset="/.github/readme/app-light.png">
<img src="/.github/readme/app-dark.png" alt="Screenshot of Secretive" width="600">
</picture> </picture>
@@ -13,7 +13,7 @@ Secretive is an app for storing and managing SSH keys in the Secure Enclave. It
### Safer Storage ### Safer Storage
The most common setup for SSH keys is just keeping them on disk, guarded by proper permissions. This is fine in most cases, but it's not super hard for malicious users or malware to copy your private key. If you store your keys in the Secure Enclave, it's impossible to export them, by design. The most common setup for SSH keys is just keeping them on disk, guarded by proper permissions. This is fine in most cases, but it's not super hard for malicious users or malware to copy your private key. If you protect your keys with the Secure Enclave, it's impossible to export them, by design.
### Access Control ### Access Control
@@ -53,7 +53,7 @@ Builds are produced by GitHub Actions with an auditable build and release genera
### A Note Around Code Signing and Keychains ### A Note Around Code Signing and Keychains
While Secretive uses the Secure Enclave for key storage, it still relies on Keychain APIs to access them. Keychain restricts reads of keys to the app (and specifically, the bundle ID) that created them. If you build Secretive from source, make sure you are consistent in which bundle ID you use so that the Keychain is able to locate your keys. While Secretive uses the Secure Enclave to protect keys, it still relies on Keychain APIs to store and access them. Keychain restricts reads of keys to the app (and specifically, the bundle ID) that created them. If you build Secretive from source, make sure you are consistent in which bundle ID you use so that the Keychain is able to locate your keys.
### Backups and Transfers to New Machines ### Backups and Transfers to New Machines
@@ -62,3 +62,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).

View File

@@ -1,2 +1,8 @@
CI_VERSION = GITHUB_CI_VERSION CI_VERSION = GITHUB_CI_VERSION
CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER
CI_BUILD_LINK = GITHUB_BUILD_URL
#include? "OpenSource.xcconfig"
SECRETIVE_BASE_BUNDLE_ID = $(SECRETIVE_BASE_BUNDLE_ID_OSS:default=com.maxgoedjen.Secretive)
SECRETIVE_DEVELOPMENT_TEAM = $(SECRETIVE_DEVELOPMENT_TEAM_OSS:default=Z72PRUAWF6)

View File

@@ -19,15 +19,30 @@ let package = Package(
.library( .library(
name: "SmartCardSecretKit", name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]), targets: ["SmartCardSecretKit"]),
.library(
name: "CertificateKit",
targets: ["CertificateKit"]),
.library( .library(
name: "SecretAgentKit", name: "SecretAgentKit",
targets: ["SecretAgentKit", "XPCWrappers"]), targets: ["SecretAgentKit"]),
.library(
name: "Formatters",
targets: ["Formatters"]),
.library(
name: "Common",
targets: ["Common"]),
.library(
name: "SharedXPCServices",
targets: ["SharedXPCServices"]),
.library( .library(
name: "Brief", name: "Brief",
targets: ["Brief"]), targets: ["Brief"]),
.library( .library(
name: "XPCWrappers", name: "XPCWrappers",
targets: ["XPCWrappers"]), targets: ["XPCWrappers"]),
.library(
name: "SSHProtocolKit",
targets: ["SSHProtocolKit"]),
], ],
dependencies: [ dependencies: [
], ],
@@ -40,7 +55,7 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "SecretKitTests", name: "SecretKitTests",
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"], dependencies: ["SecretKit", "SecretAgentKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
.target( .target(
@@ -55,9 +70,15 @@ let package = Package(
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
.target(
name: "CertificateKit",
dependencies: ["SecretKit", "Formatters"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target( .target(
name: "SecretAgentKit", name: "SecretAgentKit",
dependencies: ["SecretKit"], dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
@@ -65,9 +86,38 @@ let package = Package(
name: "SecretAgentKitTests", name: "SecretAgentKitTests",
dependencies: ["SecretAgentKit"], dependencies: ["SecretAgentKit"],
), ),
.target(
name: "SSHProtocolKit",
dependencies: ["SecretKit", "CertificateKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SSHProtocolKitTests",
dependencies: ["SSHProtocolKit"],
swiftSettings: swiftSettings,
),
.target(
name: "Formatters",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "Common",
dependencies: ["SSHProtocolKit", "SecretKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SharedXPCServices",
dependencies: ["CertificateKit", "SSHProtocolKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target( .target(
name: "Brief", name: "Brief",
dependencies: ["XPCWrappers"], dependencies: ["XPCWrappers", "SSHProtocolKit"],
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),

File diff suppressed because it is too large Load Diff

View 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"))
}
} }
} }

View File

@@ -36,11 +36,11 @@ import XPCWrappers
self.currentVersion = currentVersion self.currentVersion = currentVersion
Task { Task {
if checkOnLaunch { if checkOnLaunch {
try await checkForUpdates() 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)))
try await checkForUpdates() try? await checkForUpdates()
} }
} }
} }

View File

@@ -0,0 +1,24 @@
import Foundation
import CryptoKit
import Formatters
@dynamicMemberLookup
public struct Certificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible {
public var openSSHCertificate: OpenSSHCertificate
public let rawData: Data
public init(openSSHCertificate: OpenSSHCertificate, rawData: Data) {
self.openSSHCertificate = openSSHCertificate
self.rawData = rawData
}
public var id: String { Insecure.MD5.hash(data: rawData).formatted(.hex(separator: "")) }
public var debugDescription: String { openSSHCertificate.debugDescription }
public subscript<T>(dynamicMember keyPath: KeyPath<OpenSSHCertificate, T>) -> T {
openSSHCertificate[keyPath: keyPath]
}
}

View File

@@ -0,0 +1,153 @@
import Foundation
import Observation
import Security
import os
import SecretKit
@Observable @MainActor public final class CertificateStore: Sendable {
public private(set) var certificates: [Certificate] = []
/// Initializes a Store.
public init() {
loadCertificates()
Task {
for await note in DistributedNotificationCenter.default().notifications(named: .certificateStoreUpdated) {
guard Constants.notificationToken != (note.object as? String) else {
// Don't reload if we're the ones triggering this by reloading.
continue
}
loadCertificates()
}
}
}
public func reloadCertificates() {
let before = certificates
certificates.removeAll()
loadCertificates()
if certificates != before {
NotificationCenter.default.post(name: .certificateStoreReloaded, object: self)
DistributedNotificationCenter.default().postNotificationName(.certificateStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
}
}
public func save(certificate: Certificate) throws {
let attributes = try JSONEncoder().encode(certificate.openSSHCertificate)
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: certificate.id,
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: certificate.rawData,
kSecAttrGeneric: attributes
])
let status = SecItemAdd(keychainAttributes, nil)
if status != errSecSuccess && status != errSecDuplicateItem {
throw KeychainError(statusCode: status)
}
reloadCertificates()
}
public func delete(certificate: Certificate) throws {
let deleteAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: certificate.id,
])
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadCertificates()
}
public func update(certificate: Certificate) throws {
let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrAccount: certificate.id,
])
let cert = try JSONEncoder().encode(certificate.openSSHCertificate)
let updatedAttributes = KeychainDictionary([
kSecAttrGeneric: cert,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadCertificates()
}
public func certificates(for secret: any Secret) -> [Certificate] {
certificates.filter { $0.openSSHCertificate.publicKey.data == secret.publicKey }
}
}
extension CertificateStore {
/// Loads all certificates from the store.
private func loadCertificates() {
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let decoder = JSONDecoder()
let wrapped: [Certificate] = typed.compactMap {
do {
guard let data = $0[kSecValueData] as? Data,
let attributesData = $0[kSecAttrGeneric] as? Data else {
throw MissingAttributesError()
}
return Certificate(openSSHCertificate: try decoder.decode(OpenSSHCertificate.self, from: attributesData), rawData: data)
} catch {
return nil
}
}
.filter {
if let validityRange = $0.validityRange {
validityRange.contains(Date())
} else {
true
}
}
certificates.append(contentsOf: wrapped)
}
}
extension CertificateStore {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = Data("com.maxgoedjen.certificatestore.opensshcertificate".utf8)
static let notificationToken = UUID().uuidString
}
struct UnsupportedAlgorithmError: Error {}
struct MissingAttributesError: Error {}
}
extension NSNotification.Name {
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed certificates)
public static let certificateStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.updated")
// Internal notification that certificates were reloaded from the backing store.
public static let certificateStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.reloaded")
}

View File

@@ -0,0 +1,82 @@
import Foundation
import Formatters
public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, CustomDebugStringConvertible {
public var type: CertificateType
public var name: String
public var data: Data
public var publicKey: PublicKey
public var principals: [String]
public var keyID: String
public var serial: UInt64
public var validityRange: Range<Date>?
public var criticalOptions: [String]
public var extensions: [String]
public var signingKey: PublicKey
public init(
type: OpenSSHCertificate.CertificateType,
name: String,
data: Data,
publicKey: PublicKey,
principals: [String],
keyID: String,
serial: UInt64,
validityRange: Range<Date>? = nil,
criticalOptions: [String],
extensions: [String],
signingKey: PublicKey,
) {
self.type = type
self.name = name
self.data = data
self.publicKey = publicKey
self.principals = principals
self.keyID = keyID
self.serial = serial
self.validityRange = validityRange
self.criticalOptions = criticalOptions
self.extensions = extensions
self.signingKey = signingKey
}
public var debugDescription: String {
"OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))"
}
}
extension OpenSSHCertificate {
public enum CertificateType: String, Sendable, Codable {
case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
public var keyIdentifier: String {
rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
}
}
}
extension OpenSSHCertificate {
public struct PublicKey: Hashable, Sendable, Codable {
public let keyType: String
public let curveName: String
public let data: Data
public init(keyType: String, curveName: String, data: Data) {
self.keyType = keyType
self.curveName = curveName
self.data = data
}
}
}

View File

@@ -0,0 +1,59 @@
import Foundation
import SSHProtocolKit
import CertificateKit
import SecretKit
extension URL {
public static var agentHomeURL: URL {
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
}
public static var socketPath: String {
#if DEBUG
URL.agentHomeURL.appendingPathComponent("socket-debug.ssh").path()
#else
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
#endif
}
public static var publicKeyDirectory: URL {
agentHomeURL.appending(component: "PublicKeys")
}
public static var certificatesDirectory: URL {
agentHomeURL.appending(component: "Certificates")
}
/// The path for a Secret's public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the Secret's public key.
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
public static func publicKeyPath<SecretType: Secret>(for secret: SecretType, in directory: URL) -> String {
let keyWriter = OpenSSHPublicKeyWriter()
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending(component: "\(minimalHex).pub").path()
}
/// The path for a certificate.
/// - Parameter certificate: The Certificate to return the path for.
/// - Returns: The path to the Certificate.
/// - Warning: This method returning a path does not imply that a certificate has been written to disk already. This method only describes where it will be written to.
public static func certificatePath(for certificateID: String, in directory: URL) -> String {
return directory.appending(component: "\(certificateID)-cert.pub").path()
}
}
extension String {
public var normalizedPathAndFolder: (String, String) {
// All foundation-based normalization methods replace this with the container directly.
let processedPath = replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
let url = URL(filePath: processedPath)
let folder = url.deletingLastPathComponent().path()
return (processedPath, folder)
}
}

View File

@@ -0,0 +1,74 @@
import Foundation
import CryptoKit
public struct HexDataStyle<SequenceType: Sequence>: Hashable, Codable {
let separator: String
public init(separator: String) {
self.separator = separator
}
}
extension HexDataStyle: FormatStyle where SequenceType.Element == UInt8 {
public func format(_ value: SequenceType) -> String {
value
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: separator)
}
}
extension FormatStyle where Self == HexDataStyle<Data> {
public static func hex(separator: String = "") -> HexDataStyle<Data> {
HexDataStyle(separator: separator)
}
}
extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
public static func hex(separator: String = ":") -> HexDataStyle<Insecure.MD5Digest> {
HexDataStyle(separator: separator)
}
}
public struct Base64DataStyle<SequenceType: Sequence>: Hashable, Codable {
private let stripPadding: Bool
public init(stripPadding: Bool) {
self.stripPadding = stripPadding
}
}
extension Base64DataStyle: FormatStyle where SequenceType.Element == UInt8 {
public func format(_ value: SequenceType) -> String {
let base64 = Data(value).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
return base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
}
}
extension FormatStyle where Self == Base64DataStyle<Data> {
public static func base64(stripPadding: Bool) -> Base64DataStyle<Data> {
Base64DataStyle(stripPadding: stripPadding)
}
}
extension FormatStyle where Self == Base64DataStyle<SHA256.Digest> {
public static func base64(stripPadding: Bool) -> Base64DataStyle<SHA256.Digest> {
Base64DataStyle(stripPadding: stripPadding)
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
import CryptoKit
import CertificateKit
import Formatters
/// Generates OpenSSH representations of Certificates.
public struct OpenSSHCertificateWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the certificate.
/// - Returns: OpenSSH data payload identifying the certificate.
public func data(publicKey: OpenSSHCertificate.PublicKey) -> Data {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
publicKey.keyType.lengthAndData +
publicKey.curveName.lengthAndData +
publicKey.data.lengthAndData
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256KeyFingerprint(publicKey: OpenSSHCertificate.PublicKey) -> String {
// OpenSSL format seems to strip the padding at the end.
let cleaned = SHA256.hash(data: data(publicKey: publicKey)).formatted(.base64(stripPadding: true))
return "SHA256:\(cleaned)"
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
import CertificateKit
public protocol OpenSSHCertificateParserProtocol {
func parse(data: Data) async throws -> OpenSSHCertificate
}
public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable {
public init() {
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
}
public func parse(data: Data) throws(OpenSSHCertificateError) -> OpenSSHCertificate {
let string = String(decoding: data, as: UTF8.self)
var elements = string
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: " ")
guard elements.count >= 2 else {
throw OpenSSHCertificateError.parsingFailed
}
let typeString = elements.removeFirst()
guard let type = OpenSSHCertificate.CertificateType(rawValue: typeString) else { throw .unsupportedType }
let encodedKey = elements.removeFirst()
guard let decoded = Data(base64Encoded: encodedKey) else {
throw OpenSSHCertificateError.parsingFailed
}
let comment = elements.first
do {
let dataParser = OpenSSHReader(data: decoded)
let publicKeyType = try dataParser.readNextChunkAsString() // Theoretically the same as typeString, but
.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
_ = try dataParser.readNextChunk() // Nonce
let publicKeyCurveName = try dataParser.readNextChunkAsString()
let publicKeyData = try dataParser.readNextChunk()
let publicKey = OpenSSHCertificate.PublicKey(keyType: publicKeyType, curveName: publicKeyCurveName, data: publicKeyData)
let serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true)
_ = role
let keyIdentifier = try dataParser.readNextChunkAsString()
let principalsReader = try dataParser.readNextChunkAsSubReader()
var principals: [String] = []
while !principalsReader.done {
try principals.append(principalsReader.readNextChunkAsString())
}
let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let validBefore = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let validityRange = Date(timeIntervalSince1970: TimeInterval(validAfter))..<Date(timeIntervalSince1970: TimeInterval(validBefore))
let criticalOptionsReader = try dataParser.readNextChunkAsSubReader()
var criticalOptions: [String] = []
while !criticalOptionsReader.done {
let next = try criticalOptionsReader.readNextChunkAsString()
if !next.isEmpty {
criticalOptions.append(next)
}
}
let extensionsReader = try dataParser.readNextChunkAsSubReader()
var extensions: [String] = []
while !extensionsReader.done {
let next = try extensionsReader.readNextChunkAsString()
if !next.isEmpty {
extensions.append(next)
}
}
_ = try dataParser.readNextChunk() // reserved
let signingKeyReader = try dataParser.readNextChunkAsSubReader()
let signingKeyType = try signingKeyReader.readNextChunkAsString()
let signingKeyCurveName = try signingKeyReader.readNextChunkAsString()
let signingKeyData = try signingKeyReader.readNextChunk()
let signingKey = OpenSSHCertificate.PublicKey(keyType: signingKeyType, curveName: signingKeyCurveName, data: signingKeyData)
return OpenSSHCertificate(
type: type,
name: comment ?? keyIdentifier,
data: decoded,
publicKey: publicKey,
principals: principals,
keyID: keyIdentifier,
serial: serialNumber,
validityRange: validityRange,
criticalOptions: criticalOptions,
extensions: extensions,
signingKey: signingKey,
)
} catch {
throw .parsingFailed
}
}
}
public enum OpenSSHCertificateError: Error, Codable {
case unsupportedType
case parsingFailed
}

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import SecretKit
/// Generates OpenSSH representations of the public key sof secrets. /// Generates OpenSSH representations of the public key sof secrets.
public struct OpenSSHPublicKeyWriter: Sendable { public struct OpenSSHPublicKeyWriter: Sendable {
@@ -40,18 +41,14 @@ public struct OpenSSHPublicKeyWriter: Sendable {
/// - Returns: OpenSSH SHA256 fingerprint string. /// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String { public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end. // OpenSSL format seems to strip the padding at the end.
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString() let cleaned = SHA256.hash(data: data(secret: secret)).formatted(.base64(stripPadding: true))
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
return "SHA256:\(cleaned)" return "SHA256:\(cleaned)"
} }
/// Generates an OpenSSH MD5 fingerprint string. /// Generates an OpenSSH MD5 fingerprint string.
/// - Returns: OpenSSH MD5 fingerprint string. /// - Returns: OpenSSH MD5 fingerprint string.
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String { public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
Insecure.MD5.hash(data: data(secret: secret)) Insecure.MD5.hash(data: data(secret: secret)).formatted(.hex(separator: ":"))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
} }
public func comment<SecretType: Secret>(secret: SecretType) -> String { public func comment<SecretType: Secret>(secret: SecretType) -> String {

View File

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

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import SecretKit
/// Generates OpenSSH representations of Secrets. /// Generates OpenSSH representations of Secrets.
public struct OpenSSHSignatureWriter: Sendable { public struct OpenSSHSignatureWriter: Sendable {
@@ -29,19 +30,28 @@ public struct OpenSSHSignatureWriter: Sendable {
extension OpenSSHSignatureWriter { extension OpenSSHSignatureWriter {
/// Converts a fixed-width big-endian integer (e.g. r/s from CryptoKit rawRepresentation) into an SSH mpint.
/// Strips unnecessary leading zeros and prefixes `0x00` if needed to keep the value positive.
private func mpint(fromFixedWidthPositiveBytes bytes: Data) -> Data {
// mpint zero is encoded as a string with zero bytes of data.
guard let firstNonZeroIndex = bytes.firstIndex(where: { $0 != 0x00 }) else {
return Data()
}
let trimmed = Data(bytes[firstNonZeroIndex...])
if let first = trimmed.first, first >= 0x80 {
var prefixed = Data([0x00])
prefixed.append(trimmed)
return prefixed
}
return trimmed
}
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data { func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
let rawLength = rawRepresentation.count/2 let rawLength = rawRepresentation.count/2
// Check if we need to pad with 0x00 to prevent certain let r = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[0..<rawLength]))
// ssh servers from thinking r or s is negative let s = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[rawLength...]))
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
var r = Data(rawRepresentation[0..<rawLength])
if paddingRange ~= r.first! {
r.insert(0x00, at: 0)
}
var s = Data(rawRepresentation[rawLength...])
if paddingRange ~= s.first! {
s.insert(0x00, at: 0)
}
var signatureChunk = Data() var signatureChunk = Data()
signatureChunk.append(r.lengthAndData) signatureChunk.append(r.lengthAndData)

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
import OSLog import OSLog
import SecretKit import SecretKit
import CertificateKit
public protocol SSHAgentInputParserProtocol { public protocol SSHAgentInputParserProtocol {
@@ -13,7 +14,7 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
public init() { public init() {
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
} }
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request { public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
@@ -74,21 +75,16 @@ extension SSHAgentInputParser {
func certificatePublicKeyBlob(from hash: Data) -> Data? { func certificatePublicKeyBlob(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash) let reader = OpenSSHReader(data: hash)
do { do {
let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self) let certType = try reader.readNextChunkAsString()
switch certType { guard let certType = OpenSSHCertificate.CertificateType(rawValue: certType) else { return nil }
case "ecdsa-sha2-nistp256-cert-v01@openssh.com", _ = try reader.readNextChunk() // nonce
"ecdsa-sha2-nistp384-cert-v01@openssh.com", let curveIdentifier = try reader.readNextChunk()
"ecdsa-sha2-nistp521-cert-v01@openssh.com": let publicKey = try reader.readNextChunk()
_ = try reader.readNextChunk() // nonce let openSSHIdentifier = certType.keyIdentifier
let curveIdentifier = try reader.readNextChunk() return openSSHIdentifier.lengthAndData +
let publicKey = try reader.readNextChunk() curveIdentifier.lengthAndData +
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return openSSHIdentifier.lengthAndData +
curveIdentifier.lengthAndData +
publicKey.lengthAndData publicKey.lengthAndData
default:
return nil
}
} catch { } catch {
return nil return nil
} }

View File

@@ -2,29 +2,29 @@ import Foundation
import CryptoKit import CryptoKit
import OSLog import OSLog
import SecretKit import SecretKit
import CertificateKit
import AppKit import AppKit
import SSHProtocolKit
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores. /// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
public final class Agent: Sendable { public final class Agent: Sendable {
private let storeList: SecretStoreList private let storeList: SecretStoreList
private let certificateStore: CertificateStore
private let witness: SigningWitness? private let witness: SigningWitness?
private let publicKeyWriter = OpenSSHPublicKeyWriter() private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let signatureWriter = OpenSSHSignatureWriter() private let signatureWriter = OpenSSHSignatureWriter()
private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
/// Initializes an agent with a store list and a witness. /// Initializes an agent with a store list and a witness.
/// - Parameters: /// - Parameters:
/// - storeList: The `SecretStoreList` to make available. /// - storeList: The `SecretStoreList` to make available.
/// - witness: A witness to notify of requests. /// - witness: A witness to notify of requests.
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) { public init(storeList: SecretStoreList, certificateStore: CertificateStore, witness: SigningWitness? = nil) {
logger.debug("Agent is running") logger.debug("Agent is running")
self.storeList = storeList self.storeList = storeList
self.certificateStore = certificateStore
self.witness = witness self.witness = witness
Task { @MainActor in
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
}
} }
} }
@@ -47,6 +47,7 @@ extension Agent {
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .unknown(let value): case .unknown(let value):
logger.error("Agent received unknown request of type \(value).") logger.error("Agent received unknown request of type \(value).")
throw UnhandledRequestError()
default: default:
logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.") logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.")
throw UnhandledRequestError() throw UnhandledRequestError()
@@ -66,7 +67,6 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() async -> Data { func identities() async -> Data {
let secrets = await storeList.allSecrets let secrets = await storeList.allSecrets
await certificateHandler.reloadCertificates(for: secrets)
var count = 0 var count = 0
var keyData = Data() var keyData = Data()
@@ -75,10 +75,9 @@ extension Agent {
keyData.append(keyBlob.lengthAndData) keyData.append(keyBlob.lengthAndData)
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData) keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
count += 1 count += 1
for certificate in await certificateStore.certificates(for: secret) {
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) { keyData.append(certificate.data.lengthAndData)
keyData.append(certificateData.lengthAndData) keyData.append(certificate.name.lengthAndData)
keyData.append(name.lengthAndData)
count += 1 count += 1
} }
} }
@@ -95,7 +94,7 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload containing the signed data response. /// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data { func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
guard let (secret, store) = await secret(matching: keyBlob) else { guard let (secret, store) = await secret(matching: keyBlob) else {
let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined() let keyBlobHex = keyBlob.formatted(.hex())
logger.debug("Agent did not have a key matching \(keyBlobHex)") logger.debug("Agent did not have a key matching \(keyBlobHex)")
throw NoMatchingKeyError() throw NoMatchingKeyError()
} }

View File

@@ -1,88 +0,0 @@
import Foundation
import OSLog
import SecretKit
/// Manages storage and lookup for OpenSSH certificates.
public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHPublicKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler.
public init() {
}
/// Reloads any certificates in the PublicKeys folder.
/// - Parameter secrets: the secrets to look up corresponding certificates for.
public func reloadCertificates(for secrets: [AnySecret]) {
guard publicKeyFileStoreController.hasAnyCertificates else {
logger.log("No certificates, short circuiting")
return
}
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
partialResult[next] = try? loadKeyblobAndName(for: next)
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames[AnySecret(secret)]
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
private func loadKeyblobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret)
guard FileManager.default.fileExists(atPath: certificatePath) else {
return nil
}
logger.debug("Found certificate for \(secret.name)")
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
guard certElements.count >= 2 else {
logger.warning("Certificate found for \(secret.name) but failed to load")
throw OpenSSHCertificateError.parsingFailed
}
guard let certDecoded = Data(base64Encoded: certElements[1] as String) else {
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
throw OpenSSHCertificateError.parsingFailed
}
if certElements.count >= 3 {
let certName = Data(certElements[2].utf8)
return (certDecoded, certName)
}
let certName = Data(secret.name.utf8)
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
}
}
extension OpenSSHCertificateHandler {
enum OpenSSHCertificateError: LocalizedError {
case unsupportedType
case parsingFailed
case doesNotExist
public var errorDescription: String? {
switch self {
case .unsupportedType:
return "The key type was unsupported"
case .parsingFailed:
return "Failed to properly parse the SSH certificate"
case .doesNotExist:
return "Certificate does not exist"
}
}
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
import OSLog
import SecretKit
import SSHProtocolKit
import CertificateKit
import Common
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let publicKeysURL: URL
private let certificatesURL: URL
private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(publicKeysURL: URL, certificatesURL: URL) {
self.publicKeysURL = publicKeysURL
self.certificatesURL = certificatesURL
}
/// Writes out the keys specified to disk.
/// - Parameter secrets: The Secrets to generate keys for.
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
logger.log("Writing public keys to disk")
if clear {
let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: publicKeysURL) })
.union(Set(secrets.map { legacySSHCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: publicKeysURL.path())) ?? []
let fullPathContents = contentsOfDirectory.map { publicKeysURL.appending(path: $0).path() }
let untracked = Set(fullPathContents)
.subtracting(validPaths)
for path in untracked {
// string instead of fileURLWithPath since we're already using fileURL format.
try? FileManager.default.removeItem(at: URL(string: path)!)
}
}
try? FileManager.default.createDirectory(at: publicKeysURL, withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
let path = URL.publicKeyPath(for: secret, in: publicKeysURL)
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
}
logger.log("Finished writing public keys")
}
/// Writes out the certificates specified to disk.
/// - Parameter certificates: The Secrets to generate keys for.
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
public func generateCertificates(for certificates: [Certificate], clear: Bool = false) throws {
logger.log("Writing certificates to disk")
if clear {
let validPaths = Set(certificates.map { URL.certificatePath(for: $0.id, in: certificatesURL) })
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: certificatesURL.path())) ?? []
let fullPathContents = contentsOfDirectory.map { certificatesURL.appending(path: $0).path() }
let untracked = Set(fullPathContents)
.subtracting(validPaths)
for path in untracked {
// string instead of fileURLWithPath since we're already using fileURL format.
try? FileManager.default.removeItem(at: URL(string: path)!)
}
}
try? FileManager.default.createDirectory(at: certificatesURL, withIntermediateDirectories: false, attributes: nil)
for certificate in certificates {
let path = URL.certificatePath(for: certificate.id, in: certificatesURL)
FileManager.default.createFile(atPath: path, contents: certificate.rawData, attributes: nil)
}
logger.log("Finished writing certificates")
}
/// The path for a Secret's SSH Certificate public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the SSH Certificate public key.
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
private func legacySSHCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return publicKeysURL.appending(component: "\(minimalHex).pub").path()
}
}

View File

@@ -36,7 +36,7 @@ extension SigningRequestTracer {
/// - Parameter pid: The process ID to look up. /// - Parameter pid: The process ID to look up.
/// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process. /// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process.
func process(from pid: Int32) -> SigningRequestProvenance.Process { func process(from pid: Int32) -> SigningRequestProvenance.Process {
var pidAndNameInfo = self.pidAndNameInfo(from: pid) var pidAndNameInfo = unsafe self.pidAndNameInfo(from: pid)
let ppid = unsafe pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil let ppid = unsafe pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
unsafe String(cString: pointer) unsafe String(cString: pointer)

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
import OSLog import OSLog
import SecretKit import SecretKit
import launch
/// A controller that manages socket configuration and request dispatching. /// A controller that manages socket configuration and request dispatching.
public struct SocketController { public struct SocketController {
@@ -12,7 +13,8 @@ public struct SocketController {
private let sessionsContinuation: AsyncStream<Session>.Continuation private let sessionsContinuation: AsyncStream<Session>.Continuation
/// The active SocketPort. Must be retained to be kept valid. /// The active SocketPort. Must be retained to be kept valid.
private let port: SocketPort /// Only applicable for legacy non-launchd sockets.
private let port: SocketPort?
/// The FileHandle for the main socket. /// The FileHandle for the main socket.
private let fileHandle: FileHandle private let fileHandle: FileHandle
@@ -23,30 +25,57 @@ public struct SocketController {
/// Tracer which determines who originates a socket connection. /// Tracer which determines who originates a socket connection.
private let requestTracer = SigningRequestTracer() private let requestTracer = SigningRequestTracer()
/// Initializes a socket controller with a specified path. public enum Socket {
/// - Parameter path: The path to use as a socket. case launchd(String)
public init(path: String) { case path(String)
}
public init(_ socket: Socket) {
(sessions, sessionsContinuation) = AsyncStream<Session>.makeStream() (sessions, sessionsContinuation) = AsyncStream<Session>.makeStream()
logger.debug("Socket controller setting up at \(path)") switch socket {
if let _ = try? FileManager.default.removeItem(atPath: path) { case .path(let path):
logger.debug("Socket controller removed existing socket") logger.debug("Socket controller setting up at \(path)")
if let _ = try? FileManager.default.removeItem(atPath: path) {
logger.debug("Socket controller removed existing socket")
}
let exists = FileManager.default.fileExists(atPath: path)
assert(!exists)
logger.debug("Socket controller path is clear")
let port = SocketPort(path: path)
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
self.port = port
logger.debug("Socket listening at \(path)")
case .launchd(let name):
logger.debug("Socket controller setting for launchd-controlled socket \(name)")
port = nil
var fileDescriptors: UnsafeMutablePointer<Int32>? = nil
var count = 0
let result = unsafe launch_activate_socket(name, &fileDescriptors, &count)
guard result == kOSReturnSuccess, let socket = unsafe fileDescriptors?.pointee else {
fatalError()
}
fileHandle = FileHandle(fileDescriptor: socket, closeOnDealloc: true)
} }
let exists = FileManager.default.fileExists(atPath: path) listen()
assert(!exists) }
logger.debug("Socket controller path is clear")
port = SocketPort(path: path) func listen() {
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true) Task { @MainActor [fileHandle, sessionsContinuation, logger] in
Task { [fileHandle, sessionsContinuation, logger] in // Create the sequence before triggering the notification to
for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) { // ensure it will not be missed.
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted)
fileHandle.acceptConnectionInBackgroundAndNotify()
for await notification in connectionAcceptedNotifications {
logger.debug("Socket controller accepted connection") logger.debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue } guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
let session = Session(fileHandle: new) let session = Session(fileHandle: new)
sessionsContinuation.yield(session) sessionsContinuation.yield(session)
await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor() fileHandle.acceptConnectionInBackgroundAndNotify()
} }
} }
fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
logger.debug("Socket listening at \(path)")
} }
} }
@@ -77,29 +106,32 @@ extension SocketController {
self.fileHandle = fileHandle self.fileHandle = fileHandle
provenance = SigningRequestTracer().provenance(from: fileHandle) provenance = SigningRequestTracer().provenance(from: fileHandle)
(messages, messagesContinuation) = AsyncStream.makeStream() (messages, messagesContinuation) = AsyncStream.makeStream()
Task { [messagesContinuation, logger] in Task { @MainActor [messagesContinuation, logger] in
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) { // Create the sequence before triggering the notification to
// ensure it will not be missed.
let dataAvailableNotifications = NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle)
fileHandle.waitForDataInBackgroundAndNotify()
for await _ in dataAvailableNotifications {
let data = fileHandle.availableData let data = fileHandle.availableData
guard !data.isEmpty else { guard !data.isEmpty else {
logger.debug("Socket controller received empty data, ending continuation.") logger.debug("Socket controller received empty data, ending continuation.")
messagesContinuation.finish() messagesContinuation.finish()
try fileHandle.close() try? fileHandle.close()
return return
} }
messagesContinuation.yield(data) messagesContinuation.yield(data)
logger.debug("Socket controller yielded data.") logger.debug("Socket controller yielded data.")
} }
} }
Task {
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
}
} }
/// Writes new data to the socket. /// Writes new data to the socket.
/// - Parameter data: The data to write. /// - Parameter data: The data to write.
public func write(_ data: Data) async throws { @MainActor public func write(_ data: Data) throws {
try fileHandle.write(contentsOf: data) try fileHandle.write(contentsOf: data)
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor() fileHandle.waitForDataInBackgroundAndNotify()
} }
/// Closes the socket and cleans up resources. /// Closes the socket and cleans up resources.
@@ -113,34 +145,11 @@ extension SocketController {
} }
private extension FileHandle {
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
waitForDataInBackgroundAndNotify()
}
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
/// - Parameter modes: the runloop modes to use.
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
acceptConnectionInBackgroundAndNotify(forModes: modes)
}
}
private extension SocketPort { private extension SocketPort {
convenience init(path: String) { convenience init(path: String) {
var addr = sockaddr_un() var addr = sockaddr_un()
let length = addr.setPath(path)
let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
unsafe path.withCString { cstring in
let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len)
return len
}
}
// This doesn't seem to be _strictly_ neccessary with SocketPort. // This doesn't seem to be _strictly_ neccessary with SocketPort.
// but just for good form. // but just for good form.
addr.sun_family = sa_family_t(AF_UNIX) addr.sun_family = sa_family_t(AF_UNIX)
@@ -152,3 +161,31 @@ private extension SocketPort {
} }
} }
private extension sockaddr_un {
mutating func setPath(_ path: String) -> Int {
#if compiler(<6.4)
unsafe withUnsafeMutablePointer(to: &self.sun_path.0) { pointer in
unsafe path.withCString { cstring in
let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len)
return len
}
}
#else
withUnsafeMutablePointer(to: &self.sun_path.0) { pointer in
path.withCString { cstring in
let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len)
return len
}
}
#endif
}
}
// Changes the header from `UnsafeMutablePointer<UnsafeMutablePointer<Int32>?>?` -> `UnsafeMutablePointer<UnsafeMutablePointer<Int32>?>!`
@_silgen_name("launch_activate_socket")
func launch_activate_socket(_ name: UnsafePointer<CChar>, _ fds: UnsafeMutablePointer<UnsafeMutablePointer<Int32>?>!, _ cnt: UnsafeMutablePointer<Int>!) -> Int32

View File

@@ -0,0 +1,69 @@
import LocalAuthentication
/// A context describing a persisted authentication.
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: SecretType
/// The LAContext used to authorize the persistent context.
package nonisolated(unsafe) let context: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let monotonicExpiration: UInt64
/// Initializes a context.
/// - Parameters:
/// - secret: The Secret to persist authentication for.
/// - context: The LAContext used to authorize the persistent context.
/// - duration: The duration of the authorization context, in seconds.
init(secret: SecretType, context: LAContext, duration: TimeInterval) {
self.secret = secret
unsafe self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
/// A boolean describing whether or not the context is still valid.
package var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
package var expiration: Date {
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
return Date(timeIntervalSinceNow: remainingInSeconds)
}
}
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
package init() {
}
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
}
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
let durationString = formatter.string(from: duration)!
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
persistedAuthenticationContexts[secret] = context
}
}

View File

@@ -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()
} }

View File

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

View File

@@ -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)

View File

@@ -50,16 +50,16 @@ extension SecureEnclave {
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth)) let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else { guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
logger.log("Skipping \(name), public key already present. Marking as migrated.") logger.log("Skipping \(name), public key already present. Marking as migrated.")
try markMigrated(secret: secret, oldID: id) markMigrated(secret: secret, oldID: id)
continue continue
} }
logger.log("Migrating \(name).") logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes) try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).") logger.log("Migrated \(name).")
try markMigrated(secret: secret, oldID: id) markMigrated(secret: secret, oldID: id)
migratedAny = true migratedAny = true
} catch { } catch {
logger.error("Failed to migrate \(name): \(error).") logger.error("Failed to migrate \(name): \(error.localizedDescription).")
} }
} }
if migratedAny { if migratedAny {
@@ -69,10 +69,10 @@ extension SecureEnclave {
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
@@ -82,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).")
} }
} }

View File

@@ -1,70 +0,0 @@
import LocalAuthentication
import SecretKit
extension SecureEnclave {
/// A context describing a persisted authentication.
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
/// The LAContext used to authorize the persistent context.
nonisolated(unsafe) let context: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let monotonicExpiration: UInt64
/// Initializes a context.
/// - Parameters:
/// - secret: The Secret to persist authentication for.
/// - context: The LAContext used to authorize the persistent context.
/// - duration: The duration of the authorization context, in seconds.
init(secret: Secret, context: LAContext, duration: TimeInterval) {
self.secret = secret
unsafe self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
/// A boolean describing whether or not the context is still valid.
var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
var expiration: Date {
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
return Date(timeIntervalSinceNow: remainingInSeconds)
}
}
actor PersistentAuthenticationHandler: Sendable {
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
}
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
let durationString = formatter.string(from: duration)!
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
persistedAuthenticationContexts[secret] = context
}
}
}

View File

@@ -17,7 +17,7 @@ extension SecureEnclave {
} }
public let id = UUID() public let id = UUID()
public let name = String(localized: .secureEnclave) public let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler() private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
/// Initializes a Store. /// Initializes a Store.
@MainActor public init() { @MainActor public init() {
@@ -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)
}
)
}()
} }
} }

View File

@@ -0,0 +1,53 @@
import Foundation
import Security
import CryptoTokenKit
import CryptoKit
import os
import SSHProtocolKit
import CertificateKit
public struct CertificateMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
private let publicKeysDirectory: URL
private let certificatesDirectory: URL
private let certificateStore: CertificateStore
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: URL, certificateStore: CertificateStore) {
publicKeysDirectory = homeDirectory.appending(component: "PublicKeys")
certificatesDirectory = homeDirectory.appending(component: "Certificates")
self.certificateStore = certificateStore
}
@MainActor public func migrate() throws {
try migrate(directory: publicKeysDirectory)
try migrate(directory: certificatesDirectory)
}
@MainActor public func migrate(directory: URL) throws {
let fileCerts = try FileManager.default
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
Task {
for path in fileCerts {
do {
let url = directory.appending(component: path)
let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data)
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to delete successfully migrated cert: \(path)")
}
} catch {
logger.error("Failed to migrate cert: \(path)")
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
import OSLog
import SSHProtocolKit
import CertificateKit
import XPCWrappers
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
public final class XPCCertificateParser: OpenSSHCertificateParserProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "XPCCertificateParser")
private let session: XPCTypedSession<OpenSSHCertificate, OpenSSHCertificateError>
public init() async throws {
logger.debug("Creating XPCCertificateParser")
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretiveCertificateParser", warmup: true)
logger.debug("XPCCertificateParser is warmed up.")
}
public func parse(data: Data) async throws -> OpenSSHCertificate {
logger.debug("Parsing input")
defer { logger.debug("Parsed input") }
return try await session.send(data)
}
deinit {
session.complete()
}
}

View File

@@ -34,6 +34,7 @@ extension SmartCard {
public var secrets: [Secret] { public var secrets: [Secret] {
state.secrets state.secrets
} }
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
@@ -58,9 +59,15 @@ extension SmartCard {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext() var context: LAContext
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton) context = unsafe existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@@ -86,11 +93,12 @@ extension SmartCard {
return signature as Data return signature as Data
} }
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
nil await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
} }
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws { public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
} }
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
@@ -163,7 +171,7 @@ extension SmartCard.Store {
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)! let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any] let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data let publicKey = publicKeyAttributes[kSecValueData] as! Data
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown) let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .presenceRequired)
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes) let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
guard signatureAlgorithm(for: secret) != nil else { return nil } guard signatureAlgorithm(for: secret) != nil else { return nil }
return secret return secret

View File

@@ -0,0 +1,26 @@
import Foundation
extension ProcessInfo {
private static let fallbackTeamID = "Z72PRUAWF6"
private static let teamID: String = {
#if DEBUG
guard let task = SecTaskCreateFromSelf(nil) else {
assertionFailure("SecTaskCreateFromSelf failed")
return fallbackTeamID
}
guard let value = SecTaskCopyValueForEntitlement(task, "com.apple.developer.team-identifier" as CFString, nil) as? String else {
// assertionFailure("SecTaskCopyValueForEntitlement(com.apple.developer.team-identifier) failed")
return fallbackTeamID
}
return value
#else
/// Always use hardcoded team ID for release builds, just in case.
return fallbackTeamID
#endif
}()
public var teamID: String { Self.teamID }
}

View File

@@ -12,7 +12,7 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self) newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
let exportedObject = exportedObject let exportedObject = exportedObject
newConnection.exportedObject = exportedObject newConnection.exportedObject = exportedObject
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6") newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
newConnection.resume() newConnection.resume()
return true return true
} }
@@ -34,7 +34,9 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
if let error = error as? Codable & Error { if let error = error as? Codable & Error {
reply(nil, NSError(error)) reply(nil, NSError(error))
} else { } else {
reply(nil, error) // 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]))
} }
} }
} }

View File

@@ -8,7 +8,7 @@ public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error
public init(serviceName: String, warmup: Bool = false) async throws { public init(serviceName: String, warmup: Bool = false) async throws {
let connection = NSXPCConnection(serviceName: serviceName) let connection = NSXPCConnection(serviceName: serviceName)
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self) connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6") connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
connection.resume() connection.resume()
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() } guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
self.connection = connection self.connection = connection

View File

@@ -1,8 +1,7 @@
import Foundation import Foundation
import Testing import Testing
@testable import SecretKit @testable import SecretKit
@testable import SecureEnclaveSecretKit import SSHProtocolKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHPublicKeyWriterTests { @Suite struct OpenSSHPublicKeyWriterTests {
@@ -47,8 +46,8 @@ import Testing
extension OpenSSHPublicKeyWriterTests { extension OpenSSHPublicKeyWriterTests {
enum Constants { enum Constants {
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com")) static let ecdsa256Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com")) static let ecdsa384Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
} }

View File

@@ -1,8 +1,6 @@
import Foundation import Foundation
import Testing import Testing
@testable import SecretAgentKit import SSHProtocolKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHReaderTests { @Suite struct OpenSSHReaderTests {

View File

@@ -0,0 +1,83 @@
import Foundation
import Testing
import SSHProtocolKit
@testable import SecretKit
@Suite struct OpenSSHSignatureWriterTests {
private let writer = OpenSSHSignatureWriter()
@Test func ecdsaMpintStripsUnnecessaryLeadingZeros() throws {
let secret = Constants.ecdsa256Secret
// r has a leading 0x00 followed by 0x01 (< 0x80): the mpint must not keep the leading zero.
let rBytes: [UInt8] = [0x00] + (1...31).map { UInt8($0) }
let r = Data(rBytes)
// s has two leading 0x00 bytes followed by 0x7f (< 0x80): the mpint must not keep the leading zeros.
let sBytes: [UInt8] = [0x00, 0x00, 0x7f] + Array(repeating: UInt8(0x01), count: 29)
let s = Data(sBytes)
let rawRepresentation = r + s
let response = writer.data(secret: secret, signature: rawRepresentation)
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
#expect(parsedR == Data((1...31).map { UInt8($0) }))
#expect(parsedS == Data([0x7f] + Array(repeating: UInt8(0x01), count: 29)))
}
@Test func ecdsaMpintPrefixesZeroWhenHighBitSet() throws {
let secret = Constants.ecdsa256Secret
// r starts with 0x80 (high bit set): mpint must be prefixed with 0x00.
let r = Data([UInt8(0x80)] + Array(repeating: UInt8(0x01), count: 31))
let s = Data([UInt8(0x01)] + Array(repeating: UInt8(0x02), count: 31))
let rawRepresentation = r + s
let response = writer.data(secret: secret, signature: rawRepresentation)
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
#expect(parsedR == Data([0x00, 0x80] + Array(repeating: UInt8(0x01), count: 31)))
#expect(parsedS == Data([0x01] + Array(repeating: UInt8(0x02), count: 31)))
}
}
private extension OpenSSHSignatureWriterTests {
enum Constants {
static let ecdsa256Secret = TestSecret(
id: Data(),
name: "Test Key (ECDSA 256)",
publicKey: Data(repeating: 0x01, count: 65),
attributes: Attributes(
keyType: KeyType(algorithm: .ecdsa, size: 256),
authentication: .notRequired,
publicKeyAttribution: "test@example.com"
)
)
}
enum ParseError: Error {
case eof
case invalidAlgorithm
}
func parseEcdsaSignatureMpints(from openSSHSignedData: Data) throws -> (r: Data, s: Data) {
let reader = OpenSSHReader(data: openSSHSignedData)
// Prefix
_ = try reader.readNextBytes(as: UInt32.self)
let algorithm = try reader.readNextChunkAsString()
guard algorithm == "ecdsa-sha2-nistp256" else {
throw ParseError.invalidAlgorithm
}
let sigReader = try reader.readNextChunkAsSubReader()
let r = try sigReader.readNextChunk()
let s = try sigReader.readNextChunk()
return (r, s)
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
import SecretKit
public struct TestSecret: SecretKit.Secret {
public let id: Data
public let name: String
public let publicKey: Data
public var attributes: Attributes
}

View File

@@ -1,15 +1,17 @@
import Foundation import Foundation
import Testing import Testing
import CryptoKit import CryptoKit
import CertificateKit
@testable import SSHProtocolKit
@testable import SecretKit @testable import SecretKit
@testable import SecretAgentKit @testable import SecretAgentKit
@Suite struct AgentTests { @Suite @MainActor struct AgentTests {
// MARK: Identity Listing // MARK: Identity Listing
@Test func emptyStores() async throws { @Test func emptyStores() async throws {
let agent = Agent(storeList: SecretStoreList()) let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesEmpty) #expect(response == Constants.Responses.requestIdentitiesEmpty)
@@ -17,7 +19,7 @@ import CryptoKit
@Test func identitiesList() async throws { @Test func identitiesList() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list, certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test)
@@ -31,7 +33,7 @@ import CryptoKit
@Test func noMatchingIdentities() async throws { @Test func noMatchingIdentities() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list, certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
@@ -41,11 +43,11 @@ import CryptoKit
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
guard case SSHAgent.Request.signRequest(let context) = request else { return } guard case SSHAgent.Request.signRequest(let context) = request else { return }
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list, certificateStore: CertificateStore())
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test)
let responseReader = OpenSSHReader(data: response) let responseReader = OpenSSHReader(data: response)
let length = try responseReader.readNextBytes(as: UInt32.self).bigEndian let length = try responseReader.readNextBytes(as: UInt32.self)
let type = try responseReader.readNextBytes(as: UInt8.self).bigEndian let type = try responseReader.readNextBytes(as: UInt8.self)
#expect(length == response.count - MemoryLayout<UInt32>.size) #expect(length == response.count - MemoryLayout<UInt32>.size)
#expect(type == SSHAgent.Response.agentSignResponse.rawValue) #expect(type == SSHAgent.Response.agentSignResponse.rawValue)
let outer = OpenSSHReader(data: responseReader.remaining) let outer = OpenSSHReader(data: responseReader.remaining)
@@ -76,7 +78,7 @@ import CryptoKit
let witness = StubWitness(speakNow: { _,_ in let witness = StubWitness(speakNow: { _,_ in
return true return true
}, witness: { _, _ in }) }, witness: { _, _ in })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
let response = await agent.handle(request: .signRequest(.empty), provenance: .test) let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
#expect(response == Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
} }
@@ -89,7 +91,7 @@ import CryptoKit
}, witness: { _, trace in }, witness: { _, trace in
witnessed = true witnessed = true
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test) _ = await agent.handle(request: request, provenance: .test)
#expect(witnessed) #expect(witnessed)
@@ -105,7 +107,7 @@ import CryptoKit
}, witness: { _, trace in }, witness: { _, trace in
witnessTrace = trace witnessTrace = trace
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test) _ = await agent.handle(request: request, provenance: .test)
#expect(witnessTrace == speakNowTrace) #expect(witnessTrace == speakNowTrace)
@@ -116,9 +118,9 @@ import CryptoKit
@Test func signatureException() async throws { @Test func signatureException() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = await list.stores.first?.base as! Stub.Store let store = list.stores.first?.base as! Stub.Store
store.shouldThrow = true store.shouldThrow = true
let agent = Agent(storeList: list) let agent = Agent(storeList: list, certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
let response = await agent.handle(request: request, provenance: .test) let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
@@ -127,7 +129,7 @@ import CryptoKit
// MARK: Unsupported // MARK: Unsupported
@Test func unhandledAdd() async throws { @Test func unhandledAdd() async throws {
let agent = Agent(storeList: SecretStoreList()) let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
let response = await agent.handle(request: .addIdentity, provenance: .test) let response = await agent.handle(request: .addIdentity, provenance: .test)
#expect(response == Constants.Responses.requestFailure) #expect(response == Constants.Responses.requestFailure)
} }
@@ -142,7 +144,7 @@ extension SigningRequestProvenance {
extension AgentTests { extension AgentTests {
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList { func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
let store = Stub.Store() let store = Stub.Store()
store.secrets.append(contentsOf: secrets) store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList() let storeList = SecretStoreList()

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
import SecretKit import SecretKit
import CryptoKit import CryptoKit
import SSHProtocolKit
struct Stub {} struct Stub {}

View File

@@ -6,7 +6,21 @@ import SmartCardSecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
import Observation import Observation
import SSHProtocolKit
import CertificateKit
import Common
import SwiftUI
extension EnvironmentValues {
@MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore()
@MainActor var certificateStore: CertificateStore {
EnvironmentValues._certificateStore
}
}
@main @main
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
@@ -17,35 +31,36 @@ class AppDelegate: NSObject, NSApplicationDelegate {
try? migrator.migrate(to: cryptoKit) try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit) list.add(store: cryptoKit)
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: EnvironmentValues._certificateStore)
try? certsMigrator.migrate()
return list return list
}() }()
private let updater = Updater(checkOnLaunch: true) private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier() private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory) private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
private lazy var agent: Agent = { @MainActor private lazy var agent: Agent = {
Agent(storeList: storeList, witness: notifier) Agent(storeList: storeList, certificateStore: EnvironmentValues._certificateStore, witness: notifier)
}()
private lazy var socketController: SocketController = {
let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String
return SocketController(path: path)
}() }()
private var shutdownTask: Task<Void, Error>?
private let socketController = SocketController(.launchd("SecureListener"))
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
func applicationDidFinishLaunching(_ aNotification: Notification) { func applicationDidFinishLaunching(_ aNotification: Notification) {
logger.debug("SecretAgent finished launching") logger.debug("SecretAgent finished launching")
Task { Task {
let inputParser = try await XPCAgentInputParser()
for await session in socketController.sessions { for await session in socketController.sessions {
Task { Task {
do { do {
let inputParser = try await XPCAgentInputParser()
for await message in session.messages { for await message in session.messages {
let request = try await inputParser.parse(data: message) let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance) let agentResponse = await agent.handle(request: request, provenance: session.provenance)
try await session.write(agentResponse) try session.write(agentResponse)
} }
} catch { } catch {
try session.close() try? session.close()
} }
startCountdownClock()
} }
} }
} }
@@ -54,7 +69,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
} }
} }
Task {
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
}
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
notifier.prompt() notifier.prompt()
_ = withObservationTracking { _ = withObservationTracking {
updater.update updater.update
@@ -68,5 +89,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
func startCountdownClock() {
// FIXME: ACCOUNT FOR STORED AUTH
logger.log("Beginning countdown clock")
shutdownTask?.cancel()
shutdownTask = Task { [logger] in
try await Task.sleep(for: .seconds(30))
logger.log("Shutting down")
await NSApplication.shared.terminate(nil)
}
}
} }

View File

@@ -0,0 +1,47 @@
import Foundation
import Security
import CryptoTokenKit
import CryptoKit
import os
import SSHProtocolKit
import CertificateKit
import SharedXPCServices
public struct CertificateMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
private let directory: URL
private let certificateStore: CertificateStore
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: URL, certificateStore: CertificateStore) {
directory = homeDirectory.appending(component: "PublicKeys")
self.certificateStore = certificateStore
}
@MainActor public func migrate() throws {
let fileCerts = try FileManager.default
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
Task {
for path in fileCerts {
do {
let url = directory.appending(component: path)
let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data)
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to delete successfully migrated cert: \(path)")
}
} catch {
logger.error("Failed to migrate cert: \(path)")
}
}
}
}
}

View File

@@ -2,6 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
<key>com.apple.security.smartcard</key> <key>com.apple.security.smartcard</key>
<true/> <true/>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>

View File

@@ -1,19 +1,27 @@
import Foundation import Foundation
import SecretAgentKit import OSLog
import SSHProtocolKit
import Brief import Brief
import XPCWrappers import XPCWrappers
import OSLog
import SSHProtocolKit
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH /// Delegates all agent input parsing to an XPC service which wraps OpenSSH
public final class XPCAgentInputParser: SSHAgentInputParserProtocol { public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "XPCAgentInputParser")
private let session: XPCTypedSession<SSHAgent.Request, SSHAgentInputParser.AgentParsingError> private let session: XPCTypedSession<SSHAgent.Request, SSHAgentInputParser.AgentParsingError>
public init() async throws { public init() async throws {
logger.debug("Creating XPCAgentInputParser")
session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentInputParser", warmup: true) 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 { public func parse(data: Data) async throws -> SSHAgent.Request {
try await session.send(data) logger.debug("Parsing input")
defer { logger.debug("Parsed input") }
return try await session.send(data)
} }
deinit { deinit {

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key>
<true/>
<key>com.apple.security.hardened-process.hardened-heap</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>1</string>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
</dict>
</plist>

View File

@@ -2,6 +2,7 @@ import Foundation
import OSLog import OSLog
import XPCWrappers import XPCWrappers
import SecretAgentKit import SecretAgentKit
import SSHProtocolKit
final class SecretAgentInputParser: NSObject, XPCProtocol { final class SecretAgentInputParser: NSObject, XPCProtocol {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2600" LastUpgradeVersion = "2640"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -14,7 +14,8 @@
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans> <TestPlans>
<TestPlanReference <TestPlanReference
reference = "container:Config/Secretive.xctestplan"> reference = "container:Config/Secretive.xctestplan"
default = "YES">
</TestPlanReference> </TestPlanReference>
</TestPlans> </TestPlans>
</TestAction> </TestAction>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2600" LastUpgradeVersion = "2640"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -70,7 +70,7 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "1"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2600" LastUpgradeVersion = "2640"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -23,7 +23,7 @@
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction <TestAction
buildConfiguration = "Test" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
@@ -87,6 +87,9 @@
ReferencedContainer = "container:Secretive.xcodeproj"> ReferencedContainer = "container:Secretive.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<MetalAPIValidationSettings
isEnabled = "No">
</MetalAPIValidationSettings>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Debug" buildConfiguration = "Debug"

View File

@@ -1,8 +1,99 @@
import SwiftUI import SwiftUI
@unsafe @preconcurrency import ServiceManagement
import SecretKit import SecretKit
import SecureEnclaveSecretKit import SecureEnclaveSecretKit
import SmartCardSecretKit import SmartCardSecretKit
import Brief import Brief
import CertificateKit
@Observable
final class LaunchService: Sendable {
private let service = SMAppService.agent(plistName: "com.maxgoedjen.Secretive.SecretAgent.plist")
var status: SMAppService.Status {
service.status
}
func configure() {
try? service.unregister()
try! service.register()
}
func disable() {
try? service.unregister()
}
}
@main
struct Secretive: App {
@Environment(\.justUpdatedChecker) var justUpdatedChecker
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView()
.environment(EnvironmentValues._secretStoreList)
.environment(EnvironmentValues._certificateStore)
.onAppear {
EnvironmentValues._launchService.configure()
}
}
.commands {
AppCommands()
}
WindowGroup(id: String(describing: IntegrationsView.self)) {
IntegrationsView()
}
.windowResizability(.contentMinSize)
WindowGroup(id: String(describing: AboutView.self)) {
AboutView()
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
}
extension Secretive {
struct AppCommands: Commands {
@Environment(\.openWindow) var openWindow
@Environment(\.openURL) var openURL
@FocusedValue(\.showCreateSecret) var showCreateSecret
var body: some Commands {
CommandGroup(replacing: .appInfo) {
Button(.aboutMenuBarTitle, systemImage: "info.circle") {
openWindow(id: String(describing: AboutView.self))
}
}
CommandGroup(before: CommandGroupPlacement.appSettings) {
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
openWindow(id: String(describing: IntegrationsView.self))
}
}
CommandGroup(after: CommandGroupPlacement.newItem) {
Button(.appMenuNewSecretButton, systemImage: "plus") {
showCreateSecret?()
}
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
.disabled(showCreateSecret?.isEnabled == false)
}
CommandGroup(replacing: .help) {
Button(.appMenuHelpButton) {
openURL(Constants.helpURL)
}
}
SidebarCommands()
}
}
}
private enum Constants {
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
}
extension EnvironmentValues { extension EnvironmentValues {
@@ -10,15 +101,18 @@ extension EnvironmentValues {
@MainActor fileprivate static let _secretStoreList: SecretStoreList = { @MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
let cryptoKit = SecureEnclave.Store() let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator() let cryptoKitMigrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit) try? cryptoKitMigrator.migrate(to: cryptoKit)
list.add(store: cryptoKit) list.add(store: cryptoKit)
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()
private static let _agentStatusChecker = AgentStatusChecker() @MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
private static let _agentLaunchController = AgentLaunchController()
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
private static let _updater: any UpdaterProtocol = { private static let _updater: any UpdaterProtocol = {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false @AppStorage("defaultsHasRunSetup") var hasRunSetup = false
return Updater(checkOnLaunch: hasRunSetup) return Updater(checkOnLaunch: hasRunSetup)
@@ -28,93 +122,34 @@ extension EnvironmentValues {
private static let _justUpdatedChecker = JustUpdatedChecker() private static let _justUpdatedChecker = JustUpdatedChecker()
@Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker @Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker
fileprivate static let _launchService = LaunchService()
@Entry var launchService: LaunchService = _launchService
@MainActor var secretStoreList: SecretStoreList { @MainActor var secretStoreList: SecretStoreList {
EnvironmentValues._secretStoreList EnvironmentValues._secretStoreList
} }
@MainActor var certificateStore: CertificateStore {
EnvironmentValues._certificateStore
}
} }
@main extension FocusedValues {
struct Secretive: App { @Entry var showCreateSecret: OpenSheet?
}
@Environment(\.agentStatusChecker) var agentStatusChecker final class OpenSheet {
@Environment(\.justUpdatedChecker) var justUpdatedChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@State private var showingIntegrations = 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.justUpdatedBuild {
// Relaunch the agent, since it'll be running from earlier update still
reinstallAgent()
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
forceLaunchAgent()
}
}
.sheet(isPresented: $showingIntegrations) {
IntegrationsView()
}
}
.commands {
CommandGroup(before: CommandGroupPlacement.appSettings) {
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
showingIntegrations = true
}
}
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)
}
}
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")!
}

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

View File

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

View File

@@ -2,18 +2,26 @@ import Foundation
import AppKit import AppKit
import SecretKit import SecretKit
import Observation import Observation
import OSLog
import ServiceManagement
import Common
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable { @MainActor protocol AgentLaunchControllerProtocol: Observable, Sendable {
var running: Bool { get } var running: Bool { get }
var developmentBuild: Bool { get } var developmentBuild: Bool { get }
var process: NSRunningApplication? { get } var process: NSRunningApplication? { get }
func check() func check()
func install() async throws
func uninstall() async throws
func forceLaunch() async throws
} }
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol { @Observable @MainActor final class AgentLaunchController: AgentLaunchControllerProtocol {
var running: Bool = false var running: Bool = false
var process: NSRunningApplication? = nil var process: NSRunningApplication? = nil
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
private let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
nonisolated init() { nonisolated init() {
Task { @MainActor in Task { @MainActor in
@@ -33,7 +41,7 @@ import Observation
// The process corresponding to this instance of Secretive // The process corresponding to this instance of Secretive
var instanceSecretAgentProcess: NSRunningApplication? { var instanceSecretAgentProcess: NSRunningApplication? {
// FIXME: CHECK VERSION // TODO: CHECK VERSION
let agents = allSecretAgentProcesses 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 }
@@ -49,6 +57,47 @@ import Observation
Bundle.main.bundleURL.isXcodeURL Bundle.main.bundleURL.isXcodeURL
} }
func install() async throws {
logger.debug("Installing agent")
try? await service.unregister()
// This is definitely a bit of a "seems to work better" thing but:
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
// and start new?
try await Task.sleep(for: .seconds(1))
try service.register()
try await Task.sleep(for: .seconds(1))
check()
}
func uninstall() async throws {
logger.debug("Uninstalling agent")
try await Task.sleep(for: .seconds(1))
try await service.unregister()
try await Task.sleep(for: .seconds(1))
check()
}
func forceLaunch() async throws {
logger.debug("Agent is not running, attempting to force launch by reinstalling")
try await install()
if running {
logger.debug("Agent successfully force launched by reinstalling")
return
}
logger.debug("Agent is not running, attempting to force launch by launching directly")
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
let config = NSWorkspace.OpenConfiguration()
config.activates = false
do {
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
logger.debug("Agent force launched")
try await Task.sleep(for: .seconds(1))
} catch {
logger.error("Error force launching \(error.localizedDescription)")
}
check()
}
} }
extension URL { extension URL {

View File

@@ -1,65 +0,0 @@
import Foundation
import ServiceManagement
import AppKit
import OSLog
import SecretKit
struct LaunchAgentController {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
func install() async -> Bool {
logger.debug("Installing agent")
_ = setEnabled(false)
// This is definitely a bit of a "seems to work better" thing but:
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
// and start new?
try? await Task.sleep(for: .seconds(1))
let result = await MainActor.run {
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 {
logger.debug("Agent is not running, attempting to force launch")
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
let config = NSWorkspace.OpenConfiguration()
config.activates = false
do {
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
logger.debug("Agent force launched")
try? await Task.sleep(for: .seconds(1))
return true
} catch {
logger.error("Error force launching \(error.localizedDescription)")
return false
}
}
private func setEnabled(_ enabled: Bool) -> Bool {
let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
return true
} catch {
return false
}
}
}

View File

@@ -1,25 +0,0 @@
import Foundation
extension URL {
static var agentHomeURL: URL {
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
}
static var socketPath: String {
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
}
}
extension String {
var normalizedPathAndFolder: (String, String) {
// All foundation-based normalization methods replace this with the container directly.
let processedPath = replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
let url = URL(filePath: processedPath)
let folder = url.deletingLastPathComponent().path()
return (processedPath, folder)
}
}

View File

@@ -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}}}

View File

@@ -20,6 +20,8 @@
<string>$(CI_VERSION)</string> <string>$(CI_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CI_BUILD_NUMBER)</string> <string>$(CI_BUILD_NUMBER)</string>
<key>GitHubBuildLog</key>
<string>https://$(CI_BUILD_LINK)</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
import AppKit import AppKit
class PreviewAgentStatusChecker: AgentStatusCheckerProtocol { class PreviewAgentLaunchController: AgentLaunchControllerProtocol {
let running: Bool let running: Bool
let process: NSRunningApplication? let process: NSRunningApplication?
@@ -15,4 +15,13 @@ class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
func check() { func check() {
} }
func install() async throws {
}
func uninstall() async throws {
}
func forceLaunch() async throws {
}
} }

View File

@@ -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]) {

View File

@@ -4,16 +4,22 @@
<dict> <dict>
<key>com.apple.security.hardened-process</key> <key>com.apple.security.hardened-process</key>
<true/> <true/>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
<true/>
<key>com.apple.security.hardened-process.dyld-ro</key> <key>com.apple.security.hardened-process.dyld-ro</key>
<true/> <true/>
<key>com.apple.security.hardened-process.enhanced-security-version</key>
<integer>1</integer>
<key>com.apple.security.hardened-process.hardened-heap</key> <key>com.apple.security.hardened-process.hardened-heap</key>
<true/> <true/>
<key>com.apple.security.hardened-process.platform-restrictions</key> <key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<integer>2</integer> <string>1</string>
<key>com.apple.security.smartcard</key> <key>com.apple.security.smartcard</key>
<true/> <true/>
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
<string>2</string>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string> <string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>

View File

@@ -32,7 +32,7 @@ struct ConfigurationItemView<Content: View>: View {
Spacer() Spacer()
switch action { switch action {
case .copy(let string): case .copy(let string):
Button(.copyableClickToCopyButton, systemImage: "document.on.document") { Button(.copyableClickToCopyButton, systemImage: "doc.on.doc") {
NSPasteboard.general.declareTypes([.string], owner: nil) NSPasteboard.general.declareTypes([.string], owner: nil)
NSPasteboard.general.setString(string, forType: .string) NSPasteboard.general.setString(string, forType: .string)
} }

View File

@@ -21,47 +21,19 @@ struct IntegrationsView: View {
} }
} }
} detail: { } detail: {
IntegrationsDetailView(selectedInstruction: $selectedInstruction) IntegrationsDetailView(selectedInstruction: $selectedInstruction)
.fauxToolbar {
Button(.setupDoneButton) {
dismiss()
}
.normalButton()
}
} }
.toolbar {
Button(.setupDoneButton) {
dismiss()
}
}
.hiddenToolbar()
.windowBackgroundStyle(.thinMaterial)
.onAppear { .onAppear {
selectedInstruction = instructions.gettingStarted selectedInstruction = instructions.gettingStarted
} }
.frame(minHeight: 500) .frame(minWidth: 400, minHeight: 400)
}
}
extension View {
func fauxToolbar<Content: View>(content: () -> Content) -> some View {
modifier(FauxToolbarModifier(toolbarContent: content()))
}
}
struct FauxToolbarModifier<ToolbarContent: View>: ViewModifier {
var toolbarContent: ToolbarContent
func body(content: Content) -> some View {
VStack(alignment: .leading, spacing: 0) {
content
Divider()
HStack {
Spacer()
toolbarContent
.padding(.top, 8)
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
} }
} }

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct SetupView: View { struct SetupView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.agentLaunchController) private var agentLaunchController
@Binding var setupComplete: Bool @Binding var setupComplete: Bool
@State var showingIntegrations = false @State var showingIntegrations = false
@@ -31,7 +32,7 @@ struct SetupView: View {
) { ) {
installed = true installed = true
Task { Task {
await LaunchAgentController().install() try? await agentLaunchController.install()
} }
} }
} }
@@ -85,7 +86,10 @@ struct SetupView: View {
integrations = true integrations = true
}, content: { }, content: {
IntegrationsView() IntegrationsView()
.frame(minWidth: 500, minHeight: 400)
}) })
.frame(idealWidth: 600)
.fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -172,10 +176,13 @@ struct StepView<Content: View>: View {
.frame(width: 20) .frame(width: 20)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title) Text(title)
.fixedSize(horizontal: false, vertical: true)
.bold() .bold()
Text(description) Text(description)
.fixedSize(horizontal: false, vertical: true)
if let detail { if let detail {
Text(detail) Text(detail)
.fixedSize(horizontal: false, vertical: true)
.font(.callout) .font(.callout)
.italic() .italic()
} }

View File

@@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
import SecretKit import SecretKit
import SSHProtocolKit
import Common
struct ToolConfigurationView: View { struct ToolConfigurationView: View {
@@ -10,6 +12,7 @@ struct ToolConfigurationView: View {
@State var creating = false @State var creating = false
@State var selectedSecret: AnySecret? @State var selectedSecret: AnySecret?
@State var email = ""
init(selectedInstruction: ConfigurationFileInstructions) { init(selectedInstruction: ConfigurationFileInstructions) {
self.selectedInstruction = selectedInstruction self.selectedInstruction = selectedInstruction
@@ -32,6 +35,7 @@ struct ToolConfigurationView: View {
selectedSecret = created selectedSecret = created
} }
} }
.fixedSize()
} }
} }
} }
@@ -47,6 +51,12 @@ struct ToolConfigurationView: View {
.tag(secret) .tag(secret)
} }
} }
TextField(text: $email, prompt: Text(.integrationsConfigureUsingEmailPlaceholder)) {
Text(.integrationsConfigureUsingEmailTitle)
Text(.integrationsConfigureUsingEmailSubtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
} header: { } header: {
Text(.integrationsConfigureUsingSecretHeader) Text(.integrationsConfigureUsingSecretHeader)
} }
@@ -59,7 +69,7 @@ struct ToolConfigurationView: View {
Section { Section {
ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path)) ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path))
ForEach(stepGroup.steps, id: \.self.key) { step in ForEach(stepGroup.steps, id: \.self.key) { step in
ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(String(localized: step))) { ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(placeholdersReplaced(text: String(localized: step)))) {
HStack { HStack {
Text(placeholdersReplaced(text: String(localized: step))) Text(placeholdersReplaced(text: String(localized: step)))
.padding(8) .padding(8)
@@ -101,10 +111,11 @@ struct ToolConfigurationView: View {
func placeholdersReplaced(text: String) -> String { func placeholdersReplaced(text: String) -> String {
guard let selectedSecret else { return text } guard let selectedSecret else { return text }
let writer = OpenSSHPublicKeyWriter() let writer = OpenSSHPublicKeyWriter()
let fileController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL) let gitAllowedSignersString = [email.isEmpty ? String(localized: .integrationsConfigureUsingEmailPlaceholder) : email, writer.openSSHString(secret: selectedSecret)]
.joined(separator: " ")
return text return text
.replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: writer.openSSHString(secret: selectedSecret)) .replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: gitAllowedSignersString)
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: fileController.publicKeyPath(for: selectedSecret)) .replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: URL.publicKeyPath(for: selectedSecret, in: URL.publicKeyDirectory))
} }
} }

View File

@@ -24,7 +24,7 @@ extension View {
} }
struct MenuButtonModifier: ViewModifier { struct ToolbarCircleButtonModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
@@ -40,8 +40,8 @@ struct MenuButtonModifier: ViewModifier {
extension View { extension View {
func menuButton() -> some View { func toolbarCircleButton() -> some View {
modifier(MenuButtonModifier()) modifier(ToolbarCircleButtonModifier())
} }
} }

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