Compare commits

...

120 Commits

Author SHA1 Message Date
Max Goedjen
9baf6aa9ed Update SecretListItemView.swift
Removed delete confirmation handling from SecretListItemView.
2025-12-13 20:10:46 -08: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
Max Goedjen
96ef91df0c Use macOS 26 runner (#691)
* Update macOS version in GitHub Actions workflow

* Update macOS version in release workflow

* Update macOS version for nightly workflow

* Fix runner OS for Swift in CodeQL workflow
2025-09-12 01:15:54 +00:00
Max Goedjen
aa46d8fa48 Pull in new localizations (#690) 2025-09-11 18:10:51 -07:00
Max Goedjen
cf7c6e9fbe Fix detail update on dismiss of edit screen. (#687) 2025-09-10 07:29:12 +00:00
Max Goedjen
7c7db56c1e Fix unavail modifiable empty store (#686) 2025-09-10 00:24:06 -07:00
Max Goedjen
cd12e4c828 Crowdin readme (#685) 2025-09-09 23:47:17 -07:00
Max Goedjen
a5b43ea046 Cleanup and string fixes (#684)
* Setup UI tweaks.

* Protection level string

* Toolbar cleanup

* More strings.
2025-09-09 23:00:26 -07:00
Max Goedjen
8c516e128a Enabling strict memory safety. (#683) 2025-09-09 20:41:29 -07:00
Max Goedjen
6854c05763 Remove deprecated warning setting from XPCWrappers (#682) 2025-09-09 06:28:38 +00:00
Max Goedjen
5467474d88 Switch to higher level XPC & enforce signing requirements (#681)
* Revert "Add launch constraints (#678)"

This reverts commit c5a610d786.

* .

* Cleanup.
2025-09-09 06:25:40 +00:00
Max Goedjen
5c2d039682 Update release.yml (#680) 2025-09-08 07:32:03 +00:00
Max Goedjen
20e64604d6 Replace headers with silgen (#679)
* Revert "Put back"

This reverts commit 37d791f787.

* Cleanup
2025-09-08 00:28:24 -07:00
Max Goedjen
c5a610d786 Add launch constraints (#678)
* XPC updater POC

* WIP

* obo

* WIP

* Working

* .

* .

* .

* Cleanup

* Cleanup

* Throw restrict

* Remove dead protocol.

* .

* Fix ECDSA test.

* Scripts

* WIP

* .

* Put back

* Put back
2025-09-08 07:20:24 +00:00
Max Goedjen
7d21e3983c Set teams (#677) 2025-09-08 06:50:33 +00:00
Max Goedjen
2c38aaed6f Move internet access policy to xpc (#676) 2025-09-07 06:28:58 +00:00
Max Goedjen
63b42bd9df Move downloader and socket input parsing to xpc services (#675)
* XPC updater POC

* WIP

* obo

* WIP

* Working

* .

* .

* .

* Cleanup

* Cleanup

* Throw restrict

* Remove dead protocol.

* .

* Fix ECDSA test.

* Scripts
2025-09-06 23:16:23 -07:00
Max Goedjen
cf5ae49ebc Remove unneeded protocosl (#674) 2025-09-06 19:53:10 +00:00
Max Goedjen
61705af42f Extend parsing to cover the full set of protocol messages (#673)
* WIP

* Cleanup

* WIP

* Strip out extensions

* Remove singature reader
2025-09-06 19:48:59 +00:00
Max Goedjen
558ae15b2d Missing secrets help (#671) 2025-09-04 08:10:50 +00:00
Max Goedjen
902d5c4a1e Nightly display version (#670)
* Link to nightly and parse version better

* Add nightly date.
2025-09-04 07:19:55 +00:00
Max Goedjen
e0c24917f2 Add codeql workflow (#655)
* Add codeql workflow

* Disable signing on codeql workflow

Updated build command to disable code signing.

* Archive

* Temporarily disable previews

* Try disabling hardening

* Release mode

* Previews

* Temporarily disable.

* Update xcodebuild command in codeql.yml

* Try swift build output

* Fix

* Revert "Fix"

This reverts commit 064172779a.

* Revert "Try swift build output"

This reverts commit a3aa982223.

* Reset most stuff back to main

* Cleanup workflows and limit a few jobs to read.

* .
2025-09-04 06:46:21 +00:00
Max Goedjen
3d5f0b45bd Add reveal in finder to copyable (#669) 2025-09-04 06:36:50 +00:00
Max Goedjen
74f4f1c0b1 Cleanup socketport construction (#668) 2025-09-04 06:14:55 +00:00
Max Goedjen
c8cf0db1c5 Remove some unused stirngs. (#664) 2025-09-04 05:03:48 +00:00
Max Goedjen
412687467b Digest wants 'sha256:' prefix that the upload step doesn't add for some reason (#667) 2025-09-04 05:01:44 +00:00
Max Goedjen
416a7d5f40 Fix attestation issue from double-zip file process in upload action (#666)
* Remove archives since we don’t strip symbols anyway.

* Attest upload result.
2025-09-03 21:38:22 -07:00
Max Goedjen
c4605fb60e Update nightly.yml (#665) 2025-09-03 08:49:20 +00:00
Max Goedjen
61eed5987c Add permissions to nightly (#663)
* Add permissions to nightly

* Add archives
2025-09-03 08:39:59 +00:00
Max Goedjen
cbef7c6181 Add manual trigger to nightly (#662) 2025-09-03 08:25:00 +00:00
Max Goedjen
63a09390b8 Temporarily disable previews (#661)
* Previews

* Temporarily disable.
2025-09-03 08:11:55 +00:00
Max Goedjen
a4e1ab9eb6 Move (#659) 2025-09-03 07:29:58 +00:00
Max Goedjen
147f4d9908 New setup (#657)
* WIP

* WIP

* WIP

* Tweaks.

* WIP

* WIP

* WIP

* WIP

* WIP

* Cleanup

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* REmove setup menu item

* WIP

* .

* .

* .

* Cleaup.
2025-09-03 07:20:24 +00:00
Max Goedjen
ddcb2a36ec Fixes to agent reloading. (#656) 2025-09-02 03:09:32 +00:00
Max Goedjen
6dc93806a8 Enable GitHub private security issue reporting and update policies (#653)
* Revise security vulnerability reporting process

Updated security reporting instructions in README.md.

* Change vulnerability reporting email to GitHub feature

Updated the vulnerability reporting method to use GitHub's private reporting feature.
2025-09-02 01:46:06 +00:00
Max Goedjen
99a6d48e53 Specify private key usage explicitly (#652) 2025-09-01 02:41:47 +00:00
Max Goedjen
935ac32ea2 Factor out comment writer. (#651) 2025-08-31 23:07:35 +00:00
Max Goedjen
a0a632f245 Save nil if empty string. (#650) 2025-08-31 23:03:59 +00:00
Max Goedjen
51fed9e593 Fix potential timing bug (#649) 2025-08-31 14:22:08 -07:00
Max Goedjen
f652d1d961 Return name as identities comment. (#647) 2025-08-31 20:50:16 +00:00
Max Goedjen
8aacd428b1 Fix deleting key attribution (#648) 2025-08-30 22:41:15 +00:00
Max Goedjen
c2eb5ce1f6 Delete .github/workflows/add-to-project.yml (#646)
This appears to be built in now?
2025-08-30 03:07:21 +00:00
Max Goedjen
fb4dec383b Move delete to use a confirmation dialog + various other fixes. (#645) 2025-08-28 06:45:56 +00:00
Max Goedjen
c5052dd457 Little bit of cleanup in agent code (#643) 2025-08-28 06:43:33 +00:00
Max Goedjen
d967c7de07 Pass in MLDSA auth context (#644) 2025-08-28 04:39:25 +00:00
Max Goedjen
d5b6382dd0 Add security principles documentation to SECURITY.md (#642) 2025-08-27 21:34:37 -07:00
Max Goedjen
e8fcb95db0 Rewrite SocketController (#634)
* WIP

* Working

* Working

* Cleanup
2025-08-27 06:44:16 +00:00
Max Goedjen
8ad2d60082 Remove logic to deal with sending/not sending distrib notifications (#641) 2025-08-27 06:15:17 +00:00
Max Goedjen
6ce0510f21 Fix eraser. (#640) 2025-08-27 06:14:07 +00:00
Max Goedjen
c52728a050 Return created key (#638)
* Return created key.

* Fix
2025-08-27 05:43:08 +00:00
Max Goedjen
8f4d0b8eda Static vars for keytypes (#639)
* Static inits for keytype.

* Missed
2025-08-26 22:41:01 -07:00
Max Goedjen
c37d0c0cba Update badge links in README.md (#637) 2025-08-27 01:56:04 +00:00
Max Goedjen
6c607f065c Add link to config repo (#636)
* Revise app configuration documentation

* Update link to secretive configuration instructions
2025-08-27 01:50:30 +00:00
Max Goedjen
cff1fde90e Update badge links in README.md to main branch (#635) 2025-08-26 18:48:03 -07:00
Max Goedjen
c32ceb6ad8 Fix creation of auth required keys. (#633) 2025-08-25 03:36:12 +00:00
Max Goedjen
f0a6f2e43b Save text (#632) 2025-08-25 03:19:29 +00:00
Max Goedjen
828c61cb2f Add support for MLDSA keys (#631)
* WIP.

* WIP

* WIP Edit

* Key selection.

* WIP

* WIP

* Proxy through

* WIP

* Remove verify.

* Migration.

* Comment

* Add param

* Semi-offering key

* Ignore updates if test build.

* Fix rsa public key gen

* Messily fix RSA

* Remove 1024 bit rsa

* Cleanup

* Cleanup

* MLDSA warning.

* MLDSA working.

* Strings.

* Put back UI changes
2025-08-25 03:02:51 +00:00
Max Goedjen
e8c5336888 Don't notify on test builds (#629) 2025-08-24 22:51:55 +00:00
Max Goedjen
b3bea27f40 Cleanup localization. (#630) 2025-08-24 22:50:36 +00:00
Max Goedjen
3d3d123484 CryptoKit migration (#628)
* WIP.

* WIP

* WIP Edit

* Key selection.

* WIP

* WIP

* Proxy through

* WIP

* Remove verify.

* Migration.

* Comment

* Add param

* Semi-offering key

* Ignore updates if test build.

* Fix rsa public key gen

* Messily fix RSA

* Remove 1024 bit rsa

* Cleanup

* Cleanup

* Clean out MLDSA refs for now

* Dump notifier changes

* Put back UI tweaks

* Fixes.
2025-08-24 15:35:15 -07:00
Max Goedjen
5b0135d694 Remove unused localizations (#627) 2025-08-23 20:57:46 -07:00
Max Goedjen
5067f05558 Run test not just build (#624)
* Change build command to run tests instead of build

* Change build command to test in release workflow

* Add root test
2025-08-23 20:52:34 -07:00
Max Goedjen
b815c1e5ad Cleanup packages/dead imports (#625)
* Slim package

* Cleanup

* .

* Expose tokenID.

* Expose some constants.

* Open.

* Combine cleanup

* Make eraser base public.

* Reload

* Fix concurrency issue on key insertion.

* Add capabilities.

* .

* Revert "."

This reverts commit 7c5c2924fa.

* Revert "Add capabilities."

This reverts commit bfa7a3cd51.
2025-08-23 20:42:08 -07:00
Max Goedjen
75c9b5bb62 Support infra for extensions (#626) 2025-08-23 20:41:08 -07:00
Max Goedjen
f259cf6bf3 Add slimmed Package.swift to root (#623) 2025-08-23 20:35:36 -07:00
Max Goedjen
2ba73ff680 Fixes for insertion handler on smartcard (#622) 2025-08-24 03:33:12 +00:00
Max Goedjen
e3938caecb Remove unused verify functions. (#621) 2025-08-23 22:26:40 +00:00
Max Goedjen
bd096c3012 Add attestation info to readme (#620)
* Update README.md

* Enhance README with attestation visibility details

* Update README to clarify build process and attestations
2025-08-23 22:07:09 +00:00
Max Goedjen
2355d3f989 Use the symlink (#619) 2025-08-21 05:20:40 +00:00
Max Goedjen
45bcb03fef Enable enhanced security. (#618) 2025-08-20 07:10:23 +00:00
Max Goedjen
e86aa559a4 Remove unchecked sendable (#617) 2025-08-20 06:32:46 +00:00
Max Goedjen
d36537b919 Release and attestation tweaks (#616)
* Abs path

* Write.

* Pass attestation.

* Attest nightly
2025-08-19 23:27:58 -07:00
Max Goedjen
8adb4423ac Release management using gh cli (#615) 2025-08-19 07:24:22 +00:00
Max Goedjen
8dbf992cce Add attestation (#614) 2025-08-19 07:07:43 +00:00
Max Goedjen
f382d72ee5 Update localization status (#613) 2025-08-18 03:31:53 +00:00
Max Goedjen
9749cd6f3e Switch to generated localized string symbols (#607)
* Switch to string symbols

* Names

* Cleanup packages

* Cleanup packages

* Remove namespace

* More cleanup

* Fix extra param.

* Use swiftbuild
2025-08-18 03:26:13 +00:00
Max Goedjen
83ecc15332 Dim cells on background (#612) 2025-08-18 03:19:13 +00:00
Max Goedjen
ecd001a082 Update (#611) 2025-08-18 02:42:23 +00:00
150 changed files with 31687 additions and 9255 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

16
.github/templates/release.md vendored Normal file
View File

@@ -0,0 +1,16 @@
Update description
## Features
## Fixes
## Minimum macOS Version
## Build
https://github.com/maxgoedjen/secretive/actions/runs/RUN_ID
## Attestation
https://github.com/maxgoedjen/secretive/attestations/ATTESTATION_ID

View File

@@ -1,16 +0,0 @@
name: Add bugs to bugs project
on:
issues:
types:
- opened
jobs:
add-to-project:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v1.0.1
with:
project-url: https://github.com/users/maxgoedjen/projects/1
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}

47
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '26 15 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
# Disable this until CodeQL supports Xcode 26 builds.
# - language: swift
# build-mode: manual
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
name: "Select Xcode"
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
- if: matrix.build-mode == 'manual'
name: "Build"
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -3,13 +3,18 @@ name: Nightly
on:
schedule:
- cron: "0 8 * * *"
jobs:
build:
# runs-on: macOS-latest
runs-on: macos-15
runs-on: macos-26
permissions:
id-token: write
contents: write
attestations: write
actions: read
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -20,35 +25,41 @@ jobs:
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_beta_5.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
- name: Update Build Number
env:
RUN_ID: ${{ github.run_id }}
run: |
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig
DATE=$(date "+%Y-%m-%d")
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_nightly-$DATE/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_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
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs
- name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4
with:
name: Secretive
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.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
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: Document SHAs
run: |
echo "sha-512:"
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
echo "sha-256:"
shasum -a 256 Secretive.zip
shasum -a 256 Archive.zip
- name: Upload App to Artifacts
uses: actions/upload-artifact@v4
- name: Attest
id: attest
uses: actions/attest-build-provenance@v2
with:
name: Secretive.zip
path: Secretive.zip
subject-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}

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

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

View File

@@ -6,11 +6,12 @@ on:
- '*'
jobs:
test:
# runs-on: macOS-latest
runs-on: macos-15
permissions:
contents: read
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -21,18 +22,21 @@ jobs:
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_beta_5.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
- name: Test
run: |
pushd Sources/Packages
swift test
popd
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently?
# run: swift test --build-system swiftbuild --package-path Sources/Packages
build:
# runs-on: macOS-latest
runs-on: macos-15
permissions:
id-token: write
contents: write
attestations: write
actions: read
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -43,7 +47,7 @@ jobs:
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_beta_5.app
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
- name: Update Build Number
env:
TAG_NAME: ${{ github.ref }}
@@ -52,67 +56,43 @@ jobs:
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_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
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs
- name: Move to Artifact Folder
run: mkdir Artifact; cp -r Archive.xcarchive/Products/Applications/Secretive.app Artifact
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Artifact
- name: Download Zipped Artifact
id: download
env:
ZIP_ID: ${{ steps.upload.outputs.artifact-id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.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
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: Document SHAs
run: |
echo "sha-512:"
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
echo "sha-256:"
shasum -a 256 Secretive.zip
shasum -a 256 Archive.zip
- name: Attest
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-path: "Secretive.zip"
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: |
Update description
## Features
## Fixes
## Minimum macOS Version
## Build
https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }}
draft: true
prerelease: false
- name: Upload App to Release
id: upload-release-asset-app
uses: actions/upload-release-asset@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./Secretive.zip
asset_name: Secretive.zip
asset_content_type: application/zip
- name: Upload App to Artifacts
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Secretive.zip
- name: Upload Archive to Artifacts
uses: actions/upload-artifact@v4
with:
name: Xcode_Archive.zip
path: Archive.zip
TAG_NAME: ${{ github.ref }}
RUN_ID: ${{ github.run_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

@@ -3,15 +3,17 @@ name: Test
on: [push, pull_request]
jobs:
test:
# runs-on: macOS-latest
runs-on: macos-15
permissions:
contents: read
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta_5.app
- name: Test
run: |
pushd Sources/Packages
swift test
popd
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
- name: Test Main Packages
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
# SPM doesn't seem to pick up on the tests currently?
# run: swift test --build-system swiftbuild --package-path Sources/Packages
- name: Test SecretKit Packages
run: swift test --build-system swiftbuild

View File

@@ -1,125 +1,3 @@
# Setting up Third Party Apps FAQ
# App Configuration
## Tower
Tower provides [instructions](https://www.git-tower.com/help/mac/integration/environment).
## GitHub Desktop
Should just work, no configuration needed
## Fork
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
## VS Code
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
## nushell
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
## Cyberduck
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Cyberduck.
## Mountain Duck
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Mountain Duck.
## GitKraken
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
## Retcon
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
# The app I use isn't listed here!
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
If you're not able to get it working, please file a [GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) for it. No guarantees we'll be able to get it working, but chances are someone else in the community might be able to.
Instructions for setting up apps and shells has moved to [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions)!

2
FAQ.md
View File

@@ -6,7 +6,7 @@ The secure enclave doesn't allow import or export of private keys. For any new c
### Secretive doesn't work with my git client/app
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [App Config FAQ](APP_CONFIG.md).
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions) repo.
### Secretive isn't working for me

View File

@@ -2,36 +2,35 @@
If you speak another language, and would like to help translate Secretive to support that language, we'd love your help!
## Getting Started
## Crowdin
### Download Xcode
[Secretive uses Crowdin for localization](https://crowdin.com/project/secretive/). Open the link and select your language to translate!
Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/).
### Manual Translation
### Clone Secretive
Clone Secretive using [these instructions from GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository).
### Open Secretive
Open [Sources/Secretive.xcodeproj](Sources/Secretive.xcodeproj) in Xcode.
### Translate
Navigate to [Secretive/Localizable](Sources/Secretive/Localizable.xcstrings).
<img src="/.github/readme/localize_sidebar.png" alt="Screenshot of Xcode navigating to the Localizable file" width="300">
If your language already has an in-progress localization, select it from the list. If it isn't there, hit the "+" button and choose your language from the list.
<img src="/.github/readme/localize_add.png" alt="Screenshot of Xcode adding a new language" width="600">
Start translating! You'll see a list of english phrases, and a space to add a translation of your language.
### Create a Pull Request
Push your changes and open a pull request.
Crowdin is the easiest way to translate Secretive, but I'm happy to accept Pull Requests directly as well.
### Questions
Please open an issue if you have a question about translating the app. I'm more than happy to clarify any terms that are ambiguous or confusing. Thanks for contributing!
### Thank You
Thanks to all the folks who have contributed localizations so far!
- @mtardy for the French localization
- @GravityRyu for the Chinese localization
- @Saeger for the Portuguese (Brazil) localization
- @moritzsternemann for the German localization
- @RoboRich00A16 for the Italian localization
- @akx for the Finnish localization
- @mog422 for the Korean localization
- @niw for the Japanese localization
- @truita for the Catalan localization
- @Adimac93 for the Polish localization
- @alongotv for the Russian localization
A special thanks to [Crowdin](https://crowdin.com) for their [generous support of open source projects](https://crowdin.com/page/open-source-project-setup-request).

69
Package.swift Normal file
View File

@@ -0,0 +1,69 @@
// swift-tools-version:6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
// This is basically the same package as `Sources/Packages/Package.swift`, but thinned slightly.
// Ideally this would be the same package, but SPM requires it to be at the root of the project,
// and Xcode does _not_ like that, so they're separate.
let package = Package(
name: "SecretKit",
defaultLocalization: "en",
platforms: [
.macOS(.v14)
],
products: [
.library(
name: "SecretKit",
targets: ["SecretKit"]),
.library(
name: "SecureEnclaveSecretKit",
targets: ["SecureEnclaveSecretKit"]),
.library(
name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]),
],
dependencies: [
],
targets: [
.target(
name: "SecretKit",
dependencies: [],
path: "Sources/Packages/Sources/SecretKit",
resources: [localization],
swiftSettings: swiftSettings
),
.testTarget(
name: "SecretKitTests",
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
path: "Sources/Packages/Tests/SecretKitTests",
swiftSettings: swiftSettings
),
.target(
name: "SecureEnclaveSecretKit",
dependencies: ["SecretKit"],
path: "Sources/Packages/Sources/SecureEnclaveSecretKit",
resources: [localization],
swiftSettings: swiftSettings
),
.target(
name: "SmartCardSecretKit",
dependencies: ["SecretKit"],
path: "Sources/Packages/Sources/SmartCardSecretKit",
resources: [localization],
swiftSettings: swiftSettings
),
]
)
var localization: Resource {
.process("../../Resources/Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
// This freaks out Xcode in a dependency context.
// .treatAllWarnings(as: .error),
]
}

View File

@@ -1,11 +1,11 @@
# Secretive ![Test](https://github.com/maxgoedjen/secretive/workflows/Test/badge.svg) ![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>
<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>
@@ -13,7 +13,7 @@ Secretive is an app for storing and managing SSH keys in the Secure Enclave. It
### 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
@@ -49,11 +49,11 @@ There's a [FAQ here](FAQ.md).
### Auditable Build Process
Builds are produced by GitHub Actions with an auditable build and release generation process. Each build has a "Document SHAs" step, which will output SHA checksums for the build produced by the GitHub Action, so you can verify that the source code for a given build corresponds to any given release.
Builds are produced by GitHub Actions with an auditable build and release generation process. Starting with Secretive 3.0, builds are attested using [GitHub Artifact Attestation](https://docs.github.com/en/actions/concepts/security/artifact-attestations). Attestations are viewable in the build log for a build, and also on the [main attestation page](https://github.com/maxgoedjen/secretive/attestations).
### A Note Around Code Signing and Keychains
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
@@ -61,4 +61,12 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
## Security
If you discover any vulnerabilities in this project, please notify [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with the subject containing "SECRETIVE SECURITY."
Secretive's security policy is detailed in [SECURITY.md](SECURITY.md). To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
## Acknowledgements
### sekey
Secretive was inspired by the [sekey project](https://github.com/sekey/sekey).
### Localization
Secretive is localized to many languages by a generous team of volunteers. To learn more, see [LOCALIZING.md](LOCALIZING.md). Secretive's localization workflow is generously provided by [Crowdin](https://crowdin.com).

View File

@@ -1,9 +1,27 @@
# Security Policy
## Security Principles
Secretive is designed with a few general tenets in mind:
### It's Hard to Leak a Key Secretive Can't Read The Key Material
Secretive only operates on hardware-backed keys. In general terms, this means that it should be _very_ hard for Secretive to have any sort of bug that causes a key to be shared, because Secretive can't access private key data even if it wants to.
### Simplicity and Auditability
Secretive won't expand to have every feature it could possibly have. Part of the goal of the app is that it is possible for consumers to reasonably audit the code, and that often means not implementing features that might be cool, but which would significantly inflate the size of the codebase.
### Dependencies
Both in support of the previous principle and to rule out supply chain attacks, Secretive does not rely on any third party dependencies.
There are limited exceptions to this, particularly in the build process, but the app itself does not depend on any third party code.
## Supported Versions
The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version.
## Reporting a Vulnerability
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY."
To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)

View File

@@ -1,2 +1,3 @@
CI_VERSION = GITHUB_CI_VERSION
CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER
CI_BUILD_LINK = GITHUB_BUILD_URL

View File

@@ -13,12 +13,24 @@
},
"testTargets" : [
{
"enabled" : false,
"parallelizable" : true,
"target" : {
"containerPath" : "container:Secretive.xcodeproj",
"identifier" : "50617D9323FCE48E0099B055",
"name" : "SecretiveTests"
"containerPath" : "container:Packages",
"identifier" : "BriefTests",
"name" : "BriefTests"
}
},
{
"target" : {
"containerPath" : "container:Packages",
"identifier" : "SecretKitTests",
"name" : "SecretKitTests"
}
},
{
"target" : {
"containerPath" : "container:Packages",
"identifier" : "SecretAgentKitTests",
"name" : "SecretAgentKitTests"
}
}
],

View File

@@ -5,6 +5,7 @@ import PackageDescription
let package = Package(
name: "SecretivePackages",
defaultLocalization: "en",
platforms: [
.macOS(.v14)
],
@@ -20,13 +21,16 @@ let package = Package(
targets: ["SmartCardSecretKit"]),
.library(
name: "SecretAgentKit",
targets: ["SecretAgentKit"]),
targets: ["SecretAgentKit", "XPCWrappers"]),
.library(
name: "SecretAgentKitHeaders",
targets: ["SecretAgentKitHeaders"]),
name: "Common",
targets: ["Common"]),
.library(
name: "Brief",
targets: ["Brief"]),
.library(
name: "XPCWrappers",
targets: ["XPCWrappers"]),
],
dependencies: [
],
@@ -34,50 +38,67 @@ let package = Package(
.target(
name: "SecretKit",
dependencies: [],
swiftSettings: swiftSettings
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SecretKitTests",
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
swiftSettings: swiftSettings
swiftSettings: swiftSettings,
),
.target(
name: "SecureEnclaveSecretKit",
dependencies: ["SecretKit"],
swiftSettings: swiftSettings
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SmartCardSecretKit",
dependencies: ["SecretKit"],
swiftSettings: swiftSettings
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SecretAgentKit",
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
swiftSettings: swiftSettings
),
.systemLibrary(
name: "SecretAgentKitHeaders"
dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "SecretAgentKitTests",
dependencies: ["SecretAgentKit"])
,
dependencies: ["SecretAgentKit"],
),
.target(
name: "Common",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "Brief",
dependencies: [],
swiftSettings: swiftSettings
dependencies: ["XPCWrappers"],
resources: [localization],
swiftSettings: swiftSettings,
),
.testTarget(
name: "BriefTests",
dependencies: ["Brief"]
dependencies: ["Brief"],
),
.target(
name: "XPCWrappers",
swiftSettings: swiftSettings,
),
]
)
var localization: Resource {
.process("../../Resources/Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error),
.strictMemorySafety()
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import Foundation
import SwiftUI
/// 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"
public let name: String
@@ -15,6 +16,8 @@ public struct Release: Codable, Sendable {
/// A user-facing description of the contents of the update.
public let body: String
public let attributedBody: AttributedString
/// Initializes a Release.
/// - Parameters:
/// - name: The user-facing name of the release.
@@ -26,6 +29,56 @@ public struct Release: Codable, Sendable {
self.prerelease = prerelease
self.html_url = html_url
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

@@ -5,12 +5,20 @@ public struct SemVer: Sendable {
/// The SemVer broken into an array of integers.
let versionNumbers: [Int]
public let previewDescription: String?
public var isTestBuild: Bool {
versionNumbers == [0, 0, 0]
}
/// Initializes a SemVer from a string representation.
/// - Parameter version: A string representation of the SemVer, formatted as "major.minor.patch".
public init(_ version: String) {
// Betas have the format 1.2.3_beta1
let strippedBeta = version.split(separator: "_").first!
// Nightlies have the format 0.0.0_nightly-2025-09-03
let splitFull = version.split(separator: "_")
let strippedBeta = splitFull.first!
previewDescription = splitFull.count > 1 ? String(splitFull[1]) : nil
var split = strippedBeta.split(separator: ".").compactMap { Int($0) }
while split.count < 3 {
split.append(0)
@@ -22,6 +30,7 @@ public struct SemVer: Sendable {
/// - Parameter version: An `OperatingSystemVersion` representation of the SemVer.
public init(_ version: OperatingSystemVersion) {
versionNumbers = [version.majorVersion, version.minorVersion, version.patchVersion]
previewDescription = nil
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
import Observation
import XPCWrappers
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
@Observable public final class Updater: UpdaterProtocol, Sendable {
@@ -13,12 +14,11 @@ import Observation
state.update
}
public let testBuild: Bool
/// The current version of the app that is running.
public let currentVersion: SemVer
/// The current OS version.
private let osVersion: SemVer
/// The current version of the app that is running.
private let currentVersion: SemVer
/// Initializes an Updater.
/// - Parameters:
@@ -34,28 +34,25 @@ import Observation
) {
self.osVersion = osVersion
self.currentVersion = currentVersion
testBuild = currentVersion == SemVer("0.0.0")
if checkOnLaunch {
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
Task {
await checkForUpdates()
}
}
Task {
if checkOnLaunch {
try await checkForUpdates()
}
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
await checkForUpdates()
try await checkForUpdates()
}
}
}
/// Manually trigger an update check.
public func checkForUpdates() async {
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL) else { return }
guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
await evaluate(releases: releases)
public func checkForUpdates() async throws {
let session = try await XPCTypedSession<[Release], Never>(serviceName: "com.maxgoedjen.Secretive.SecretiveUpdater")
await evaluate(releases: try await session.send())
session.complete()
}
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
/// - Parameter release: The release to ignore.
public func ignore(release: Release) async {
@@ -102,11 +99,3 @@ extension Updater {
}
}
extension Updater {
enum Constants {
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")!
}
}

View File

@@ -5,8 +5,8 @@ public protocol UpdaterProtocol: Observable, Sendable {
/// The latest update
@MainActor var update: Release? { get }
/// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
var testBuild: Bool { get }
var currentVersion: SemVer { get }
func ignore(release: Release) async
}

View File

@@ -0,0 +1,11 @@
import Foundation
extension Bundle {
public static var agentBundleID: String {
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "Host", with: "SecretAgent")
}
public static var hostBundleID: String {
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "SecretAgent", with: "Host")
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
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
}
}
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

@@ -9,8 +9,8 @@ public final class Agent: Sendable {
private let storeList: SecretStoreList
private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer()
private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let signatureWriter = OpenSSHSignatureWriter()
private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@@ -31,52 +31,32 @@ public final class Agent: Sendable {
extension Agent {
/// Handles an incoming request.
/// - Parameters:
/// - reader: A ``FileHandleReader`` to read the content of the request.
/// - writer: A ``FileHandleWriter`` to write the response to.
/// - Return value:
/// - Boolean if data could be read
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) async -> Bool {
logger.debug("Agent handling new data")
let data = Data(reader.availableData)
guard data.count > 4 else { return false}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return true
}
logger.debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...])
let response = await handle(requestType: requestType, data: subData, reader: reader)
writer.write(response)
return true
}
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) async -> Data {
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
await reloadSecretsIfNeccessary()
var response = Data()
do {
switch requestType {
switch request {
case .requestIdentities:
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
response.append(SSHAgent.Response.agentIdentitiesAnswer.data)
response.append(await identities())
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
case .signRequest:
let provenance = requestTracer.provenance(from: reader)
response.append(SSHAgent.ResponseType.agentSignResponse.data)
response.append(try await sign(data: data, provenance: provenance))
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
case .signRequest(let context):
response.append(SSHAgent.Response.agentSignResponse.data)
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .unknown(let value):
logger.error("Agent received unknown request of type \(value).")
throw UnhandledRequestError()
default:
logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.")
throw UnhandledRequestError()
}
} catch {
response.removeAll()
response.append(SSHAgent.ResponseType.agentFailure.data)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
response = SSHAgent.Response.agentFailure.data
logger.debug("Agent returned \(SSHAgent.Response.agentFailure.debugDescription)")
}
let full = OpenSSHKeyWriter().lengthAndData(of: response)
return full
return response.lengthAndData
}
}
@@ -88,24 +68,24 @@ extension Agent {
func identities() async -> Data {
let secrets = await storeList.allSecrets
await certificateHandler.reloadCertificates(for: secrets)
var count = secrets.count
var count = 0
var keyData = Data()
for secret in secrets {
let keyBlob = writer.data(secret: secret)
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
keyData.append(writer.lengthAndData(of: keyBlob))
keyData.append(writer.lengthAndData(of: curveData))
let keyBlob = publicKeyWriter.data(secret: secret)
keyData.append(keyBlob.lengthAndData)
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
count += 1
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(writer.lengthAndData(of: certificateData))
keyData.append(writer.lengthAndData(of: name))
keyData.append(certificateData.lengthAndData)
keyData.append(name.lengthAndData)
count += 1
}
}
logger.log("Agent enumerated \(count) identities")
var countBigEndian = UInt32(count).bigEndian
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
let countData = unsafe Data(bytes: &countBigEndian, count: MemoryLayout<UInt32>.size)
return countData + keyData
}
@@ -114,71 +94,19 @@ extension Agent {
/// - data: The data to sign.
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
let reader = OpenSSHReader(data: data)
let payloadHash = reader.readNextChunk()
let hash: Data
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
hash = certificatePublicKey
} else {
hash = payloadHash
}
guard let (store, secret) = await secret(matching: hash) else {
logger.debug("Agent did not have a key matching \(hash as NSData)")
throw AgentError.noMatchingKey
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
guard let (secret, store) = await secret(matching: keyBlob) else {
let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined()
logger.debug("Agent did not have a key matching \(keyBlobHex)")
throw NoMatchingKeyError()
}
if let witness = witness {
try await witness.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
}
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
let dataToSign = reader.readNextChunk()
let signed = try await store.sign(data: dataToSign, with: secret, for: provenance)
let derSignature = signed
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
// Convert from DER formatted rep to raw (r||s)
let rawRepresentation: Data
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
case (.ellipticCurve, 384):
rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
default:
throw AgentError.unsupportedKeyType
}
let rawLength = rawRepresentation.count/2
// Check if we need to pad with 0x00 to prevent certain
// ssh servers from thinking r or s is negative
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
var r = Data(rawRepresentation[0..<rawLength])
if paddingRange ~= r.first! {
r.insert(0x00, at: 0)
}
var s = Data(rawRepresentation[rawLength...])
if paddingRange ~= s.first! {
s.insert(0x00, at: 0)
}
var signatureChunk = Data()
signatureChunk.append(writer.lengthAndData(of: r))
signatureChunk.append(writer.lengthAndData(of: s))
var signedData = Data()
var sub = Data()
sub.append(writer.lengthAndData(of: curveData))
sub.append(writer.lengthAndData(of: signatureChunk))
signedData.append(writer.lengthAndData(of: sub))
if let witness = witness {
try await witness.witness(accessTo: secret, from: store, by: provenance)
}
try await witness?.witness(accessTo: secret, from: store, by: provenance)
logger.debug("Agent signed request")
@@ -203,16 +131,10 @@ extension Agent {
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
/// - Parameter hash: The hash to match against.
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
for store in await storeList.stores {
let allMatching = await store.secrets.filter { secret in
hash == writer.data(secret: secret)
}
if let matching = allMatching.first {
return (store, matching)
}
func secret(matching hash: Data) async -> (AnySecret, AnySecretStore)? {
await storeList.allSecretsWithStores.first {
hash == publicKeyWriter.data(secret: $0.0)
}
return nil
}
}
@@ -220,21 +142,16 @@ extension Agent {
extension Agent {
/// An error involving agent operations..
enum AgentError: Error {
case unhandledType
case noMatchingKey
case unsupportedKeyType
case notOpenSSHCertificate
}
struct NoMatchingKeyError: Error {}
struct UnhandledRequestError: Error {}
}
extension SSHAgent.ResponseType {
extension SSHAgent.Response {
var data: Data {
var raw = self.rawValue
return Data(bytes: &raw, count: UInt8.bitWidth/8)
return unsafe Data(bytes: &raw, count: MemoryLayout<UInt8>.size)
}
}

View File

@@ -1,32 +1,12 @@
import Foundation
/// Protocol abstraction of the reading aspects of FileHandle.
public protocol FileHandleReader: Sendable {
/// Gets data that is available for reading.
var availableData: Data { get }
/// A file descriptor of the handle.
var fileDescriptor: Int32 { get }
/// The process ID of the process coonnected to the other end of the FileHandle.
var pidOfConnectedProcess: Int32 { get }
}
/// Protocol abstraction of the writing aspects of FileHandle.
public protocol FileHandleWriter: Sendable {
/// Writes data to the handle.
func write(_ data: Data)
}
extension FileHandle: FileHandleReader, FileHandleWriter {
extension FileHandle {
public var pidOfConnectedProcess: Int32 {
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1)
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: MemoryLayout<Int32>.size, alignment: 1)
var len = socklen_t(MemoryLayout<Int32>.size)
getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
return pidPointer.load(as: Int32.self)
unsafe getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
return unsafe pidPointer.load(as: Int32.self)
}
}

View File

@@ -1,12 +1,13 @@
import Foundation
import OSLog
import SecretKit
/// Manages storage and lookup for OpenSSH certificates.
public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter()
private let writer = OpenSSHPublicKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler.
@@ -25,30 +26,6 @@ public actor OpenSSHCertificateHandler: Sendable {
}
}
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
public func publicKeyHash(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
default:
return nil
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
@@ -78,14 +55,13 @@ public actor OpenSSHCertificateHandler: Sendable {
throw OpenSSHCertificateError.parsingFailed
}
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) {
if certElements.count >= 3 {
let certName = Data(certElements[2].utf8)
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
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)
}
}

View File

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

View File

@@ -0,0 +1,109 @@
import Foundation
import OSLog
import SecretKit
public protocol SSHAgentInputParserProtocol {
func parse(data: Data) async throws -> SSHAgent.Request
}
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
public init() {
}
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
logger.debug("Parsing new data")
guard data.count > 4 else {
throw .invalidData
}
let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4
let rawRequestInt = data[4]
let remainingDataRange = 5..<min(Int(specifiedLength), data.count)
lazy var body: Data = { Data(data[remainingDataRange]) }()
switch rawRequestInt {
case SSHAgent.Request.requestIdentities.protocolID:
return .requestIdentities
case SSHAgent.Request.signRequest(.empty).protocolID:
do {
return .signRequest(try signatureRequestContext(from: body))
} catch {
throw .openSSHReader(error)
}
case SSHAgent.Request.addIdentity.protocolID:
return .addIdentity
case SSHAgent.Request.removeIdentity.protocolID:
return .removeIdentity
case SSHAgent.Request.removeAllIdentities.protocolID:
return .removeAllIdentities
case SSHAgent.Request.addIDConstrained.protocolID:
return .addIDConstrained
case SSHAgent.Request.addSmartcardKey.protocolID:
return .addSmartcardKey
case SSHAgent.Request.removeSmartcardKey.protocolID:
return .removeSmartcardKey
case SSHAgent.Request.lock.protocolID:
return .lock
case SSHAgent.Request.unlock.protocolID:
return .unlock
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
return .addSmartcardKeyConstrained
case SSHAgent.Request.protocolExtension.protocolID:
return .protocolExtension
default:
return .unknown(rawRequestInt)
}
}
}
extension SSHAgentInputParser {
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
let reader = OpenSSHReader(data: data)
let rawKeyBlob = try reader.readNextChunk()
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
let dataToSign = try reader.readNextChunk()
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign)
}
func certificatePublicKeyBlob(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
do {
let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = try reader.readNextChunk() // nonce
let curveIdentifier = try reader.readNextChunk()
let publicKey = try reader.readNextChunk()
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return openSSHIdentifier.lengthAndData +
curveIdentifier.lengthAndData +
publicKey.lengthAndData
default:
return nil
}
} catch {
return nil
}
}
}
extension SSHAgentInputParser {
public enum AgentParsingError: Error, Codable {
case unknownRequest
case unhandledRequest
case invalidData
case openSSHReader(OpenSSHReaderError)
}
}

View File

@@ -6,39 +6,92 @@ public enum SSHAgent {}
extension SSHAgent {
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum RequestType: UInt8, CustomDebugStringConvertible {
public enum Request: CustomDebugStringConvertible, Codable, Sendable {
case requestIdentities = 11
case signRequest = 13
case requestIdentities
case signRequest(SignatureRequestContext)
case addIdentity
case removeIdentity
case removeAllIdentities
case addIDConstrained
case addSmartcardKey
case removeSmartcardKey
case lock
case unlock
case addSmartcardKeyConstrained
case protocolExtension
case unknown(UInt8)
public var protocolID: UInt8 {
switch self {
case .requestIdentities: 11
case .signRequest: 13
case .addIdentity: 17
case .removeIdentity: 18
case .removeAllIdentities: 19
case .addIDConstrained: 25
case .addSmartcardKey: 20
case .removeSmartcardKey: 21
case .lock: 22
case .unlock: 23
case .addSmartcardKeyConstrained: 26
case .protocolExtension: 27
case .unknown(let value): value
}
}
public var debugDescription: String {
switch self {
case .requestIdentities:
return "RequestIdentities"
case .signRequest:
return "SignRequest"
case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES"
case .signRequest: "SSH_AGENTC_SIGN_REQUEST"
case .addIdentity: "SSH_AGENTC_ADD_IDENTITY"
case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY"
case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES"
case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED"
case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY"
case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY"
case .lock: "SSH_AGENTC_LOCK"
case .unlock: "SSH_AGENTC_UNLOCK"
case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED"
case .protocolExtension: "SSH_AGENTC_EXTENSION"
case .unknown: "UNKNOWN_MESSAGE"
}
}
public struct SignatureRequestContext: Sendable, Codable {
public let keyBlob: Data
public let dataToSign: Data
public init(keyBlob: Data, dataToSign: Data) {
self.keyBlob = keyBlob
self.dataToSign = dataToSign
}
public static var empty: SignatureRequestContext {
SignatureRequestContext(keyBlob: Data(), dataToSign: Data())
}
}
}
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum ResponseType: UInt8, CustomDebugStringConvertible {
public enum Response: UInt8, CustomDebugStringConvertible {
case agentFailure = 5
case agentSuccess = 6
case agentIdentitiesAnswer = 12
case agentSignResponse = 14
case agentExtensionFailure = 28
case agentExtensionResponse = 29
public var debugDescription: String {
switch self {
case .agentFailure:
return "AgentFailure"
case .agentSuccess:
return "AgentSuccess"
case .agentIdentitiesAnswer:
return "AgentIdentitiesAnswer"
case .agentSignResponse:
return "AgentSignResponse"
case .agentFailure: "SSH_AGENT_FAILURE"
case .agentSuccess: "SSH_AGENT_SUCCESS"
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
}
}
}

View File

@@ -1,11 +0,0 @@
import Foundation
struct UncheckedSendable<T>: @unchecked Sendable {
let value: T
init(_ value: T) {
self.value = value
}
}

View File

@@ -2,7 +2,6 @@ import Foundation
import AppKit
import Security
import SecretKit
import SecretAgentKitHeaders
/// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects.
struct SigningRequestTracer {
@@ -10,12 +9,11 @@ struct SigningRequestTracer {
extension SigningRequestTracer {
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandleReader``.
/// - Parameter fileHandleReader: The reader involved in processing the request.
/// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandle``.
/// - Parameter fileHandle: The reader involved in processing the request.
/// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request.
func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance {
let firstInfo = process(from: fileHandleReader.pidOfConnectedProcess)
func provenance(from fileHandle: FileHandle) -> SigningRequestProvenance {
let firstInfo = process(from: fileHandle.pidOfConnectedProcess)
var provenance = SigningRequestProvenance(root: firstInfo)
while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil {
provenance.chain.append(process(from: provenance.origin.parentPID!))
@@ -27,30 +25,30 @@ extension SigningRequestTracer {
/// - Parameter pid: The process ID to look up.
/// - Returns: a `kinfo_proc` struct describing the process ID.
func pidAndNameInfo(from pid: Int32) -> kinfo_proc {
var len = MemoryLayout<kinfo_proc>.size
var len = unsafe MemoryLayout<kinfo_proc>.size
let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1)
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
return infoPointer.load(as: kinfo_proc.self)
unsafe sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0)
return unsafe infoPointer.load(as: kinfo_proc.self)
}
/// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID.
/// - Parameter pid: The process ID to look up.
/// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process.
func process(from pid: Int32) -> SigningRequestProvenance.Process {
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
String(cString: pointer)
var pidAndNameInfo = unsafe self.pidAndNameInfo(from: pid)
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
unsafe String(cString: pointer)
}
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
let path = String(cString: pathPointer)
_ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
let path = unsafe String(cString: pathPointer)
var secCode: Unmanaged<SecCode>!
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
unsafe SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
let valid = unsafe SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
}
@@ -81,3 +79,11 @@ extension SigningRequestTracer {
}
}
// from libproc.h
@_silgen_name("proc_pidpath")
@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32
//// from SecTask.h
@_silgen_name("SecCodeCreateWithPID")
@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer<Unmanaged<SecCode>?>!) -> OSStatus

View File

@@ -1,23 +1,32 @@
import Foundation
import OSLog
import SecretKit
/// A controller that manages socket configuration and request dispatching.
public final class SocketController {
public struct SocketController {
/// The active FileHandle.
private var fileHandle: FileHandle?
/// The active SocketPort.
private var port: SocketPort?
/// A handler that will be notified when a new read/write handle is available.
/// False if no data could be read
public var handler: (@Sendable (FileHandleReader, FileHandleWriter) async -> Bool)?
/// Logger.
/// A stream of Sessions. Each session represents one connection to a class communicating with the socket. Multiple Sessions may be active simultaneously.
public let sessions: AsyncStream<Session>
/// A continuation to create new sessions.
private let sessionsContinuation: AsyncStream<Session>.Continuation
/// The active SocketPort. Must be retained to be kept valid.
private let port: SocketPort
/// The FileHandle for the main socket.
private let fileHandle: FileHandle
/// Logger for the socket controller.
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController")
/// Tracer which determines who originates a socket connection.
private let requestTracer = SigningRequestTracer()
/// Initializes a socket controller with a specified path.
/// - Parameter path: The path to use as a socket.
public init(path: String) {
(sessions, sessionsContinuation) = AsyncStream<Session>.makeStream()
logger.debug("Socket controller setting up at \(path)")
if let _ = try? FileManager.default.removeItem(atPath: path) {
logger.debug("Socket controller removed existing socket")
@@ -25,87 +34,113 @@ public final class SocketController {
let exists = FileManager.default.fileExists(atPath: path)
assert(!exists)
logger.debug("Socket controller path is clear")
port = socketPort(at: path)
configureSocket(at: path)
port = SocketPort(path: path)
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
Task { @MainActor [fileHandle, sessionsContinuation, logger] in
// Create the sequence before triggering the notification to
// ensure it will not be missed.
let connectionAcceptedNotifications = NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted)
fileHandle.acceptConnectionInBackgroundAndNotify()
for await notification in connectionAcceptedNotifications {
logger.debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
let session = Session(fileHandle: new)
sessionsContinuation.yield(session)
fileHandle.acceptConnectionInBackgroundAndNotify()
}
}
logger.debug("Socket listening at \(path)")
}
/// Configures the socket and a corresponding FileHandle.
/// - Parameter path: The path to use as a socket.
func configureSocket(at path: String) {
guard let port = port else { return }
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionAccept(notification:)), name: .NSFileHandleConnectionAccepted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionDataAvailable(notification:)), name: .NSFileHandleDataAvailable, object: nil)
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
}
/// Creates a SocketPort for a path.
/// - Parameter path: The path to use as a socket.
/// - Returns: A configured SocketPort.
func socketPort(at path: String) -> SocketPort {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
var len: Int = 0
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
path.withCString { cstring in
len = strlen(cstring)
strncpy(pointer, cstring, len)
}
}
addr.sun_len = UInt8(len+2)
var data: Data!
withUnsafePointer(to: &addr) { pointer in
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
}
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
}
/// Handles a new connection being accepted, invokes the handler, and prepares to accept new connections.
/// - Parameter notification: A `Notification` that triggered the call.
@objc func handleConnectionAccept(notification: Notification) {
logger.debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return }
Task { [handler, fileHandle] in
_ = await handler?(new, new)
await new.waitForDataInBackgroundAndNotifyOnMainActor()
await fileHandle?.acceptConnectionInBackgroundAndNotifyOnMainActor()
}
}
/// Handles a new connection providing data and invokes the handler callback.
/// - Parameter notification: A `Notification` that triggered the call.
@objc func handleConnectionDataAvailable(notification: Notification) {
logger.debug("Socket controller has new data available")
guard let new = notification.object as? FileHandle else { return }
logger.debug("Socket controller received new file handle")
Task { [handler, logger = UncheckedSendable(logger)] in
if((await handler?(new, new)) == true) {
logger.value.debug("Socket controller handled data, wait for more data")
await new.waitForDataInBackgroundAndNotifyOnMainActor()
} else {
logger.value.debug("Socket controller called with empty data, socked closed")
}
}
}
}
extension FileHandle {
extension SocketController {
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
waitForDataInBackgroundAndNotify()
}
/// A session represents a connection that has been established between the two ends of the socket.
public struct Session: Sendable {
/// Data received by the socket.
public let messages: AsyncStream<Data>
/// The provenance of the process that established the session.
public let provenance: SigningRequestProvenance
/// A FileHandle used to communicate with the socket.
private let fileHandle: FileHandle
/// A continuation for issuing new messages.
private let messagesContinuation: AsyncStream<Data>.Continuation
/// A logger for the session.
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Session")
/// Initializes a new Session.
/// - Parameter fileHandle: The FileHandle used to communicate with the socket.
init(fileHandle: FileHandle) {
self.fileHandle = fileHandle
provenance = SigningRequestTracer().provenance(from: fileHandle)
(messages, messagesContinuation) = AsyncStream.makeStream()
Task { @MainActor [messagesContinuation, logger] in
// Create the sequence before triggering the notification to
// ensure it will not be missed.
let dataAvailableNotifications = NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle)
fileHandle.waitForDataInBackgroundAndNotify()
for await _ in dataAvailableNotifications {
let data = fileHandle.availableData
guard !data.isEmpty else {
logger.debug("Socket controller received empty data, ending continuation.")
messagesContinuation.finish()
try fileHandle.close()
return
}
messagesContinuation.yield(data)
logger.debug("Socket controller yielded data.")
}
}
}
/// Writes new data to the socket.
/// - Parameter data: The data to write.
@MainActor public func write(_ data: Data) throws {
try fileHandle.write(contentsOf: data)
fileHandle.waitForDataInBackgroundAndNotify()
}
/// Closes the socket and cleans up resources.
public func close() throws {
logger.debug("Session closed.")
messagesContinuation.finish()
try fileHandle.close()
}
/// 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 {
convenience init(path: String) {
var addr = sockaddr_un()
let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
unsafe path.withCString { cstring in
let len = unsafe strlen(cstring)
unsafe strncpy(pointer, cstring, len)
return len
}
}
// This doesn't seem to be _strictly_ neccessary with SocketPort.
// but just for good form.
addr.sun_family = sa_family_t(AF_UNIX)
// This mirrors the SUN_LEN macro format.
addr.sun_len = UInt8(MemoryLayout<sockaddr_un>.size - MemoryLayout.size(ofValue: addr.sun_path) + length)
let data = unsafe Data(bytes: &addr, count: MemoryLayout<sockaddr_un>.size)
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
}
}

View File

@@ -1,19 +0,0 @@
#import <Foundation/Foundation.h>
#import <Security/Security.h>
// Forward declarations
// from libproc.h
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
// from SecTask.h
OSStatus SecCodeCreateWithPID(int32_t, SecCSFlags, SecCodeRef *);
//! Project version number for SecretAgentKit.
FOUNDATION_EXPORT double SecretAgentKitVersionNumber;
//! Project version string for SecretAgentKit.
FOUNDATION_EXPORT const unsigned char SecretAgentKitVersionString[];

View File

@@ -1,4 +0,0 @@
module SecretAgentKitHeaders [system] {
header "include/SecretAgentKit.h"
export *
}

View File

@@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores.
### OpenSSH
- ``OpenSSHKeyWriter``
- ``OpenSSHPublicKeyWriter``
- ``OpenSSHReader``
### Signing Process

View File

@@ -3,34 +3,28 @@ import Foundation
/// Type eraser for Secret.
public struct AnySecret: Secret, @unchecked Sendable {
let base: Any
private let hashable: AnyHashable
public let base: any Secret
private let _id: () -> AnyHashable
private let _name: () -> String
private let _algorithm: () -> Algorithm
private let _keySize: () -> Int
private let _requiresAuthentication: () -> Bool
private let _publicKey: () -> Data
private let _attributes: () -> Attributes
private let _eq: (AnySecret) -> Bool
public init<T>(_ secret: T) where T: Secret {
if let secret = secret as? AnySecret {
base = secret.base
hashable = secret.hashable
_id = secret._id
_name = secret._name
_algorithm = secret._algorithm
_keySize = secret._keySize
_requiresAuthentication = secret._requiresAuthentication
_publicKey = secret._publicKey
_attributes = secret._attributes
_eq = secret._eq
} else {
base = secret as Any
self.hashable = secret
base = secret
_id = { secret.id as AnyHashable }
_name = { secret.name }
_algorithm = { secret.algorithm }
_keySize = { secret.keySize }
_requiresAuthentication = { secret.requiresAuthentication }
_publicKey = { secret.publicKey }
_attributes = { secret.attributes }
_eq = { secret == $0.base as? T }
}
}
@@ -42,28 +36,20 @@ public struct AnySecret: Secret, @unchecked Sendable {
_name()
}
public var algorithm: Algorithm {
_algorithm()
}
public var keySize: Int {
_keySize()
}
public var requiresAuthentication: Bool {
_requiresAuthentication()
}
public var publicKey: Data {
_publicKey()
}
public var attributes: Attributes {
_attributes()
}
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
lhs.hashable == rhs.hashable
lhs._eq(rhs)
}
public func hash(into hasher: inout Hasher) {
hashable.hash(into: &hasher)
id.hash(into: &hasher)
}
}

View File

@@ -1,16 +1,14 @@
import Foundation
import Combine
/// Type eraser for SecretStore.
public class AnySecretStore: SecretStore, @unchecked Sendable {
open class AnySecretStore: SecretStore, @unchecked Sendable {
let base: any Sendable
let base: any SecretStore
private let _isAvailable: @MainActor @Sendable () -> Bool
private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String
private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: @Sendable () async -> Void
@@ -22,7 +20,6 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
_id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
_verify = { try await secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) }
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { await secretStore.reloadSecrets() }
@@ -48,10 +45,6 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
try await _sign(data, secret, provenance)
}
public func verify(signature: Data, for data: Data, with secret: AnySecret) async throws -> Bool {
try await _verify(signature, data, secret)
}
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
await _existingPersistedAuthenticationContext(secret)
}
@@ -68,27 +61,34 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
private let _create: @Sendable (String, Bool) async throws -> Void
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
private let _delete: @Sendable (AnySecret) async throws -> Void
private let _update: @Sendable (AnySecret, String) async throws -> Void
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
private let _supportedKeyTypes: @Sendable () -> KeyAvailability
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try await secretStore.create(name: $0, requiresAuthentication: $1) }
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) }
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
_supportedKeyTypes = { secretStore.supportedKeyTypes }
super.init(secretStore)
}
public func create(name: String, requiresAuthentication: Bool) async throws {
try await _create(name, requiresAuthentication)
@discardableResult
public func create(name: String, attributes: Attributes) async throws -> SecretType {
try await _create(name, attributes)
}
public func delete(secret: AnySecret) async throws {
try await _delete(secret)
}
public func update(secret: AnySecret, name: String) async throws {
try await _update(secret, name)
public func update(secret: AnySecret, name: String, attributes: Attributes) async throws {
try await _update(secret, name, attributes)
}
public var supportedKeyTypes: KeyAvailability {
_supportedKeyTypes()
}
}

View File

@@ -36,12 +36,12 @@ public struct KeychainError: Error {
/// A signing-related error.
public struct SigningError: Error {
/// The underlying error reported by the API, if one was returned.
public let error: SecurityError?
public let error: CFError?
/// Initializes a SigningError with an optional SecurityError.
/// - Parameter statusCode: The SecurityError, if one is applicable.
public init(error: SecurityError?) {
self.error = error
self.error = unsafe error?.takeRetainedValue()
}
}
@@ -51,19 +51,17 @@ public extension SecretStore {
/// Returns the appropriate keychian signature algorithm to use for a given secret.
/// - Parameters:
/// - secret: The secret which will be used for signing.
/// - allowRSA: Whether or not RSA key types should be permited.
/// - Returns: The appropriate algorithm.
func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm {
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
return .ecdsaSignatureMessageX962SHA256
case (.ellipticCurve, 384):
return .ecdsaSignatureMessageX962SHA384
case (.rsa, 1024), (.rsa, 2048):
guard allowRSA else { fatalError() }
return .rsaSignatureMessagePKCS1v15SHA512
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
switch secret.keyType {
case .ecdsa256:
.ecdsaSignatureMessageX962SHA256
case .ecdsa384:
.ecdsaSignatureMessageX962SHA384
case .rsa2048:
.rsaSignatureMessagePKCS1v15SHA512
default:
fatalError()
nil
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
extension Data {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
let rawLength = UInt32(count)
var endian = rawLength.bigEndian
return unsafe Data(bytes: &endian, count: MemoryLayout<UInt32>.size) + self
}
}
extension String {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
Data(utf8).lengthAndData
}
}

View File

@@ -1,89 +0,0 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHKeyWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType) -> Data {
lengthAndData(of: curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
lengthAndData(of: curveIdentifier(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
lengthAndData(of: secret.publicKey)
}
/// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType, comment: String? = nil) -> String {
[curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment]
.compactMap { $0 }
.joined(separator: " ")
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
return "SHA256:\(cleaned)"
}
/// Generates an OpenSSH MD5 fingerprint string.
/// - Returns: OpenSSH MD5 fingerprint string.
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
Insecure.MD5.hash(data: data(secret: secret))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
}
}
extension OpenSSHKeyWriter {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Parameter data: The data payload.
/// - Returns: OpenSSH data.
public func lengthAndData(of data: Data) -> Data {
let rawLength = UInt32(data.count)
var endian = rawLength.bigEndian
return Data(bytes: &endian, count: UInt32.bitWidth/8) + data
}
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func curveType(for algorithm: Algorithm, length: Int) -> String {
switch algorithm {
case .ellipticCurve:
return "ecdsa-sha2-nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function, per
// https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key
return "rsa-sha2-512"
}
}
/// The OpenSSH identifier for an algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
private func curveIdentifier(for algorithm: Algorithm, length: Int) -> String {
switch algorithm {
case .ellipticCurve:
return "nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function
return "rsa-sha2-512"
}
}
}

View File

@@ -0,0 +1,114 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of the public key sof secrets.
public struct OpenSSHPublicKeyWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
openSSHIdentifier(for: secret.keyType).lengthAndData +
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData
case .mldsa:
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
openSSHIdentifier(for: secret.keyType).lengthAndData +
rsaPublicKeyBlob(secret: secret)
}
}
/// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), comment(secret: secret)]
.compactMap { $0 }
.joined(separator: " ")
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
return "SHA256:\(cleaned)"
}
/// Generates an OpenSSH MD5 fingerprint string.
/// - Returns: OpenSSH MD5 fingerprint string.
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
Insecure.MD5.hash(data: data(secret: secret))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
}
public func comment<SecretType: Secret>(secret: SecretType) -> String {
if let comment = secret.publicKeyAttribution {
return comment
} else {
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
return "\(dashedKeyName)@\(dashedHostName)"
}
}
}
extension OpenSSHPublicKeyWriter {
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func openSSHIdentifier(for keyType: KeyType) -> String {
switch keyType {
case .ecdsa256:
"ecdsa-sha2-nistp256"
case .ecdsa384:
"ecdsa-sha2-nistp384"
case .mldsa65:
"ssh-mldsa-65"
case .mldsa87:
"ssh-mldsa-87"
case .rsa2048:
"ssh-rsa"
default:
"unknown"
}
}
}
extension OpenSSHPublicKeyWriter {
func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
// Keychain stores it as a thin ASN.1 wrapper with this format:
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
// we only support one key type, and the keychain always gives it in a specific format.
guard secret.keyType == .rsa2048 else { fatalError() }
let length = secret.keyType.size/8
let data = secret.publicKey
let n = Data(data[8..<(9+length)])
let e = Data(data[(2+9+length)...])
return e.lengthAndData + n.lengthAndData
}
}

View File

@@ -1,30 +0,0 @@
import Foundation
/// Reads OpenSSH protocol data.
public final class OpenSSHReader {
var remaining: Data
/// Initialize the reader with an OpenSSH data payload.
/// - Parameter data: The data to read.
public init(data: Data) {
remaining = Data(data)
}
/// Reads the next chunk of data from the playload.
/// - Returns: The next chunk of data.
public func readNextChunk() -> Data {
let lengthRange = 0..<(UInt32.bitWidth/8)
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in
return pointer.load(as: UInt32.self)
}
let length = Int(littleEndianLength.bigEndian)
let dataRange = 0..<length
let ret = Data(remaining[dataRange])
remaining.removeSubrange(dataRange)
return ret
}
}

View File

@@ -0,0 +1,75 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHSignatureWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType, signature: Data) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
ecdsaSignature(signature, keyType: secret.keyType)
case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms
mldsaSignature(signature, keyType: secret.keyType)
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
rsaSignature(signature)
}
}
}
extension OpenSSHSignatureWriter {
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
let rawLength = rawRepresentation.count/2
// Check if we need to pad with 0x00 to prevent certain
// ssh servers from thinking r or s is negative
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
var r = Data(rawRepresentation[0..<rawLength])
if paddingRange ~= r.first! {
r.insert(0x00, at: 0)
}
var s = Data(rawRepresentation[rawLength...])
if paddingRange ~= s.first! {
s.insert(0x00, at: 0)
}
var signatureChunk = Data()
signatureChunk.append(r.lengthAndData)
signatureChunk.append(s.lengthAndData)
var mutSignedData = Data()
var sub = Data()
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
sub.append(signatureChunk.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
func mldsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
var mutSignedData = Data()
var sub = Data()
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
sub.append(rawRepresentation.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
func rsaSignature(_ rawRepresentation: Data) -> Data {
var mutSignedData = Data()
var sub = Data()
sub.append("rsa-sha2-512".lengthAndData)
sub.append(rawRepresentation.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
}

View File

@@ -5,12 +5,12 @@ import OSLog
public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: String
private let keyWriter = OpenSSHKeyWriter()
private let directory: URL
private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) {
directory = homeDirectory.appending("/PublicKeys")
public init(homeDirectory: URL) {
directory = homeDirectory.appending(component: "PublicKeys")
}
/// Writes out the keys specified to disk.
@@ -19,20 +19,22 @@ public final class PublicKeyFileStoreController: Sendable {
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)) ?? []
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
let validPaths = Set(secrets.map { publicKeyPath(for: $0) })
.union(Set(secrets.map { sshCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
let fullPathContents = contentsOfDirectory.map { directory.appending(path: $0).path() }
let untracked = Set(fullPathContents)
.subtracting(validPaths)
for path in untracked {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
// string instead of fileURLWithPath since we're already using fileURL format.
try? FileManager.default.removeItem(at: URL(string: path)!)
}
}
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
let path = publicKeyPath(for: secret)
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
}
logger.log("Finished writing public keys")
@@ -44,14 +46,14 @@ public final class PublicKeyFileStoreController: Sendable {
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex).pub")
return directory.appending(component: "\(minimalHex).pub").path()
}
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
public var hasAnyCertificates: Bool {
do {
return try FileManager.default
.contentsOfDirectory(atPath: directory)
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
.isEmpty == false
} catch {
@@ -65,7 +67,7 @@ public final class PublicKeyFileStoreController: Sendable {
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex)-cert.pub")
return directory.appending(component: "\(minimalHex)-cert.pub").path()
}
}

View File

@@ -20,7 +20,7 @@ import Observation
/// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store)
let modifiable = AnySecretStoreModifiable(store)
if modifiableStore == nil {
modifiableStore = modifiable
}
@@ -36,4 +36,12 @@ import Observation
stores.flatMap(\.secrets)
}
public var allSecretsWithStores: [(AnySecret, AnySecretStore)] {
stores.flatMap { store in
store.secrets.map { secret in
(secret, store)
}
}
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
public struct Attributes: Sendable, Codable, Hashable {
/// The type of key involved.
public let keyType: KeyType
/// The authentication requirements for the key. This is simply a description of the option recorded at creation modifying it doers not modify the key's authentication requirements.
public let authentication: AuthenticationRequirement
/// The string appended to the end of the SSH Public Key.
/// If nil, a default value will be used.
public var publicKeyAttribution: String?
public init(
keyType: KeyType,
authentication: AuthenticationRequirement,
publicKeyAttribution: String? = nil
) {
self.keyType = keyType
self.authentication = authentication
self.publicKeyAttribution = publicKeyAttribution
}
public struct UnsupportedOptionError: Error {
package init() {}
}
}
/// The option specified
public enum AuthenticationRequirement: String, Hashable, Sendable, Codable, Identifiable {
/// Authentication is not required for usage.
case notRequired
/// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry..
case presenceRequired
/// ONLY the current set of biometric data, as matching at time of creation, is accepted.
/// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password.
case biometryCurrent
/// The authentication requirement was not recorded at creation, and is unknown.
case unknown
/// Whether or not the key is known to require authentication.
public var required: Bool {
self == .presenceRequired || self == .biometryCurrent
}
public var id: AuthenticationRequirement {
self
}
}

View File

@@ -5,43 +5,81 @@ public protocol Secret: Identifiable, Hashable, Sendable {
/// A user-facing string identifying the Secret.
var name: String { get }
/// The algorithm this secret uses.
var algorithm: Algorithm { get }
/// The key size for the secret.
var keySize: Int { get }
/// Whether the secret requires authentication before use.
var requiresAuthentication: Bool { get }
/// The public key data for the secret.
var publicKey: Data { get }
/// The attributes of the key.
var attributes: Attributes { get }
}
/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported.
public enum Algorithm: Hashable, Sendable {
public extension Secret {
case ellipticCurve
case rsa
/// The algorithm and key size this secret uses.
var keyType: KeyType {
attributes.keyType
}
/// Whether the secret requires authentication before use.
var authenticationRequirement: AuthenticationRequirement {
attributes.authentication
}
/// An attribution string to apply to the generated public key.
var publicKeyAttribution: String? {
attributes.publicKeyAttribution
}
}
/// The type of algorithm the Secret uses.
public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
public static let ecdsa256 = KeyType(algorithm: .ecdsa, size: 256)
public static let ecdsa384 = KeyType(algorithm: .ecdsa, size: 384)
public static let mldsa65 = KeyType(algorithm: .mldsa, size: 65)
public static let mldsa87 = KeyType(algorithm: .mldsa, size: 87)
public static let rsa2048 = KeyType(algorithm: .rsa, size: 2048)
public enum Algorithm: Hashable, Sendable, Codable {
case ecdsa
case mldsa
case rsa
}
public var algorithm: Algorithm
public var size: Int
public init(algorithm: Algorithm, size: Int) {
self.algorithm = algorithm
self.size = size
}
/// Initializes the Algorithm with a secAttr representation of an algorithm.
/// - Parameter secAttr: the secAttr, represented as an NSNumber.
public init(secAttr: NSNumber) {
public init?(secAttr: NSNumber, size: Int) {
let secAttrString = secAttr.stringValue as CFString
switch secAttrString {
case kSecAttrKeyTypeEC:
self = .ellipticCurve
algorithm = .ecdsa
case kSecAttrKeyTypeRSA:
self = .rsa
algorithm = .rsa
default:
fatalError()
return nil
}
self.size = size
}
public var secAttrKeyType: CFString? {
switch algorithm {
case .ecdsa:
kSecAttrKeyTypeEC
case .rsa:
kSecAttrKeyTypeRSA
case .mldsa:
nil
}
}
public var secAttrKeyType: CFString {
switch self {
case .ellipticCurve:
return kSecAttrKeyTypeEC
case .rsa:
return kSecAttrKeyTypeRSA
}
public var description: String {
"\(algorithm)-\(size)"
}
}

View File

@@ -1,8 +1,7 @@
import Foundation
import Combine
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore: Identifiable, Sendable {
public protocol SecretStore<SecretType>: Identifiable, Sendable {
associatedtype SecretType: Secret
@@ -23,14 +22,6 @@ public protocol SecretStore: Identifiable, Sendable {
/// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
/// Verifies that a signature is valid over a specified payload.
/// - Parameters:
/// - signature: The signature over the data.
/// - data: The data to verify the signature of.
/// - secret: The secret whose signature to verify.
/// - Returns: Whether the signature was verified.
func verify(signature: Data, for data: Data, with secret: SecretType) async throws -> Bool
/// Checks to see if there is currently a valid persisted authentication for a given secret.
/// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
@@ -50,13 +41,14 @@ public protocol SecretStore: Identifiable, Sendable {
}
/// A SecretStore that the Secretive admin app can modify.
public protocol SecretStoreModifiable: SecretStore {
public protocol SecretStoreModifiable<SecretType>: SecretStore {
/// Creates a new ``Secret`` in the store.
/// - Parameters:
/// - name: The user-facing name for the ``Secret``.
/// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret.
func create(name: String, requiresAuthentication: Bool) async throws
/// - attributes: A struct describing the options for creating the key.'
@discardableResult
func create(name: String, attributes: Attributes) async throws -> SecretType
/// Deletes a Secret in the store.
/// - Parameters:
@@ -67,10 +59,40 @@ public protocol SecretStoreModifiable: SecretStore {
/// - Parameters:
/// - secret: The ``Secret`` to update.
/// - name: The new name for the Secret.
func update(secret: SecretType, name: String) async throws
/// - attributes: The new attributes for the secret.
func update(secret: SecretType, name: String, attributes: Attributes) async throws
var supportedKeyTypes: KeyAvailability { get }
}
public struct KeyAvailability: Sendable {
public let available: [KeyType]
public let unavailable: [UnavailableKeyType]
public init(available: [KeyType], unavailable: [UnavailableKeyType]) {
self.available = available
self.unavailable = unavailable
}
public struct UnavailableKeyType: Sendable {
public let keyType: KeyType
public let reason: Reason
public init(keyType: KeyType, reason: Reason) {
self.keyType = keyType
self.reason = reason
}
public enum Reason: Sendable {
case macOSUpdateRequired
}
}
}
extension NSNotification.Name {
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)

View File

@@ -0,0 +1,103 @@
import Foundation
import Security
import CryptoTokenKit
import CryptoKit
import SecretKit
import os
extension SecureEnclave {
public struct CryptoKitMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CryptoKitMigrator")
public init() {
}
/// Keys prior to 3.0 were created and stored directly using the keychain as kSecClassKey items. CryptoKit operates a little differently, in that it creates a key on your behalf which you can persist using an opaque data blob to a generic keychain item. Keychain created keys _also_ use this blob under the hood, but it's stored in the "toid" attribute. This migrates the old keys from kSecClassKey to generic items, copying the "toid" to be the main stored data. If the key is migrated successfully, the old key's identifier is renamed to indicate it's been migrated.
/// - Note: Migration is non-destructive users can still see and use their keys in older versions of Secretive.
@MainActor public func migrate(to store: Store) throws {
let privateAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: Constants.oldKeyType,
kSecAttrApplicationTag: SecureEnclave.Store.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
unsafe SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
var migratedAny = false
for key in privateTyped {
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = key[kSecAttrApplicationLabel] as! Data
guard !id.contains(Constants.migrationMagicNumber) else {
logger.log("Skipping \(name), already migrated.")
continue
}
let ref = key[kSecValueRef] as! SecKey
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
let tokenObjectID = unsafe attributes[Constants.tokenObjectID] as! Data
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
// Best guess.
let auth: AuthenticationRequirement = String(describing: accessControl)
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
do {
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
logger.log("Skipping \(name), public key already present. Marking as migrated.")
markMigrated(secret: secret, oldID: id)
continue
}
logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).")
markMigrated(secret: secret, oldID: id)
migratedAny = true
} catch {
logger.error("Failed to migrate \(name): \(error.localizedDescription).")
}
}
if migratedAny {
store.reloadSecrets()
}
}
public func markMigrated(secret: Secret, oldID: Data) {
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: oldID
])
let newID = oldID + Constants.migrationMagicNumber
let updatedAttributes = KeychainDictionary([
kSecAttrApplicationLabel: newID as CFData
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
logger.warning("Failed to mark \(secret.name) as migrated: \(status).")
}
}
}
}
extension SecureEnclave.CryptoKitMigrator {
enum Constants {
public static let oldKeyType = kSecAttrKeyTypeECSECPrimeRandom as String
public static let migrationMagicNumber = Data("_cryptokit_1".utf8)
// https://github.com/apple-opensource/Security/blob/5e9101b3bd1fb096bae4f40e79d50426ba1db8e9/OSX/sec/Security/SecItemConstants.c#L111
public static nonisolated(unsafe) let tokenObjectID = "toid" as CFString
}
}

View File

@@ -21,7 +21,7 @@ extension SecureEnclave {
/// - duration: The duration of the authorization context, in seconds.
init(secret: Secret, context: LAContext, duration: TimeInterval) {
self.secret = secret
self.context = context
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)
}
@@ -50,17 +50,15 @@ extension SecureEnclave {
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
if let durationString = formatter.string(from: duration) {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
} else {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
}
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)

View File

@@ -1,5 +1,4 @@
import Foundation
import Combine
import SecretKit
extension SecureEnclave {
@@ -7,12 +6,26 @@ extension SecureEnclave {
/// An implementation of Secret backed by the Secure Enclave.
public struct Secret: SecretKit.Secret {
public let id: Data
public let id: String
public let name: String
public let algorithm = Algorithm.ellipticCurve
public let keySize = 256
public let requiresAuthentication: Bool
public let publicKey: Data
public let attributes: Attributes
init(
id: String,
name: String,
publicKey: Data,
attributes: Attributes
) {
self.id = id
self.name = name
self.publicKey = publicKey
self.attributes = attributes
}
public static func ==(lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
}

View File

@@ -4,10 +4,11 @@ import Security
import CryptoKit
import LocalAuthentication
import SecretKit
import os
extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave.
/// An implementation of Store backed by the Secure Enclave using CryptoKit API.
@Observable public final class Store: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
@@ -15,160 +16,76 @@ extension SecureEnclave {
CryptoKit.SecureEnclave.isAvailable
}
public let id = UUID()
public let name = String(localized: "secure_enclave")
public let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
/// Initializes a Store.
@MainActor public init() {
loadSecrets()
Task {
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false)
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
guard Constants.notificationToken != (note.object as? String) else {
// Don't reload if we're the ones triggering this by reloading.
continue
}
reloadSecrets()
}
}
}
// MARK: Public API
public func create(name: String, requiresAuthentication: Bool) async throws {
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags
if requiresAuthentication {
flags = [.privateKeyUsage, .userPresence]
} else {
flags = .privateKeyUsage
}
let access =
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError) as Any
if let error = accessError {
throw error.takeRetainedValue() as Error
}
let attributes = KeychainDictionary([
kSecAttrLabel: name,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecPrivateKeyAttrs: [
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
])
var createKeyError: SecurityError?
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
if let error = createKeyError {
throw error.takeRetainedValue() as Error
}
guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else {
throw KeychainError(statusCode: nil)
}
try savePublicKey(publicKey, name: name)
await reloadSecretsInternal()
}
public func delete(secret: Secret) async throws {
let deleteAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecretsInternal()
}
public func update(secret: Secret, name: String) async throws {
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecretsInternal()
}
// MARK: - Public API
// MARK: SecretStore
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
let context: LAContext
var context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context
context = unsafe existing.context
} else {
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: secret.id,
kSecReturnAttributes: true,
kSecReturnData: true,
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
let status = unsafe SecItemCopyMatching(queryAttributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
guard let untypedSafe = untyped as? [CFString: Any] else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let attributesData = untypedSafe[kSecAttrGeneric] as? Data,
let keyData = untypedSafe[kSecValueData] as? Data else {
throw MissingAttributesError()
}
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
switch attributes.keyType {
case .ecdsa256:
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
return try key.signature(for: data).rawRepresentation
case .mldsa65:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
return try key.signature(for: data)
case .mldsa87:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
return try key.signature(for: data)
default:
throw UnsupportedAlgorithmError()
}
return signature as Data
}
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_verify_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var verifyError: SecurityError?
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
}
}
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
@@ -179,114 +96,202 @@ extension SecureEnclave {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
public func reloadSecrets() async {
await reloadSecretsInternal(notifyAgent: false)
@MainActor public func reloadSecrets() {
let before = secrets
secrets.removeAll()
loadSecrets()
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
}
}
// MARK: SecretStoreModifiable
public func create(name: String, attributes: Attributes) async throws -> Secret {
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
case .notRequired:
[.privateKeyUsage]
case .presenceRequired:
[.userPresence, .privateKeyUsage]
case .biometryCurrent:
[.biometryCurrentSet, .privateKeyUsage]
case .unknown:
fatalError()
}
let access =
unsafe SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError)
if let error = unsafe accessError {
throw unsafe error.takeRetainedValue() as Error
}
let dataRep: Data
let publicKey: Data
switch attributes.keyType {
case .ecdsa256:
let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
dataRep = created.dataRepresentation
publicKey = created.publicKey.x963Representation
case .mldsa65:
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!)
dataRep = created.dataRepresentation
publicKey = created.publicKey.rawRepresentation
case .mldsa87:
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!)
dataRep = created.dataRepresentation
publicKey = created.publicKey.rawRepresentation
default:
throw Attributes.UnsupportedOptionError()
}
let id = try saveKey(dataRep, name: name, attributes: attributes)
await reloadSecrets()
return Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
}
public func delete(secret: Secret) async throws {
let deleteAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: secret.id,
])
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecrets()
}
public func update(secret: Secret, name: String, attributes: Attributes) async throws {
let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrAccount: secret.id,
])
let attributes = try JSONEncoder().encode(attributes)
let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name,
kSecAttrGeneric: attributes,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecrets()
}
public let supportedKeyTypes: KeyAvailability = {
let macOS26Keys: [KeyType] = [.mldsa65, .mldsa87]
let isAtLeastMacOS26 = if #available(macOS 26, *) {
true
} else {
false
}
return KeyAvailability(
available: [
.ecdsa256,
] + (isAtLeastMacOS26 ? macOS26Keys : []),
unavailable: (isAtLeastMacOS26 ? [] : macOS26Keys).map {
KeyAvailability.UnavailableKeyType(keyType: $0, reason: .macOSUpdateRequired)
}
)
}()
}
}
extension SecureEnclave.Store {
/// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
let before = secrets
secrets.removeAll()
loadSecrets()
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
}
}
}
/// Loads all secrets from the store.
@MainActor private func loadSecrets() {
let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecReturnRef: true,
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var publicUntyped: CFTypeRef?
SecItemCopyMatching(publicAttributes, &publicUntyped)
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
let privateAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in
let id = next[kSecAttrApplicationLabel] as! Data
partialResult[id] = next
}
let authNotRequiredAccessControl: SecAccessControl =
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage],
nil)!
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
let privateKey = privateMapped[id]
let requiresAuth: Bool
if let authRequirements = privateKey?[kSecAttrAccessControl] {
// Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality.
requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl
} else {
requiresAuth = false
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
do {
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
guard let attributesData = $0[kSecAttrGeneric] as? Data,
let id = $0[kSecAttrAccount] as? String else {
throw MissingAttributesError()
}
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
let keyData = $0[kSecValueData] as! Data
let publicKey: Data
switch attributes.keyType {
case .ecdsa256:
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
publicKey = key.publicKey.x963Representation
case .mldsa65:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
publicKey = key.publicKey.rawRepresentation
case .mldsa87:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
publicKey = key.publicKey.rawRepresentation
default:
throw UnsupportedAlgorithmError()
}
return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
} catch {
return nil
}
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
}
/// Saves a public key.
/// - Parameters:
/// - publicKey: The public key to save.
/// - key: The data representation key to save.
/// - name: A user-facing name for the key.
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecValueRef: publicKey,
kSecAttrIsPermanent: true,
kSecReturnData: true,
kSecAttrLabel: name
])
let status = SecItemAdd(attributes, nil)
/// - attributes: Attributes of the key.
/// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate.
@discardableResult
func saveKey(_ key: Data, name: String, attributes: Attributes) throws -> String {
let attributes = try JSONEncoder().encode(attributes)
let id = UUID().uuidString
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAttrAccount: id,
kSecValueData: key,
kSecAttrLabel: name,
kSecAttrGeneric: attributes
])
let status = SecItemAdd(keychainAttributes, nil)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
return id
}
}
extension SecureEnclave {
extension SecureEnclave.Store {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String
static let unauthenticatedThreshold: TimeInterval = 0.05
static let notificationToken = UUID().uuidString
}
struct UnsupportedAlgorithmError: Error {}
struct MissingAttributesError: Error {}
}

View File

@@ -1,5 +1,4 @@
import Foundation
import Combine
import SecretKit
extension SmartCard {
@@ -9,10 +8,8 @@ extension SmartCard {
public let id: Data
public let name: String
public let algorithm: Algorithm
public let keySize: Int
public let requiresAuthentication: Bool = false
public let publicKey: Data
public var attributes: Attributes
}

View File

@@ -1,7 +1,7 @@
import Foundation
import Observation
import Security
import CryptoTokenKit
@unsafe @preconcurrency import CryptoTokenKit
import LocalAuthentication
import SecretKit
@@ -9,7 +9,7 @@ extension SmartCard {
@MainActor @Observable fileprivate final class State {
var isAvailable = false
var name = String(localized: "smart_card")
var name = String(localized: .smartCard)
var secrets: [Secret] = []
let watcher = TKTokenWatcher()
var tokenID: String? = nil
@@ -23,6 +23,9 @@ extension SmartCard {
public var isAvailable: Bool {
state.isAvailable
}
@MainActor public var smartcardTokenID: String? {
state.tokenID
}
public let id = UUID()
@MainActor public var name: String {
@@ -34,17 +37,18 @@ extension SmartCard {
/// Initializes a Store.
public init() {
Task { @MainActor in
if let tokenID = state.tokenID {
state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
Task {
await MainActor.run {
if let tokenID = smartcardTokenID {
state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
}
loadSecrets()
}
loadSecrets()
state.watcher.setInsertionHandler { id in
// Setting insertion handler will cause it to be called immediately.
// Make a thread jump so we don't hit a recursive lock attempt.
// Doing this inside a regular mainactor handler casues thread assertions in CryptoTokenKit to blow up when the handler executes.
await state.watcher.setInsertionHandler { id in
Task {
self.smartcardInserted(for: id)
await self.smartcardInserted(for: id)
}
}
}
@@ -52,19 +56,11 @@ extension SmartCard {
// MARK: Public API
public func create(name: String) throws {
fatalError("Keys must be created on the smart card.")
}
public func delete(secret: Secret) throws {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@@ -74,7 +70,7 @@ extension SmartCard {
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
let status = unsafe SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
@@ -83,35 +79,13 @@ extension SmartCard {
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else {
throw SigningError(error: signError)
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
guard let signature = unsafe SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
throw unsafe SigningError(error: signError)
}
return signature as Data
}
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
}
}
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
nil
}
@@ -143,12 +117,13 @@ extension SmartCard.Store {
/// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was inserted.
@MainActor private func smartcardInserted(for tokenID: String? = nil) {
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
guard state.tokenID == nil else { return }
guard !string.contains("setoken") else { return }
state.tokenID = string
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
state.tokenID = string
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
guard state.tokenID == nil else { return }
guard !string.contains("setoken") else { return }
state.tokenID = string
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
state.tokenID = string
reloadSecretsInternal()
}
/// Resets the token ID and reloads secrets.
@@ -162,7 +137,7 @@ extension SmartCard.Store {
@MainActor private func loadSecrets() {
guard let tokenID = state.tokenID else { return }
let fallbackName = String(localized: "smart_card")
let fallbackName = String(localized: .smartCard)
if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
state.name = driverName
} else {
@@ -177,106 +152,27 @@ extension SmartCard.Store {
kSecReturnAttributes: true
])
var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped)
unsafe SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped = typed.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
let wrapped: [SecretType] = typed.compactMap {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber
let keySize = $0[kSecAttrKeySizeInBits] as! Int
let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
guard signatureAlgorithm(for: secret) != nil else { return nil }
return secret
}
state.secrets.append(contentsOf: wrapped)
}
}
// MARK: Smart Card specific encryption/decryption/verification
extension SmartCard.Store {
/// Encrypts a payload with a specified key.
/// - Parameters:
/// - data: The payload to encrypt.
/// - secret: The secret to encrypt with.
/// - Returns: The encrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecUseAuthenticationContext: context
])
var encryptError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &encryptError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
guard let signature = SecKeyCreateEncryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
throw SigningError(error: encryptError)
}
return signature as Data
}
/// Decrypts a payload with a specified key.
/// - Parameters:
/// - data: The payload to decrypt.
/// - secret: The secret to decrypt with.
/// - Returns: The decrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func decrypt(data: Data, with secret: SecretType) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var encryptError: SecurityError?
guard let signature = SecKeyCreateDecryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
throw SigningError(error: encryptError)
}
return signature as Data
}
private func encryptionAlgorithm(for secret: SecretType) -> SecKeyAlgorithm {
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM
case (.ellipticCurve, 384):
return .eciesEncryptionCofactorVariableIVX963SHA384AESGCM
case (.rsa, 1024), (.rsa, 2048):
return .rsaEncryptionOAEPSHA512AESGCM
default:
fatalError()
}
}
}
extension TKTokenWatcher {
/// All available tokens, excluding the Secure Enclave.
@@ -285,3 +181,9 @@ extension TKTokenWatcher {
}
}
extension SmartCard {
public struct UnsupportKeyType: Error {}
}

View File

@@ -0,0 +1,14 @@
import Foundation
@objc protocol _XPCProtocol: Sendable {
func process(_ data: Data, with reply: @Sendable @escaping (Data?, Error?) -> Void)
}
public protocol XPCProtocol<Input, Output>: Sendable {
associatedtype Input: Codable
associatedtype Output: Codable
func process(_ data: Input) async throws -> Output
}

View File

@@ -0,0 +1,72 @@
import Foundation
public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
private let exportedObject: ErasedXPCProtocol
public init<XPCProtocolType: XPCProtocol>(exportedObject: XPCProtocolType) {
self.exportedObject = ErasedXPCProtocol(exportedObject)
}
public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
let exportedObject = exportedObject
newConnection.exportedObject = exportedObject
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
newConnection.resume()
return true
}
}
@objc private final class ErasedXPCProtocol: NSObject, _XPCProtocol {
let _process: @Sendable (Data, @Sendable @escaping (Data?, (any Error)?) -> Void) -> Void
public init<XPCProtocolType: XPCProtocol>(_ exportedObject: XPCProtocolType) {
_process = { data, reply in
Task { [reply] in
do {
let decoded = try JSONDecoder().decode(XPCProtocolType.Input.self, from: data)
let result = try await exportedObject.process(decoded)
let encoded = try JSONEncoder().encode(result)
reply(encoded, nil)
} catch {
if let error = error as? Codable & Error {
reply(nil, NSError(error))
} else {
// Sending cast directly tries to serialize it and crashes XPCEncoder.
let cast = error as NSError
reply(nil, NSError(domain: cast.domain, code: cast.code, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]))
}
}
}
}
}
func process(_ data: Data, with reply: @Sendable @escaping (Data?, (any Error)?) -> Void) {
_process(data, reply)
}
}
extension NSError {
private enum Constants {
static let domain = "com.maxgoedjen.secretive.xpcwrappers"
static let code = -1
static let dataKey = "underlying"
}
@nonobjc convenience init<ErrorType: Codable & Error>(_ error: ErrorType) {
let encoded = try? JSONEncoder().encode(error)
self.init(domain: Constants.domain, code: Constants.code, userInfo: [Constants.dataKey: encoded as Any])
}
@nonobjc public func underlying<ErrorType: Codable & Error>(as errorType: ErrorType.Type) -> ErrorType? {
guard domain == Constants.domain && code == Constants.code, let data = userInfo[Constants.dataKey] as? Data else { return nil }
return try? JSONDecoder().decode(ErrorType.self, from: data)
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error & Codable>: ~Copyable {
private let connection: NSXPCConnection
private let proxy: _XPCProtocol
public init(serviceName: String, warmup: Bool = false) async throws {
let connection = NSXPCConnection(serviceName: serviceName)
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
connection.resume()
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
self.connection = connection
self.proxy = proxy
if warmup {
_ = try? await send()
}
}
public func send(_ message: some Encodable = Data()) async throws -> ResponseType {
let encoded = try JSONEncoder().encode(message)
return try await withCheckedThrowingContinuation { continuation in
proxy.process(encoded) { data, error in
do {
if let error {
throw error
}
guard let data else {
throw NoDataError()
}
let decoded = try JSONDecoder().decode(ResponseType.self, from: data)
continuation.resume(returning: decoded)
} catch {
if let typed = (error as NSError).underlying(as: ErrorType.self) {
continuation.resume(throwing: typed)
} else {
continuation.resume(throwing: error)
}
}
}
}
}
public func complete() {
connection.invalidate()
}
public struct NoDataError: Error {}
}

View File

@@ -6,51 +6,54 @@ import CryptoKit
@Suite struct AgentTests {
let stubWriter = StubFileHandleWriter()
// MARK: Identity Listing
@Test func emptyStores() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
@Test func emptyStores() async throws {
let agent = Agent(storeList: SecretStoreList())
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestIdentitiesEmpty)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesEmpty)
}
@Test func identitiesList() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
@Test func identitiesList() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test)
let actual = OpenSSHReader(data: response)
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
print(actual, expected)
#expect(response == Constants.Responses.requestIdentitiesMultiple)
}
// MARK: Signatures
@Test func noMatchingIdentities() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
@Test func noMatchingIdentities() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
@Test func signature() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
_ = requestReader.readNextChunk()
let dataToSign = requestReader.readNextChunk()
@Test func ecdsaSignature() async throws {
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
guard case SSHAgent.Request.signRequest(let context) = request else { return }
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
let outer = OpenSSHReader(data: stubWriter.data[5...])
let payload = outer.readNextChunk()
let inner = OpenSSHReader(data: payload)
_ = inner.readNextChunk()
let signedData = inner.readNextChunk()
let rsData = OpenSSHReader(data: signedData)
var r = rsData.readNextChunk()
var s = rsData.readNextChunk()
let response = await agent.handle(request: request, provenance: .test)
let responseReader = OpenSSHReader(data: response)
let length = try responseReader.readNextBytes(as: UInt32.self).bigEndian
let type = try responseReader.readNextBytes(as: UInt8.self).bigEndian
#expect(length == response.count - MemoryLayout<UInt32>.size)
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
let outer = OpenSSHReader(data: responseReader.remaining)
let inner = try outer.readNextChunkAsSubReader()
_ = try inner.readNextChunk()
let rsData = try inner.readNextChunkAsSubReader()
var r = try rsData.readNextChunk()
var s = try rsData.readNextChunk()
// This is fine IRL, but it freaks out CryptoKit
if r[0] == 0 {
r.removeFirst()
@@ -60,35 +63,25 @@ import CryptoKit
}
var rs = r
rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
let store = await list.stores.first!
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidWrongKey = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa384Secret))
#expect(referenceValid)
#expect(derVerifies)
#expect(invalidRandomSignature == false)
#expect(invalidRandomData == false)
#expect(invalidWrongKey == false)
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
// Correct signature
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
.isValidSignature(signature, for: context.dataToSign))
}
// MARK: Witness protocol
@Test func witnessObjectionStopsRequest() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
@Test func witnessObjectionStopsRequest() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let witness = StubWitness(speakNow: { _,_ in
return true
}, witness: { _, _ in })
let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
@Test func witnessSignature() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
@Test func witnessSignature() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
nonisolated(unsafe) var witnessed = false
let witness = StubWitness(speakNow: { _, trace in
@@ -97,12 +90,12 @@ import CryptoKit
witnessed = true
})
let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test)
#expect(witnessed)
}
@Test func requestTracing() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
@Test func requestTracing() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
@@ -113,36 +106,40 @@ import CryptoKit
witnessTrace = trace
})
let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test)
#expect(witnessTrace == speakNowTrace)
#expect(witnessTrace?.origin.displayName == "Finder")
#expect(witnessTrace?.origin.validSignature == true)
#expect(witnessTrace?.origin.parentPID == 1)
#expect(witnessTrace == .test)
}
// MARK: Exception Handling
@Test func signatureException() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
@Test func signatureException() async throws {
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = await list.stores.first?.base as! Stub.Store
store.shouldThrow = true
let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
let response = await agent.handle(request: request, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
// MARK: Unsupported
@Test func unhandledAdd() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity)
@Test func unhandledAdd() async throws {
let agent = Agent(storeList: SecretStoreList())
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
let response = await agent.handle(request: .addIdentity, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
}
}
extension SigningRequestProvenance {
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
}
extension AgentTests {
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
@@ -157,14 +154,13 @@ extension AgentTests {
enum Requests {
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
static let addIdentity = Data(base64Encoded: "AAAAARE=")!
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
}
enum Responses {
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")!
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")!
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
}

View File

@@ -1,18 +1,18 @@
import Foundation
import Testing
@testable import SecretKit
@testable import SecretAgentKit
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHReaderTests {
@Test func signatureRequest() {
@Test func signatureRequest() throws {
let reader = OpenSSHReader(data: Constants.signatureRequest)
let hash = reader.readNextChunk()
let hash = try reader.readNextChunk()
#expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
let dataToSign = reader.readNextChunk()
let dataToSign = try reader.readNextChunk()
#expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
let empty = reader.readNextChunk()
let empty = try reader.readNextChunk()
#expect(empty.isEmpty)
}

View File

@@ -1,14 +0,0 @@
import SecretAgentKit
import AppKit
struct StubFileHandleReader: FileHandleReader {
let availableData: Data
var fileDescriptor: Int32 {
NSWorkspace.shared.runningApplications.filter({ $0.localizedName == "Finder" }).first!.processIdentifier
}
var pidOfConnectedProcess: Int32 {
fileDescriptor
}
}

View File

@@ -1,12 +0,0 @@
import Foundation
import SecretAgentKit
class StubFileHandleWriter: FileHandleWriter, @unchecked Sendable {
var data = Data()
func write(_ data: Data) {
self.data.append(data)
}
}

View File

@@ -45,43 +45,15 @@ extension Stub {
let privateData = (privateAttributes[kSecValueData] as! Data)
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
print(secret)
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))")
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPrivate
])
, nil)!
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
}
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: Unmanaged<CFError>?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError)
if let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
}
return verified
let privateKey = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey)
return try privateKey.signature(for: data).rawRepresentation
}
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
@@ -102,24 +74,22 @@ extension Stub {
struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
let id = UUID().uuidString.data(using: .utf8)!
let id = Data(UUID().uuidString.utf8)
let name = UUID().uuidString
let algorithm = Algorithm.ellipticCurve
let keySize: Int
let attributes: Attributes
let publicKey: Data
let requiresAuthentication = false
let privateKey: Data
init(keySize: Int, publicKey: Data, privateKey: Data) {
self.keySize = keySize
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired, publicKeyAttribution: "ecdsa-\(keySize)@example.com")
self.publicKey = publicKey
self.privateKey = privateKey
}
var debugDescription: String {
"""
Key Size \(keySize)
Key Size \(attributes.keyType.size)
Private: \(privateKey.base64EncodedString())
Public: \(publicKey.base64EncodedString())
"""

View File

@@ -4,15 +4,16 @@ import Testing
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct AnySecretTests {
@Test func eraser() {
let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!)
let data = Data(UUID().uuidString.utf8)
let secret = SmartCard.Secret(id: data, name: "Name", publicKey: data, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired))
let erased = AnySecret(secret)
#expect(erased.id == secret.id as AnyHashable)
#expect(erased.name == secret.name)
#expect(erased.algorithm == secret.algorithm)
#expect(erased.keySize == secret.keySize)
#expect(erased.keyType == secret.keyType)
#expect(erased.publicKey == secret.publicKey)
}

View File

@@ -4,9 +4,9 @@ import Testing
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHWriterTests {
@Suite struct OpenSSHPublicKeyWriterTests {
let writer = OpenSSHKeyWriter()
let writer = OpenSSHPublicKeyWriter()
@Test func ecdsa256MD5Fingerprint() {
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
@@ -18,7 +18,7 @@ import Testing
@Test func ecdsa256PublicKey() {
#expect(writer.openSSHString(secret: Constants.ecdsa256Secret) ==
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo= test@example.com")
}
@Test func ecdsa256Hash() {
@@ -35,7 +35,7 @@ import Testing
@Test func ecdsa384PublicKey() {
#expect(writer.openSSHString(secret: Constants.ecdsa384Secret) ==
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ== test@example.com")
}
@Test func ecdsa384Hash() {
@@ -44,11 +44,11 @@ import Testing
}
extension OpenSSHWriterTests {
extension OpenSSHPublicKeyWriterTests {
enum Constants {
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", algorithm: .ellipticCurve, keySize: 256, publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!)
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!)
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = 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"))
}

View File

@@ -1,40 +1,53 @@
import Cocoa
import OSLog
import Combine
import SecretKit
import SecureEnclaveSecretKit
import SmartCardSecretKit
import SecretAgentKit
import Brief
import Observation
import Common
@main
class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store())
return list
}()
private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private lazy var agent: Agent = {
Agent(storeList: storeList, witness: notifier)
}()
private lazy var socketController: SocketController = {
let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String
let path = URL.socketPath as String
return SocketController(path: path)
}()
private var updateSink: AnyCancellable?
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate")
func applicationDidFinishLaunching(_ aNotification: Notification) {
logger.debug("SecretAgent finished launching")
Task { @MainActor in
socketController.handler = { [agent] reader, writer in
await agent.handle(reader: reader, writer: writer)
Task {
let inputParser = try await XPCAgentInputParser()
for await session in socketController.sessions {
Task {
do {
for await message in session.messages {
let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance)
try session.write(agentResponse)
}
} catch {
try session.close()
}
}
}
}
Task {
@@ -48,6 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
updater.update
} onChange: { [updater, notifier] in
Task {
guard !updater.currentVersion.isTestBuild else { return }
await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release)
}

View File

@@ -9,22 +9,7 @@
<key>Website</key>
<string>https://github.com/maxgoedjen/secretive</string>
<key>Connections</key>
<array>
<dict>
<key>IsIncoming</key>
<false/>
<key>Host</key>
<string>api.github.com</string>
<key>NetworkProtocol</key>
<string>TCP</string>
<key>Port</key>
<string>443</string>
<key>Purpose</key>
<string>Secretive checks GitHub for new versions and security updates.</string>
<key>DenyConsequences</key>
<string>If you deny these connections, you will not be notified about new versions and critical security updates.</string>
</dict>
</array>
<array/>
<key>Services</key>
<array/>
</dict>

View File

@@ -10,8 +10,8 @@ final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate()
init() {
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: "update_notification_update_button"), options: [])
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: "update_notification_ignore_button"), options: [])
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: .updateNotificationUpdateButton), options: [])
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: .updateNotificationIgnoreButton), options: [])
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
@@ -22,7 +22,7 @@ final class Notifier: Sendable {
Measurement(value: 24, unit: UnitDuration.hours)
]
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: "persist_authentication_decline_button"), options: [])
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
var allPersistenceActions = [doNotPersistAction]
let formatter = DateComponentsFormatter()
@@ -41,7 +41,7 @@ final class Notifier: Sendable {
let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: [])
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
persistAuthenticationCategory.setValue(String(localized: "persist_authentication_accept_button"), forKey: "_actionsMenuTitle")
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
}
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate
@@ -64,12 +64,12 @@ final class Notifier: Sendable {
await notificationDelegate.state.setPending(secret: secret, store: store)
let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent()
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)")
notificationContent.subtitle = String(localized: "signed_notification_description_\(secret.name)")
notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name))
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.requiresAuthentication {
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
}
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
@@ -85,11 +85,11 @@ final class Notifier: Sendable {
let notificationContent = UNMutableNotificationContent()
if update.critical {
notificationContent.interruptionLevel = .critical
notificationContent.title = String(localized: "update_notification_update_critical_title_\(update.name)")
notificationContent.title = String(localized: .updateNotificationUpdateCriticalTitle(updateName: update.name))
} else {
notificationContent.title = String(localized: "update_notification_update_normal_title_\(update.name)")
notificationContent.title = String(localized: .updateNotificationUpdateNormalTitle(updateName: update.name))
}
notificationContent.subtitle = String(localized: "update_notification_update_description")
notificationContent.subtitle = String(localized: .updateNotificationUpdateDescription)
notificationContent.body = update.body
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)

View File

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

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>XPCService</key>
<dict>
<key>ServiceType</key>
<string>Application</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,17 @@
import Foundation
import OSLog
import XPCWrappers
import SecretAgentKit
final class SecretAgentInputParser: NSObject, XPCProtocol {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretAgentInputParser", category: "SecretAgentInputParser")
func process(_ data: Data) async throws -> SSHAgent.Request {
let parser = SSHAgentInputParser()
let result = try parser.parse(data: data)
logger.log("Parser parsed message as type \(result.debugDescription)")
return result
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
import XPCWrappers
let delegate = XPCServiceDelegate(exportedObject: SecretAgentInputParser())
let listener = NSXPCListener.service()
listener.delegate = delegate
listener.resume()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>iOSPackagesShouldBuildARM64e</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:Config/Secretive.xctestplan">
</TestPlanReference>
</TestPlans>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,77 +1,78 @@
import Cocoa
import SwiftUI
import SecretKit
import SecureEnclaveSecretKit
import SmartCardSecretKit
import Brief
extension EnvironmentValues {
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
private static let _agentStatusChecker = AgentStatusChecker()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
private static let _updater: any UpdaterProtocol = {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
return Updater(checkOnLaunch: hasRunSetup)
}()
@Entry var updater: any UpdaterProtocol = _updater
@MainActor var secretStoreList: SecretStoreList {
EnvironmentValues._secretStoreList
}
}
@main
struct Secretive: App {
private let justUpdatedChecker = JustUpdatedChecker()
@Environment(\.agentStatusChecker) var agentStatusChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@State private var showingCreation = false
@Environment(\.agentLaunchController) var agentLaunchController
@Environment(\.justUpdatedChecker) var justUpdatedChecker
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
ContentView()
.environment(EnvironmentValues._secretStoreList)
.onAppear {
if !hasRunSetup {
showingSetup = true
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
guard hasRunSetup else { return }
agentStatusChecker.check()
if agentStatusChecker.running && justUpdatedChecker.justUpdated {
// Relaunch the agent, since it'll be running from earlier update still
reinstallAgent()
} else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild {
forceLaunchAgent()
Task {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@AppStorage("explicitlyDisabled") var explicitlyDisabled = false
guard hasRunSetup && !explicitlyDisabled else { return }
agentLaunchController.check()
guard !agentLaunchController.developmentBuild else { return }
if justUpdatedChecker.justUpdatedBuild || !agentLaunchController.running {
// Relaunch the agent, since it'll be running from earlier update still
try await agentLaunchController.forceLaunch()
}
}
}
}
.commands {
AppCommands()
}
WindowGroup(id: String(describing: IntegrationsView.self)) {
IntegrationsView()
}
.windowResizability(.contentMinSize)
WindowGroup(id: String(describing: AboutView.self)) {
AboutView()
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
}
extension Secretive {
struct AppCommands: Commands {
@Environment(\.openWindow) var openWindow
@Environment(\.openURL) var openURL
@FocusedValue(\.showCreateSecret) var showCreateSecret
var body: some Commands {
CommandGroup(replacing: .appInfo) {
Button(.aboutMenuBarTitle, systemImage: "info.circle") {
openWindow(id: String(describing: AboutView.self))
}
}
CommandGroup(before: CommandGroupPlacement.appSettings) {
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
openWindow(id: String(describing: IntegrationsView.self))
}
}
CommandGroup(after: CommandGroupPlacement.newItem) {
Button("app_menu_new_secret_button") {
showingCreation = true
Button(.appMenuNewSecretButton, systemImage: "plus") {
showCreateSecret?()
}
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
.disabled(showCreateSecret?.isEnabled == false)
}
CommandGroup(replacing: .help) {
Button("app_menu_help_button") {
NSWorkspace.shared.open(Constants.helpURL)
}
}
CommandGroup(after: .help) {
Button("app_menu_setup_button") {
showingSetup = true
Button(.appMenuHelpButton) {
openURL(Constants.helpURL)
}
}
SidebarCommands()
@@ -80,33 +81,56 @@ struct Secretive: App {
}
extension Secretive {
private func reinstallAgent() {
justUpdatedChecker.check()
Task {
await LaunchAgentController().install()
try? await Task.sleep(for: .seconds(1))
agentStatusChecker.check()
if !agentStatusChecker.running {
forceLaunchAgent()
}
}
}
private func forceLaunchAgent() {
// We've run setup, we didn't just update, launchd is just not doing it's thing.
// Force a launch directly.
Task {
_ = await LaunchAgentController().forceLaunch()
agentStatusChecker.check()
}
}
}
private enum Constants {
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
}
extension EnvironmentValues {
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList()
let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store())
return list
}()
private static let _agentLaunchController = AgentLaunchController()
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
private static let _updater: any UpdaterProtocol = {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
return Updater(checkOnLaunch: hasRunSetup)
}()
@Entry var updater: any UpdaterProtocol = _updater
private static let _justUpdatedChecker = JustUpdatedChecker()
@Entry var justUpdatedChecker: any JustUpdatedCheckerProtocol = _justUpdatedChecker
@MainActor var secretStoreList: SecretStoreList {
EnvironmentValues._secretStoreList
}
}
extension FocusedValues {
@Entry var showCreateSecret: OpenSheet?
}
final class OpenSheet {
let closure: () -> Void
let isEnabled: Bool
init(isEnabled: Bool = true, closure: @escaping () -> Void) {
self.isEnabled = isEnabled
self.closure = closure
}
func callAsFunction() {
closure()
}
}

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

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